├── .release-script.yml ├── .storybook ├── addons.js ├── webpack.config.js └── config.js ├── docs ├── favicon.ico ├── main.06a73a738c4152e00753.bundle.js ├── main.f80707d9b6c6739d4452.bundle.js.map ├── index.html ├── runtime~main.6ac4c33d73ae9704bbb9.bundle.js ├── iframe.html ├── runtime~main.f80707d9b6c6739d4452.bundle.js ├── sb_dll │ └── storybook_ui_dll.LICENCE └── runtime~main.f80707d9b6c6739d4452.bundle.js.map ├── src ├── index.js ├── Histogram │ ├── __mocks__ │ │ ├── histogramBarGeometry.js │ │ └── histogramBinCalculator.js │ ├── histogramBarGeometry.test.js │ ├── histogramBinCalculator.js │ ├── histogramBarGeometry.js │ ├── BarTooltip.js │ ├── BarTooltip.test.js │ ├── __snapshots__ │ │ ├── BarTooltip.test.js.snap │ │ └── Histogram.test.js.snap │ ├── Histogram.scss │ ├── Histogram.test.js │ └── Histogram.js ├── index.test.js ├── __snapshots__ │ └── index.test.js.snap ├── common │ └── components │ │ └── Portal.js ├── constants.js ├── DensityChart │ ├── __snapshots__ │ │ ├── PlayButton.test.js.snap │ │ └── DensityChart.test.js.snap │ ├── PlayButton.test.js │ ├── DensityChart.test.js │ ├── PlayButton.js │ └── DensityChart.js ├── canvasRenderUtils.js ├── utils.js └── utils.test.js ├── .huskyrc ├── jest.setup.js ├── babel.config.js ├── .eslintrc ├── .travis.yml ├── jest.config.js ├── stories ├── sampleData.js └── index.stories.js ├── .babelrc ├── .npmignore ├── .gitignore ├── CONTRIBUTING.md ├── package.json ├── CHANGELOG.md ├── README.md └── LICENSE /.release-script.yml: -------------------------------------------------------------------------------- 1 | template: npm-module -------------------------------------------------------------------------------- /.storybook/addons.js: -------------------------------------------------------------------------------- 1 | // Storybook addons 2 | 3 | import '@storybook/addon-knobs/register'; 4 | -------------------------------------------------------------------------------- /docs/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/feedzai/brushable-histogram/HEAD/docs/favicon.ico -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Histogram from "./Histogram/Histogram"; 2 | 3 | export default Histogram; 4 | -------------------------------------------------------------------------------- /.huskyrc: -------------------------------------------------------------------------------- 1 | { 2 | "hooks": { 3 | "post-commit": "npm run build && git add . && git commit -m '🐕 [husky] Update documentation'", 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /docs/main.06a73a738c4152e00753.bundle.js: -------------------------------------------------------------------------------- 1 | (window.webpackJsonp=window.webpackJsonp||[]).push([[0],{390:function(n,o,p){p(391),p(468),n.exports=p(838)},468:function(n,o,p){"use strict";p.r(o);p(469)}},[[390,1,2]]]); -------------------------------------------------------------------------------- /src/Histogram/__mocks__/histogramBarGeometry.js: -------------------------------------------------------------------------------- 1 | export function calculatePositionAndDimensions() { 2 | return { 3 | height: 10, 4 | width: 10, 5 | x: 1, 6 | y: 1 7 | }; 8 | } 9 | -------------------------------------------------------------------------------- /jest.setup.js: -------------------------------------------------------------------------------- 1 | import { configure } from "enzyme"; 2 | import Adapter from "enzyme-adapter-react-16"; 3 | 4 | // This file is necessary to use enzyme in the unit tests 5 | 6 | configure({ adapter: new Adapter() }); 7 | -------------------------------------------------------------------------------- /.storybook/webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require("path"); 2 | 3 | module.exports = { 4 | module: { 5 | rules: [{ 6 | test: /\.scss$/, 7 | loaders: ["style-loader", "css-loader", "sass-loader"], 8 | include: path.resolve(__dirname, "../") 9 | }] 10 | } 11 | }; 12 | -------------------------------------------------------------------------------- /.storybook/config.js: -------------------------------------------------------------------------------- 1 | import { configure } from "@storybook/react"; 2 | 3 | // automatically import all files ending in *.stories.js 4 | const req = require.context("../stories", true, /.stories.js$/); 5 | 6 | function loadStories() { 7 | req.keys().forEach(filename => req(filename)); 8 | } 9 | 10 | configure(loadStories, module); 11 | -------------------------------------------------------------------------------- /src/index.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { shallow } from "enzyme"; 3 | import Histogram from "./index"; 4 | 5 | /** 6 | * @author Victor Fernandes (victor.fernandes@feedzai.com) 7 | */ 8 | 9 | describe("index", () => { 10 | test("should render the histogram component", () => { 11 | expect(shallow()).toMatchSnapshot(); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/__snapshots__/index.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`index should render the histogram component 1`] = ` 4 | 15 | `; 16 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | const presets = [ 2 | ["@babel/env", { 3 | targets: { 4 | edge: "17", 5 | firefox: "60", 6 | chrome: "67", 7 | safari: "11.1" 8 | }, 9 | useBuiltIns: "usage" 10 | }] 11 | ]; 12 | 13 | const plugins = [ 14 | "@babel/plugin-transform-react-jsx", 15 | "@babel/plugin-proposal-class-properties" 16 | ]; 17 | 18 | module.exports = { presets, plugins }; 19 | -------------------------------------------------------------------------------- /docs/main.f80707d9b6c6739d4452.bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"main.f80707d9b6c6739d4452.bundle.js","sources":["webpack:///./.storybook/config.js"],"sourcesContent":["import { configure } from \"@storybook/react\";\n\n// automatically import all files ending in *.stories.js\nconst req = require.context(\"../stories\", true, /.stories.js$/);\n\nfunction loadStories() {\n req.keys().forEach(filename => req(filename));\n}\n\nconfigure(loadStories, module);\n"],"mappings":"AAGA","sourceRoot":""} -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "globals": {}, 3 | "env": { 4 | "es6": true, 5 | "browser": true 6 | }, 7 | "extends": ["@feedzai/eslint-config-feedzai-react"], 8 | "parser": "babel-eslint", 9 | "parserOptions": { 10 | "ecmaVersion": 6, 11 | "sourceType": "module", 12 | "ecmaFeatures": { 13 | "jsx": true, 14 | "experimentalObjectRestSpread": true 15 | } 16 | }, 17 | "plugins": [ 18 | "react" 19 | ] 20 | } 21 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | 3 | sudo: false 4 | 5 | # Travis is configured to run on pushed branches and pull requests so if we don't filter branches it runs twice when 6 | # we push the PR branch in our repo 7 | branches: 8 | only: 9 | - master 10 | #tag xx.yy-zz or xx.yy.zz-anythinglikeexperimental 11 | - /^\d+\.\d+(\.\d+)?(-\S*)?$/ 12 | 13 | node_js: 14 | - "8" 15 | 16 | script: 17 | - npm run lint 18 | - npm run test 19 | 20 | after_script: npm run coveralls 21 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | verbose: false, 3 | collectCoverage: true, 4 | moduleDirectories: [ 5 | "node_modules", 6 | "/src" 7 | ], 8 | roots: [ 9 | "/src" 10 | ], 11 | collectCoverageFrom: [ 12 | "src/**/*.js" 13 | ], 14 | coverageDirectory: "/coverage", 15 | transformIgnorePatterns: [ 16 | "/node_modules/" 17 | ], 18 | setupFilesAfterEnv: ["/jest.setup.js"], 19 | snapshotSerializers: ["enzyme-to-json/serializer"], 20 | coverageReporters: ["html", "lcov", "clover"] 21 | }; 22 | -------------------------------------------------------------------------------- /stories/sampleData.js: -------------------------------------------------------------------------------- 1 | const NUMBER_OF_POINTS = 1000; 2 | const startTimestamp = 1533164400000; 3 | 4 | /** 5 | * Calculates some sample data 6 | * 7 | * @param {number} numberOfPoints 8 | * @returns {Array.} 9 | */ 10 | export function calculate(numberOfPoints) { 11 | const data = []; 12 | 13 | for (let i = 0; i < numberOfPoints; i++) { 14 | data.push({ 15 | "timestamp": startTimestamp + Math.abs(Math.floor(Math.cos(i) * 1000000000)), 16 | "total": Math.abs(Math.sin(i)) 17 | }); 18 | } 19 | 20 | return data; 21 | } 22 | 23 | export default calculate(NUMBER_OF_POINTS); 24 | 25 | export const smallSample = calculate(10); 26 | -------------------------------------------------------------------------------- /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | ["@babel/env", { 4 | "targets": { 5 | "edge": "17", 6 | "firefox": "60", 7 | "chrome": "67", 8 | "safari": "11.1" 9 | }, 10 | "useBuiltIns": "usage" 11 | }] 12 | ], 13 | "plugins": [ 14 | "@babel/plugin-transform-react-jsx", 15 | "@babel/plugin-proposal-class-properties", 16 | "@babel/transform-runtime" 17 | ], 18 | 19 | "env": { 20 | "production": { 21 | "ignore": [ 22 | "**/*.test.js", 23 | "**/__mocks__" 24 | ] 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Histogram/histogramBarGeometry.test.js: -------------------------------------------------------------------------------- 1 | import { calculatePositionAndDimensions } from "./histogramBarGeometry"; 2 | 3 | describe("calculatePositionAndDimensions", () => { 4 | it("should calculate the position and dimensions for the given bar", () => { 5 | const xScale = (x) => x; 6 | const yScale = (y) => y; 7 | const bar = { 8 | x0: 0, 9 | x1: 40, 10 | yValue: 10 11 | }; 12 | const heightForBars = 100; 13 | const margin = 2; 14 | 15 | expect(calculatePositionAndDimensions({ xScale, yScale, heightForBars, margin, bar })).toEqual({ 16 | height: 90, 17 | width: 38, 18 | x: 1, 19 | y: 10 20 | }); 21 | }); 22 | }); 23 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Ignoring irrelevant files for the npm deploying 2 | # files like: 3 | # - documentation & storybook 4 | docs 5 | .storybook 6 | stories 7 | 8 | # - source files 9 | src 10 | 11 | #ide specific folders 12 | .vscode 13 | .idea 14 | 15 | # Logs 16 | logs 17 | *.log 18 | npm-debug.log* 19 | yarn-debug.log* 20 | yarn-error.log* 21 | 22 | # Runtime data 23 | pids 24 | *.pid 25 | *.seed 26 | *.pid.lock 27 | 28 | # Dependency directories 29 | node_modules/ 30 | jspm_packages/ 31 | 32 | # TypeScript v1 declaration files 33 | typings/ 34 | 35 | # Optional npm cache directory 36 | .npm 37 | 38 | # Optional eslint cache 39 | .eslintcache 40 | 41 | # Optional REPL history 42 | .node_repl_history 43 | 44 | # Output of 'npm pack' 45 | *.tgz 46 | 47 | # Yarn Integrity file 48 | .yarn-integrity 49 | 50 | # OSX metadata files 51 | .DS_Store 52 | -------------------------------------------------------------------------------- /src/Histogram/histogramBinCalculator.js: -------------------------------------------------------------------------------- 1 | import { histogram as d3Histogram } from "d3-array"; 2 | 3 | /** 4 | * histogramBinCalculator 5 | * 6 | * This module contains the histogram bin calculation logic. 7 | * 8 | * @author Luis Cardoso (luis.cardoso@feedzai.com) 9 | */ 10 | 11 | export default ({ xAccessor, yAccessor, histogramChartXScale, defaultBarCount, data }) => { 12 | // Setting the histogram function/converter 13 | const histogram = d3Histogram() 14 | .value(xAccessor) 15 | .domain(histogramChartXScale.domain()) // using the x-axis domain 16 | .thresholds(histogramChartXScale.ticks(defaultBarCount)); 17 | 18 | // Calculating the time histogram bins 19 | return histogram(data).map((bar) => { 20 | const yValue = bar.reduce((sum, curr) => sum + yAccessor(curr), 0); 21 | 22 | return { ...bar, yValue }; 23 | }); 24 | }; 25 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Ignoring "compiled" files from the github 2 | lib 3 | 4 | #ide specific folders 5 | .vscode 6 | .idea 7 | 8 | # Logs 9 | logs 10 | *.log 11 | npm-debug.log* 12 | yarn-debug.log* 13 | yarn-error.log* 14 | 15 | # Runtime data 16 | pids 17 | *.pid 18 | *.seed 19 | *.pid.lock 20 | 21 | # Directory for instrumented libs generated by jscoverage/JSCover 22 | lib-cov 23 | 24 | # Coverage directory used by tools like istanbul 25 | coverage 26 | 27 | # Compiled binary addons (https://nodejs.org/api/addons.html) 28 | build/Release 29 | 30 | # Dependency directories 31 | node_modules/ 32 | jspm_packages/ 33 | 34 | # TypeScript v1 declaration files 35 | typings/ 36 | 37 | # Optional npm cache directog 38 | # Optional eslint cache 39 | .eslintcache 40 | 41 | # Optional REPL history 42 | .node_repl_history 43 | 44 | # Output of 'npm pack' 45 | *.tgz 46 | 47 | # Yarn Integrity file 48 | .yarn-integrity 49 | 50 | # OSX metadata files 51 | .DS_Store 52 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # How to contribute to Brushable Histogram 2 | 3 | This repository is set up to work under the traditional fork + Pull Request model. 4 | 5 | ## CI/CD 6 | Quality is validated in pull requests using [travis-ci.com](https://travis-ci.com/feedzai/brushable-histogram) using the configuration that you can see in the [Travis YAML](https://raw.githubusercontent.com/feedzai/brushable-histogram/master/.travis.yml). 7 | The `script` phase of the Travis lifecycle will run the linter and tests. The `after_script` phase sends to coverage results to [coveralls.io](https://coveralls.io/github/feedzai/brushable-histogram). 8 | 9 | ## Merging 10 | Pull requests with failing builds will not be merged, and coverage is expected to be above 80%. 11 | 12 | ## Releasing 13 | Releases are responsability of the maintainers. When releasing maintainers should: 14 | - Make sure `CHANGELOG.md` is updated 15 | - Create a git tag 16 | - Create a new npm version 17 | - Publish to npm 18 | - Push the branch 19 | -------------------------------------------------------------------------------- /docs/index.html: -------------------------------------------------------------------------------- 1 | Storybook
-------------------------------------------------------------------------------- /src/common/components/Portal.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import ReactDOM from "react-dom"; 4 | 5 | /** 6 | * Portal 7 | * 8 | * Creates a React Portal to insert a child in a DOM node outside the main DOM hierarchy. 9 | * The new DOM node is inserted as a child of the body node. 10 | * 11 | * @author Nuno Neves (nuno.neves@feedzai.com) 12 | */ 13 | 14 | const propTypes = { 15 | children: PropTypes.element 16 | }; 17 | const defaultProps = { 18 | children: null 19 | }; 20 | 21 | export default class Portal extends React.PureComponent { 22 | constructor(props) { 23 | super(props); 24 | 25 | this.el = document.createElement("div"); 26 | } 27 | 28 | componentDidMount() { 29 | document.body.appendChild(this.el); 30 | } 31 | 32 | componentWillUnmount() { 33 | document.body.removeChild(this.el); 34 | } 35 | 36 | render() { 37 | return ReactDOM.createPortal(this.props.children, this.el); 38 | } 39 | } 40 | 41 | Portal.propTypes = propTypes; 42 | Portal.defaultProps = defaultProps; 43 | -------------------------------------------------------------------------------- /src/constants.js: -------------------------------------------------------------------------------- 1 | /** 2 | * contants 3 | * 4 | * Contains contants used for rendering. 5 | * 6 | * @author Beatriz Malveiro Jorge (beatriz.jorge@feedzai.com) 7 | * @author Victor Fernandes (victor.fernandes@feedzai.com) 8 | * @author Luis Cardoso (luis.cardoso@feedzai.com) 9 | */ 10 | 11 | // We reserve some space for the x adn y axis ticks. 12 | export const X_AXIS_HEIGHT = 18; 13 | export const X_AXIS_PADDING = .02; 14 | export const Y_AXIS_PADDING = 3; 15 | export const BUTTON_PADDING = 20; 16 | 17 | // We place as many ticks as a third of the number of bars, enough to give context and not overlap. 18 | export const BARS_TICK_RATIO = 3; 19 | 20 | export const MIN_ZOOM_VALUE = 1; 21 | 22 | // The density chart has a fixed height 23 | export const DENSITY_CHART_HEIGHT_PX = 20; 24 | 25 | // The minimum total height of the chart 26 | export const MIN_TOTAL_HEIGHT = 100; 27 | 28 | // An internal magic value used to align things horizontally 29 | export const PADDING = 10; 30 | 31 | // Histogram bar tooltip size constants 32 | export const HISTOGRAM_BAR_TOOLTIP_WIDTH = 100; 33 | export const HISTOGRAM_BAR_TOOLTIP_HEIGHT = 40; 34 | -------------------------------------------------------------------------------- /src/Histogram/histogramBarGeometry.js: -------------------------------------------------------------------------------- 1 | /** 2 | * histogramBarGeometry 3 | * 4 | * This module contains the histogram bar position and dimension calculation. 5 | * 6 | * @author Luis Cardoso (luis.cardoso@feedzai.com) 7 | */ 8 | 9 | /** 10 | * Calculates the dimensions for the given `bar` given the scales and other parameters. 11 | * @returns {Object} 12 | */ 13 | export function calculateDimensions({ xScale, yScale, heightForBars, margin, bar }) { 14 | const width = xScale(bar.x1) 15 | - xScale(bar.x0) - margin; 16 | const height = heightForBars - yScale(bar.yValue); 17 | 18 | return { width, height }; 19 | } 20 | 21 | /** 22 | * Calculates the position and dimensions for the given `bar` given the scales and other parameters. 23 | * @returns {Object} 24 | */ 25 | export function calculatePositionAndDimensions({ xScale, yScale, heightForBars, margin, bar }) { 26 | const { width, height } = calculateDimensions({ xScale, yScale, heightForBars, margin, bar }); 27 | 28 | const x = xScale(bar.x0) + margin / 2; 29 | const y = yScale(bar.yValue); 30 | 31 | return { 32 | width, 33 | height, 34 | x, 35 | y 36 | }; 37 | } 38 | -------------------------------------------------------------------------------- /src/DensityChart/__snapshots__/PlayButton.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`render does a baseline render 1`] = ` 4 | 13 | 38 | 39 | `; 40 | -------------------------------------------------------------------------------- /docs/runtime~main.6ac4c33d73ae9704bbb9.bundle.js: -------------------------------------------------------------------------------- 1 | !function(e){function r(r){for(var n,f,i=r[0],l=r[1],a=r[2],c=0,s=[];c` element. 7 | * 8 | * @author Beatriz Malveiro Jorge (beatriz.jorge@feedzai.com) 9 | * @author Victor Fernandes (victor.fernandes@feedzai.com) 10 | * @author Luis Cardoso (luis.cardoso@feedzai.com) 11 | */ 12 | 13 | /** 14 | * Returns the canvas 2d context of the given canvas element. 15 | * 16 | * We have this call in a separated method so that it can stubbed in unit tests. 17 | * 18 | * @param {HTMLElement} element 19 | * @returns {Object} 20 | */ 21 | export function getRenderContext(element) { 22 | return element.getContext("2d"); 23 | } 24 | 25 | /** 26 | * Clears the given canvas. 27 | * @param {Object} context 28 | * @param {number} width 29 | * @param {number} height 30 | */ 31 | export function clearCanvas(context, width, height) { 32 | context.save(); 33 | context.clearRect(0, 0, width, height); 34 | } 35 | 36 | /** 37 | * Renders a rectangle in Canvas 38 | * 39 | * @param {Object} canvasContext 40 | * @param {number} x 41 | * @param {number} y 42 | * @param {number} width 43 | * @param {number} height 44 | * @param {Object} options 45 | */ 46 | export function drawRect(canvasContext, x = 0, y = 0, width = 0, height = 0, options = null) { 47 | canvasContext.beginPath(); 48 | 49 | canvasContext.fillStyle = isObject(options) && (options.fillStyle) ? options.fillStyle : "transparent"; 50 | 51 | canvasContext.fillRect(x, y, width, height); 52 | } 53 | -------------------------------------------------------------------------------- /src/Histogram/BarTooltip.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import PropTypes from "prop-types"; 3 | import Portal from "../common/components/Portal"; 4 | import { HISTOGRAM_BAR_TOOLTIP_WIDTH, HISTOGRAM_BAR_TOOLTIP_HEIGHT } from "../constants"; 5 | 6 | /** 7 | * BarTootip 8 | * 9 | * Renders the histogram bar tooltip that allows to display information about a specific bar. 10 | * The tooltip content is customizable and is provided by the tooltipBarCustomization custom render function. 11 | * 12 | * @author Nuno Neves (nuno.neves@feedzai.com) 13 | */ 14 | 15 | const propsTypes = { 16 | selectedBarPosition: PropTypes.shape({ 17 | top: PropTypes.number, 18 | left: PropTypes.number, 19 | width: PropTypes.number 20 | }), 21 | currentBar: PropTypes.object.isRequired, 22 | tooltipBarCustomization: PropTypes.func.isRequired 23 | }; 24 | const defaultProps = { 25 | selectedBarPosition: { 26 | top: 0, 27 | left: 0, 28 | width: 0 29 | } 30 | }; 31 | 32 | function BarTooltip({ selectedBarPosition, currentBar, tooltipBarCustomization }) { 33 | if (typeof tooltipBarCustomization !== "function") { 34 | return null; 35 | } 36 | 37 | const { top, left, width } = selectedBarPosition; 38 | const tooltipStyle = { 39 | position: "fixed", 40 | left: `${left - (HISTOGRAM_BAR_TOOLTIP_WIDTH / 2) + (width / 2)}px`, 41 | top: `${top - HISTOGRAM_BAR_TOOLTIP_HEIGHT}px` 42 | }; 43 | 44 | const tooltipElement = tooltipBarCustomization(currentBar); 45 | 46 | return ( 47 | 48 |
52 | {tooltipElement} 53 |
54 |
55 | ); 56 | } 57 | 58 | BarTooltip.propTypes = propsTypes; 59 | BarTooltip.defaultProps = defaultProps; 60 | 61 | export default React.memo(BarTooltip); 62 | -------------------------------------------------------------------------------- /src/Histogram/BarTooltip.test.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import { mount } from "enzyme"; 3 | import { timeFormat } from "d3-time-format"; 4 | import BarTooltip from "./BarTooltip"; 5 | import { smallSample } from "../../stories/sampleData"; 6 | 7 | /** 8 | * BarTootip 9 | * 10 | * Renders the histogram bar tooltip that allows to display information about a specific bar. 11 | * The tooltip content is customizable and is provided by the tooltipBarCustomization custom render function. 12 | * 13 | * @author Nuno Neves (nuno.neves@feedzai.com) 14 | */ 15 | 16 | const formatMinute = timeFormat("%I:%M"); 17 | 18 | function histogramTooltipBar(bar) { 19 | return ( 20 | 21 |
22 | {bar.yValue} Events 23 |
24 |
25 | {`${formatMinute(bar.x0)} - ${formatMinute(bar.x1)}`} 26 |
27 |
28 | ); 29 | } 30 | 31 | const selectedBarPosition = { 32 | top: 100, 33 | left: 50, 34 | width: 20 35 | }; 36 | 37 | describe("Histogram/BarTooltip", () => { 38 | describe("render", () => { 39 | test("should return null if props.tooltipBarCustomization is not a function", () => { 40 | const wrapper = mount( 41 | 46 | ); 47 | 48 | expect(wrapper).toMatchSnapshot(); 49 | }); 50 | 51 | test("should render the tooltip", () => { 52 | const wrapper = mount( 53 | 58 | ); 59 | 60 | expect(wrapper).toMatchSnapshot(); 61 | }); 62 | }); 63 | }); 64 | -------------------------------------------------------------------------------- /docs/iframe.html: -------------------------------------------------------------------------------- 1 | Storybook

No Preview

Sorry, but you either have no stories or none are selected somehow.

  • Please check the Storybook config.
  • Try reloading the page.

If the problem persists, check the browser console, or the terminal you've run Storybook from.

-------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@feedzai/brushable-histogram", 3 | "version": "1.2.2", 4 | "description": "A time histogram with a time brush that renders a summary of the events", 5 | "main": "lib/index.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/feedzai/brushable-histogram.git" 9 | }, 10 | "scripts": { 11 | "test": "jest --coverage", 12 | "test:watch": "jest --watch", 13 | "lint": "eslint src/{,**/}*.js", 14 | "build": "npm run build-bundle && npm run build-storybook", 15 | "build-bundle": "NODE_ENV=production babel src --verbose --out-dir lib && node-sass src/Histogram/Histogram.scss lib/css/brushable-histogram.css", 16 | "storybook": "start-storybook -p 6006", 17 | "build-storybook": "rm -rf docs && build-storybook -o docs", 18 | "publish:dry": "fdz-deploy --dry", 19 | "publish:major": "fdz-deploy --major", 20 | "publish:minor": "fdz-deploy --minor", 21 | "publish:patch": "fdz-deploy --patch", 22 | "coveralls": "cat ./coverage/lcov.info | coveralls" 23 | }, 24 | "authors": [ 25 | "beatriz.jorge@feedzai.com", 26 | "victor.fernandes@feedzai.com", 27 | "luis.cardoso@feedzai.com", 28 | "marlom.girardi@feedzai.com" 29 | ], 30 | "dependencies": { 31 | "d3-array": "1.2.1", 32 | "d3-axis": "1.0.8", 33 | "d3-brush": "3.0.0", 34 | "d3-scale": "4.0.2", 35 | "d3-selection": "1.3.0", 36 | "d3-time": "1.0.8", 37 | "d3-time-format": "2.1.1", 38 | "d3-zoom": "3.0.0", 39 | "react-sizeme": "2.5.2" 40 | }, 41 | "devDependencies": { 42 | "@babel/cli": "^7.1.5", 43 | "@babel/core": "^7.1.6", 44 | "@babel/plugin-proposal-class-properties": "^7.1.0", 45 | "@babel/plugin-transform-react-jsx": "^7.1.6", 46 | "@babel/preset-env": "^7.1.6", 47 | "@feedzai/eslint-config-feedzai-react": "3.1.0", 48 | "@storybook/addon-knobs": "^5.0.6", 49 | "@storybook/addons": "^5.0.6", 50 | "@storybook/react": "^5.0.6", 51 | "babel-core": "^7.0.0-bridge.0", 52 | "babel-eslint": "^10.0.1", 53 | "babel-jest": "^24.7.1", 54 | "babel-loader": "^8.0.4", 55 | "coveralls": "^3.0.2", 56 | "enzyme": "^3.11.0", 57 | "enzyme-adapter-react-16": "^1.15.2", 58 | "enzyme-to-json": "^3.4.4", 59 | "eslint": "^5.9.0", 60 | "eslint-plugin-jest": "^22.0.1", 61 | "eslint-plugin-jsx-a11y": "^6.1.2", 62 | "eslint-plugin-react": "^7.11.1", 63 | "husky": "^1.3.1", 64 | "jest": "^24.7.1", 65 | "node-sass": "^4.10.0", 66 | "react": "16.6.1", 67 | "react-dom": "16.6.1", 68 | "sass-loader": "^7.1.0" 69 | }, 70 | "peerDependencies": { 71 | "react": "16.x", 72 | "react-dom": "16.x" 73 | }, 74 | "licence": "APACHE-2.0" 75 | } 76 | -------------------------------------------------------------------------------- /docs/runtime~main.f80707d9b6c6739d4452.bundle.js: -------------------------------------------------------------------------------- 1 | !function(modules){function webpackJsonpCallback(data){for(var moduleId,chunkId,chunkIds=data[0],moreModules=data[1],executeModules=data[2],i=0,resolves=[];i { 11 | moveBrush = jest.fn(); 12 | xAccessor = (elm) => elm.timestamp; 13 | width = 1000; 14 | brushDomainMin = d3Min(smallSample, xAccessor); 15 | brushDomainMax = d3Max(smallSample, xAccessor); 16 | densityChartXScale = scaleTime() 17 | .domain([ brushDomainMin, brushDomainMax]) 18 | .range([ 0, width ]); 19 | 20 | wrapper = mount(); 27 | instance = wrapper.instance(); 28 | }); 29 | 30 | describe("_onClickPlay", () => { 31 | it("sets the frameEnd at the start if brush max is at the end", () => { 32 | instance._onClickPlay(); 33 | 34 | expect(instance.frameEnd).toBe(0); 35 | }); 36 | 37 | it("sets the frameEnd at brush max otherwise", () => { 38 | const brushDomainMeddian = Math.floor((brushDomainMax - brushDomainMin) / 2 + brushDomainMin); 39 | 40 | const testWrapper = mount(); 47 | const testInstance = testWrapper.instance(); 48 | 49 | testInstance._onClickPlay(); 50 | 51 | expect(testInstance.frameEnd).toBe(500); 52 | }); 53 | }); 54 | 55 | describe("_playFrame", () => { 56 | it("stops if the frame end is after the end", () => { 57 | instance._stopLapse = jest.fn(); 58 | 59 | instance.frameEnd = 1001; 60 | 61 | instance._playFrame(0, 1000, 100); 62 | 63 | expect(instance._stopLapse.mock.calls.length).toBe(1); 64 | }); 65 | 66 | it("sets the frame end to end if the next frame would finish after the end", () => { 67 | instance._stopLapse = jest.fn(); 68 | 69 | instance.frameEnd = 950; 70 | 71 | instance._playFrame(0, 1000, 100); 72 | 73 | expect(instance.frameEnd).toBe(1000); 74 | }); 75 | 76 | it("moves the frame by step if it has enough space", () => { 77 | instance._stopLapse = jest.fn(); 78 | 79 | instance.frameEnd = 800; 80 | 81 | instance._playFrame(0, 1000, 100); 82 | 83 | expect(instance.frameEnd).toBe(900); 84 | }); 85 | }); 86 | 87 | describe("render", () => { 88 | it("does a baseline render", () => { 89 | expect(wrapper).toMatchSnapshot(); 90 | }); 91 | }); 92 | -------------------------------------------------------------------------------- /docs/sb_dll/storybook_ui_dll.LICENCE: -------------------------------------------------------------------------------- 1 | /**! 2 | * @fileOverview Kickass library to create and place poppers near their reference elements. 3 | * @version 1.14.7 4 | * @license 5 | * Copyright (c) 2016 Federico Zivolo and contributors 6 | * 7 | * Permission is hereby granted, free of charge, to any person obtaining a copy 8 | * of this software and associated documentation files (the "Software"), to deal 9 | * in the Software without restriction, including without limitation the rights 10 | * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | * copies of the Software, and to permit persons to whom the Software is 12 | * furnished to do so, subject to the following conditions: 13 | * 14 | * The above copyright notice and this permission notice shall be included in all 15 | * copies or substantial portions of the Software. 16 | * 17 | * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | * SOFTWARE. 24 | */ 25 | 26 | /*! 27 | * https://github.com/es-shims/es5-shim 28 | * @license es5-shim Copyright 2009-2015 by contributors, MIT License 29 | * see https://github.com/es-shims/es5-shim/blob/master/LICENSE 30 | */ 31 | 32 | /*! 33 | * isobject 34 | * 35 | * Copyright (c) 2014-2017, Jon Schlinkert. 36 | * Released under the MIT License. 37 | */ 38 | 39 | /*! 40 | * https://github.com/paulmillr/es6-shim 41 | * @license es6-shim Copyright 2013-2016 by Paul Miller (http://paulmillr.com) 42 | * and contributors, MIT License 43 | * es6-shim: v0.35.4 44 | * see https://github.com/paulmillr/es6-shim/blob/0.35.3/LICENSE 45 | * Details and documentation: 46 | * https://github.com/paulmillr/es6-shim/ 47 | */ 48 | 49 | /** @license React v16.8.1 50 | * react.production.min.js 51 | * 52 | * Copyright (c) Facebook, Inc. and its affiliates. 53 | * 54 | * This source code is licensed under the MIT license found in the 55 | * LICENSE file in the root directory of this source tree. 56 | */ 57 | 58 | /** @license React v0.13.1 59 | * scheduler.production.min.js 60 | * 61 | * Copyright (c) Facebook, Inc. and its affiliates. 62 | * 63 | * This source code is licensed under the MIT license found in the 64 | * LICENSE file in the root directory of this source tree. 65 | */ 66 | 67 | /* 68 | object-assign 69 | (c) Sindre Sorhus 70 | @license MIT 71 | */ 72 | 73 | /** @license React v16.8.1 74 | * react-dom.production.min.js 75 | * 76 | * Copyright (c) Facebook, Inc. and its affiliates. 77 | * 78 | * This source code is licensed under the MIT license found in the 79 | * LICENSE file in the root directory of this source tree. 80 | */ 81 | 82 | /*! 83 | Copyright (c) 2016 Jed Watson. 84 | Licensed under the MIT License (MIT), see 85 | http://jedwatson.github.io/classnames 86 | */ 87 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | # Brushable Histogram 2 | 3 | ## Unreleased 4 | - Move `react` in `package.json` from `dependency` to `peerDependency`. 5 | - Promote the histogram bar tooltip DOM node to be a child of document.body. 6 | 7 | ## 1.2.1 (2019/04/9) 8 | - Allow the user to define a custom `brushDomain` via `props`. 9 | - Update Storybook to 5.x 10 | - The scales are updared if `yAccessor` or `xAccessor` are changed 11 | - Update eslint config 12 | 13 | ## 1.2.0 14 | - This version is broken, do not use it. 15 | 16 | ## 1.1.10 (2019/03/29) 17 | 18 | - Fix Histogram's `onIntervalChange` method not being called when the graph's histogram data changes (and the graph itself) but the domain min and max remains the same. This change will cause a little degradation in the brushing performance, since the `setState` method will be invoke many times, even when there was no changes in the brush range. We will try to address this side effect in the next version. 19 | 20 | ## 1.1.9 (2019/03/20) 21 | 22 | - Fix a bug related with data replacement on update ([#36](https://github.com/feedzai/brushable-histogram/pull/36#issue-262504945)) 23 | 24 | ## 1.1.8 (2019/03/15) 25 | 26 | - Fix a bug related with screen resizing events 27 | 28 | ## 1.1.7 (2019/02/19) 29 | - Fix a bug related with the zooming interactions between the histogram 30 | and brushing areas ([#26](https://github.com/feedzai/brushable-histogram/issues/26)) 31 | - Fix bug related with brush size resizing ([#30](https://github.com/feedzai/brushable-histogram/issues/30)) 32 | - Exports a css file 33 | - Ignoring directories that are irrelevant for this component releases. 34 | Like unit tests, documentation and storybook files. 35 | - Updates .babelrc file in order to ship the source code without unit 36 | tests. 37 | - Fixes Javascript import path. 38 | 39 | ## 1.1.6 - BUMP 40 | 41 | ## 1.1.5 (2018/12/26) 42 | - Adds missing check that cause the histogram to blow-up in some cases 43 | 44 | ## 1.1.4 (2018/12/26) 45 | - Handles the case when `props.data` is empty 46 | - Fixes error when switching between storybook stores 47 | 48 | ## 1.1.3 (2018/12/23) 49 | - Fixes tooltip not rendering properlly (it appeared and disappeared) 50 | 51 | ## 1.1.2 (2018/12/19) 52 | - Reduces the minimum height to 100 pixels to allow for some internal 53 | Feedzai use cases 54 | 55 | ## 1.1.1 (2018/12/17) 56 | - Publishes the `lib` folder 57 | 58 | ## 1.1.0 (2018/12/17) 59 | - Document the props and have better defaults 60 | - Add more unit tests (still missing for Histogram.js) 61 | - Extract the "timeline" to another module 62 | - Remove the dependency on andtd 63 | - Remove the dependency on lodash 64 | - Stop `_playLapseAtInterval` if the component was unmounted 65 | - Improve method order inside main component 66 | - Add missing jsdoc 67 | - Remove the `randomString` key hack 68 | - Make the play button optional 69 | - Benchmark with a lot of nodes 70 | - Initial render is relativelly fast with 100k data points 71 | - Tooltip highlight works smoothly with 300k data points 72 | - Brushing works with 70k data points, and smoothly with 25k data 73 | points 74 | - Handle different widths and heights correctly 75 | - Remove global selectors 76 | - Create a static page with the story book 77 | - Added a `CONTRIBUTING.md` 78 | - Setup CI with coverage and badges 79 | 80 | ## 1.0.5 (2018/12/05) 81 | - Removes usage of `.toString()` to avoid requiring core-js. 82 | 83 | ## 1.0.4 (2018/12/05) 84 | - Reduces dependencies of the transpiled code to reduce the bundle size 85 | of the package consumers. 86 | 87 | ## 1.0.3 (2018/12/04) 88 | - Adds missing antd dependency 89 | 90 | ## 1.0.2 (2018/11/30) 91 | - Adds a build step to compile the src files 92 | 93 | ## 1.0.1 (2018/11/30) 94 | - Fixes index.js link and changes the dependencies version to match the 95 | ones in genome 96 | 97 | ## 1.0.0 (2018/11/30) 98 | - Copied the implementation from Genome 99 | - Copied the styles from Genome 100 | - Added a basic story to demo the histogram 101 | - Added a basic unit test 102 | -------------------------------------------------------------------------------- /src/Histogram/__snapshots__/BarTooltip.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Histogram/BarTooltip render should render the tooltip 1`] = ` 4 | 58 | 59 | 62 |
66 |
67 | Events 68 |
69 |
70 | 12:NaN - 12:NaN 71 |
72 |
73 | 74 | } 75 | > 76 |
86 |
87 | Events 88 |
89 |
90 | 12:NaN - 12:NaN 91 |
92 |
93 |
94 |
95 |
96 | `; 97 | 98 | exports[`Histogram/BarTooltip render should return null if props.tooltipBarCustomization is not a function 1`] = ` 99 | 153 | `; 154 | -------------------------------------------------------------------------------- /src/Histogram/__mocks__/histogramBinCalculator.js: -------------------------------------------------------------------------------- 1 | export default () => [ 2 | { 3 | "0": { 4 | "timestamp": new Date(1533309900034), 5 | "total": 0.9893582466233818 6 | }, 7 | "x0": new Date(1533294000000), 8 | "x1": new Date(1533337200000), 9 | "yValue": 0.9893582466233818 10 | }, 11 | { 12 | "x0": new Date(1533337200000), 13 | "x1": new Date(1533380400000), 14 | "yValue": 0 15 | }, 16 | { 17 | "x0": new Date(1533380400000), 18 | "x1": new Date(1533423600000), 19 | "yValue": 0 20 | }, 21 | { 22 | "0": { 23 | "timestamp": new Date(1533448062185), 24 | "total": 0.9589242746631385 25 | }, 26 | "x0": new Date(1533423600000), 27 | "x1": new Date(1533466800000), 28 | "yValue": 0.9589242746631385 29 | }, 30 | { 31 | "x0": new Date(1533466800000), 32 | "x1": new Date(1533510000000), 33 | "yValue": 0 34 | }, 35 | { 36 | "x0": new Date(1533510000000), 37 | "x1": new Date(1533553200000), 38 | "yValue": 0 39 | }, 40 | { 41 | "0": { 42 | "timestamp": new Date(1533580546837), 43 | "total": 0.9092974268256817 44 | }, 45 | "x0": new Date(1533553200000), 46 | "x1": new Date(1533596400000), 47 | "yValue": 0.9092974268256817 48 | }, 49 | { 50 | "x0": new Date(1533596400000), 51 | "x1": new Date(1533639600000), 52 | "yValue": 0 53 | }, 54 | { 55 | "x0": new Date(1533639600000), 56 | "x1": new Date(1533682800000), 57 | "yValue": 0 58 | }, 59 | { 60 | "0": { 61 | "timestamp": new Date(1533704702305), 62 | "total": 0.8414709848078965 63 | }, 64 | "x0": new Date(1533682800000), 65 | "x1": new Date(1533726000000), 66 | "yValue": 0.8414709848078965 67 | }, 68 | { 69 | "x0": new Date(1533726000000), 70 | "x1": new Date(1533769200000), 71 | "yValue": 0 72 | }, 73 | { 74 | "x0": new Date(1533769200000), 75 | "x1": new Date(1533812400000), 76 | "yValue": 0 77 | }, 78 | { 79 | "0": { 80 | "timestamp": new Date(1533818043621), 81 | "total": 0.7568024953079282 82 | }, 83 | "x0": new Date(1533812400000), 84 | "x1": new Date(1533855600000), 85 | "yValue": 0.7568024953079282 86 | }, 87 | { 88 | "x0": new Date(1533855600000), 89 | "x1": new Date(1533898800000), 90 | "yValue": 0 91 | }, 92 | { 93 | "0": { 94 | "timestamp": new Date(1533918302254), 95 | "total": 0.6569865987187891 96 | }, 97 | "x0": new Date(1533898800000), 98 | "x1": new Date(1533942000000), 99 | "yValue": 0.6569865987187891 100 | }, 101 | { 102 | "x0": new Date(1533942000000), 103 | "x1": new Date(1533985200000), 104 | "yValue": 0 105 | }, 106 | { 107 | "x0": new Date(1533985200000), 108 | "x1": new Date(1534028400000), 109 | "yValue": 0 110 | }, 111 | { 112 | "x0": new Date(1534028400000), 113 | "x1": new Date(1534071600000), 114 | "yValue": 0 115 | }, 116 | { 117 | "0": { 118 | "timestamp": new Date(1534075530262), 119 | "total": 0.4121184852417566 120 | }, 121 | "x0": new Date(1534071600000), 122 | "x1": new Date(1534114800000), 123 | "yValue": 0.4121184852417566 124 | }, 125 | { 126 | "0": { 127 | "timestamp": new Date(1534154392497), 128 | "total": 0.1411200080598672 129 | }, 130 | "1": { 131 | "timestamp": new Date(1534124570286), 132 | "total": 0.27941549819892586 133 | }, 134 | "x0": new Date(1534114800000), 135 | "x1": new Date(1534158000000), 136 | "yValue": 0.4205355062587931 137 | }, 138 | { 139 | "0": { 140 | "timestamp": new Date(1534164400000), 141 | "total": 0 142 | }, 143 | "x0": new Date(1534158000000), 144 | "x1": new Date(1534201200000), 145 | "yValue": 0 146 | }, 147 | { 148 | "x0": new Date(1534201200000), 149 | "x1": new Date(1534201200000), 150 | "yValue": 0 151 | } 152 | ]; 153 | -------------------------------------------------------------------------------- /src/DensityChart/__snapshots__/DensityChart.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`render does a baseline render 1`] = ` 4 | 89 |
92 | 101 | 126 | 127 |
135 | 146 | 159 |
160 |
161 |
162 | `; 163 | -------------------------------------------------------------------------------- /src/Histogram/Histogram.scss: -------------------------------------------------------------------------------- 1 | $histogram-tooltip-width: 100; 2 | $histogram-tooltip-heigth: 40; 3 | 4 | .fdz-css-graph-histogram { 5 | margin-top: 80px; 6 | 7 | $color-blue-500: #2196f3; 8 | $color-blue-300: #64b5f6; 9 | $color-blue-200: #90caf9; 10 | $color-blue-100: #bbdefb; 11 | 12 | $color-blue-gray-600: #546e7a; 13 | $color-blue-gray-500: #607d8b; 14 | $color-blue-gray-300: #90a4ae; 15 | $color-blue-gray-100: #cfd8dc; 16 | 17 | $grey-6: #bfbfbf; 18 | 19 | $histogram-tooltip-z-index: 11; 20 | 21 | $tooltip-arrow-height: 6px; 22 | 23 | &-bars{ 24 | rect { 25 | fill: rgba($color-blue-500, 0.4); 26 | } 27 | rect:hover { 28 | @extend .fdz-css-cursor-pointer; 29 | fill: rgba($color-blue-300, 0.4); 30 | } 31 | 32 | &--tooltip { 33 | background-color: $color-blue-gray-600; 34 | width: $histogram-tooltip-width + px; 35 | padding: 0.3em 0.1em 0.3em 0.1em; 36 | text-align: center; 37 | z-index: $histogram-tooltip-z-index; 38 | font-family: 'Roboto Mono', Arial, sans-serif; 39 | 40 | &:after { 41 | top: 100%; 42 | left: 50%; 43 | border: solid transparent; 44 | content: " "; 45 | position: absolute; 46 | pointer-events: none; 47 | border-top-color: $color-blue-gray-600; 48 | border-width: $tooltip-arrow-height; 49 | margin-left: -$tooltip-arrow-height; 50 | } 51 | 52 | &-value { 53 | color: $color-blue-200; 54 | font-size: 10px; 55 | margin-bottom: 2px; 56 | } 57 | 58 | &-dates { 59 | color: $color-blue-gray-100; 60 | font-size: 8px; 61 | } 62 | } 63 | 64 | } 65 | 66 | &-axis-x { 67 | // d3v4 provides default styles and overrides previously defined font. 68 | font-family: 'Roboto Mono', Arial, sans-serif; 69 | font-size: 7pt; 70 | 71 | .domain{ 72 | stroke:$color-blue-gray-300; 73 | } 74 | 75 | text { 76 | fill: $color-blue-gray-300; 77 | text-anchor: right; 78 | } 79 | 80 | line { 81 | stroke: $color-blue-gray-300; 82 | } 83 | } 84 | 85 | &-axis-y { 86 | // d3v4 provides default styles and overrides previously defined font. 87 | font-family: 'Roboto Mono', Arial, sans-serif; 88 | font-size: 7pt; 89 | 90 | .domain{ 91 | stroke: transparent; 92 | } 93 | 94 | text { 95 | fill: $color-blue-gray-300; 96 | text-anchor: start; 97 | } 98 | 99 | line { 100 | stroke: $color-blue-gray-300; 101 | } 102 | } 103 | 104 | &-zoom { 105 | cursor: move; 106 | fill: none; 107 | pointer-events: all; 108 | } 109 | 110 | &-brush { 111 | rect { 112 | &.selection { 113 | fill: $color-blue-100; 114 | fill-opacity: 0.3; 115 | stroke-width: 0; 116 | } 117 | 118 | &.handle { 119 | fill: $color-blue-gray-500; 120 | width: 5px; 121 | stroke: $color-blue-gray-500; 122 | stroke-width: 1px; 123 | } 124 | } 125 | } 126 | 127 | &-density { 128 | &__canvas { 129 | position: relative; 130 | } 131 | 132 | &__wrapper { 133 | display: grid; 134 | grid-template-columns: auto auto; 135 | } 136 | } 137 | 138 | .fdz-css-cursor-pointer { 139 | cursor: pointer; 140 | } 141 | 142 | .fdz-css-play-btn { 143 | line-height: 1.5; 144 | padding: 0 15px; 145 | border-radius: 4px; 146 | user-select: none; 147 | transition: all 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); 148 | position: relative; 149 | box-shadow: 0 2px 0 rgba(0, 0, 0, 0.015); 150 | cursor: pointer; 151 | top: -4px; 152 | 153 | 154 | background: transparent; 155 | border-color: transparent; 156 | color: $grey-6; 157 | font-size: 18px; 158 | 159 | &:hover, &:active, &:focus { 160 | background: transparent; 161 | border-color: transparent; 162 | } 163 | 164 | &:hover { 165 | color: #40a9ff; 166 | } 167 | 168 | .anticon { 169 | transition: margin-left 0.3s cubic-bezier(0.645, 0.045, 0.355, 1); 170 | line-height: 1; 171 | pointer-events: none; 172 | vertical-align: -0.125em; 173 | 174 | svg { 175 | line-height: 1; 176 | } 177 | } 178 | } 179 | } 180 | -------------------------------------------------------------------------------- /stories/index.stories.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import { timeFormat } from "d3-time-format"; 3 | 4 | import { storiesOf } from "@storybook/react"; 5 | import { withKnobs, number, object } from "@storybook/addon-knobs"; 6 | 7 | import sampleData from "./sampleData"; 8 | import Histogram from "../src/index"; 9 | import "../src/Histogram/Histogram.scss"; 10 | 11 | const stories = storiesOf("Histogram", module); 12 | 13 | stories.addDecorator(withKnobs); 14 | 15 | const formatDayFull = timeFormat("%e %b'%y"); 16 | 17 | function formatContextInterval(dateStart, dateEnd) { 18 | const formatStart = formatDayFull(dateStart); 19 | const formatEnd = formatDayFull(dateEnd); 20 | 21 | // If the formatted start and end date are the same then just display one 22 | if (formatStart === formatEnd) { 23 | return formatStart; 24 | } 25 | 26 | // Otherwise display the interval limits. 27 | return `${formatStart} - ${formatEnd}`; 28 | } 29 | 30 | function histogramTooltipBar(bar) { 31 | return ( 32 | 33 |
34 | {Math.floor(bar.yValue)} Events 35 |
36 |
37 | {formatContextInterval(bar.x0, bar.x1)} 38 |
39 |
40 | ); 41 | } 42 | 43 | stories 44 | .add("Basic example", () => ( 45 | datapoint.timestamp} 48 | yAccessor={(datapoint) => datapoint.total} 49 | /> 50 | )) 51 | .add("With a custom tooltip", () => ( 52 | datapoint.timestamp} 55 | yAccessor={(datapoint) => datapoint.total} 56 | tooltipBarCustomization={histogramTooltipBar} 57 | /> 58 | )) 59 | .add("With a custom axis formatters", () => ( 60 | datapoint.timestamp} 63 | xAxisFormatter={formatDayFull} 64 | yAxisFormatter={(value) => (value > 0 ? `${value}$` : "")} 65 | yAccessor={(datapoint) => datapoint.total} 66 | /> 67 | )) 68 | .add("Without the play button", () => ( 69 | datapoint.timestamp} 72 | yAccessor={(datapoint) => datapoint.total} 73 | renderPlayButton={false} 74 | /> 75 | )) 76 | .add("With a custom a height", () => ( 77 | datapoint.timestamp} 80 | yAccessor={(datapoint) => datapoint.total} 81 | height={number("height (min 150)", 300)} 82 | tooltipBarCustomization={histogramTooltipBar} 83 | /> 84 | )) 85 | .add("With no data", () => ( 86 | datapoint.timestamp} 89 | yAccessor={(datapoint) => datapoint.total} 90 | tooltipBarCustomization={histogramTooltipBar} 91 | /> 92 | )) 93 | .add("Changing the data updates the histogram", () => ( 94 | datapoint.timestamp} 106 | yAccessor={(datapoint) => datapoint.total} 107 | onIntervalChange={(domain) => console.log(domain)} 108 | /> 109 | )) 110 | .add("Allows to customize the brush interval", () => ( 111 | datapoint.timestamp} 123 | yAccessor={(datapoint) => datapoint.total} 124 | brushDomain={{ 125 | min: number("min", 1170070000000), 126 | max: number("max", 1870070000000) 127 | }} 128 | /> 129 | )); 130 | -------------------------------------------------------------------------------- /src/DensityChart/DensityChart.test.js: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { mount } from "enzyme"; 3 | import { max as d3Max, min as d3Min } from "d3-array"; 4 | import { scaleTime } from "d3-scale"; 5 | import DensityChart from "./DensityChart"; 6 | import PlayButton from "./PlayButton"; 7 | import { smallSample } from "../../stories/sampleData"; 8 | 9 | jest.mock("../canvasRenderUtils", () => ({ 10 | drawRect: () => {}, 11 | clearCanvas: () => {}, 12 | getRenderContext: () => ({}) 13 | })); 14 | 15 | const d3Event = global.d3Event; 16 | 17 | let onDomainChanged, xAccessor, width, brushDomainMin, brushDomainMax, densityChartXScale; 18 | 19 | beforeEach(() => { 20 | onDomainChanged = jest.fn(); 21 | xAccessor = (elm) => elm.timestamp; 22 | width = 1000; 23 | brushDomainMin = d3Min(smallSample, xAccessor); 24 | brushDomainMax = d3Max(smallSample, xAccessor); 25 | densityChartXScale = scaleTime() 26 | .domain([ brushDomainMin, brushDomainMax]) 27 | .range([ 0, width ]); 28 | }); 29 | 30 | describe("_onResizeBrush", () => { 31 | let instance; 32 | 33 | beforeEach(() => { 34 | const wrapper = mount(); 45 | 46 | instance = wrapper.instance(); 47 | }); 48 | 49 | it("does nothing if the event type is zoom", () => { 50 | instance._getD3Event = () => ({ 51 | sourceEvent: { 52 | type: "zoom" 53 | } 54 | }); 55 | 56 | instance._onResizeBrush(); 57 | 58 | expect(onDomainChanged.mock.calls.length).toBe(2); 59 | }); 60 | 61 | it("calls props.onDomainChanged with the selected range", () => { 62 | instance._getD3Event = () => ({ 63 | sourceEvent: { 64 | type: "brush" 65 | }, 66 | selection: [0, 100] 67 | }); 68 | 69 | instance._onResizeBrush(); 70 | 71 | expect(onDomainChanged.mock.calls.length).toBe(3); 72 | expect(onDomainChanged.mock.calls[2][0]).toEqual([0, 100]); 73 | }); 74 | 75 | it("calls props.onDomainChanged with the whole range if the selection was empty", () => { 76 | instance._getD3Event = () => ({ 77 | sourceEvent: { 78 | type: "brush" 79 | } 80 | }); 81 | 82 | instance._onResizeBrush(); 83 | 84 | expect(onDomainChanged.mock.calls.length).toBe(3); 85 | expect(onDomainChanged.mock.calls[2][0]).toEqual([0, 1000]); 86 | }); 87 | }); 88 | 89 | describe("componentDidUpdate", () => { 90 | it("update the brush when the screen have been resized", () => { 91 | const wrapper = mount(); 103 | const brushExtendMock = jest.fn(); 104 | const instance = wrapper.instance(); 105 | const newWidth = width - 50; 106 | 107 | instance.brush.extent = brushExtendMock; 108 | 109 | wrapper.setProps({ width: newWidth }); 110 | 111 | expect(brushExtendMock).toHaveBeenCalledTimes(1); 112 | expect(brushExtendMock).toHaveBeenCalledWith([ 113 | [0, 0], 114 | [newWidth, 50] 115 | ]); 116 | }); 117 | }); 118 | 119 | describe("render", () => { 120 | it("does a baseline render", () => { 121 | expect(mount()).toMatchSnapshot(); 132 | }); 133 | 134 | it("doesn't render the play button if renderPlayButton is false", () => { 135 | expect(mount().find(PlayButton).length).toBe(0); 147 | }); 148 | }); 149 | 150 | afterEach(() => { 151 | global.d3Event = d3Event; 152 | }); 153 | -------------------------------------------------------------------------------- /docs/runtime~main.f80707d9b6c6739d4452.bundle.js.map: -------------------------------------------------------------------------------- 1 | {"version":3,"file":"runtime~main.f80707d9b6c6739d4452.bundle.js","sources":["webpack:///webpack/bootstrap"],"sourcesContent":[" \t// install a JSONP callback for chunk loading\n \tfunction webpackJsonpCallback(data) {\n \t\tvar chunkIds = data[0];\n \t\tvar moreModules = data[1];\n \t\tvar executeModules = data[2];\n\n \t\t// add \"moreModules\" to the modules object,\n \t\t// then flag all \"chunkIds\" as loaded and fire callback\n \t\tvar moduleId, chunkId, i = 0, resolves = [];\n \t\tfor(;i < chunkIds.length; i++) {\n \t\t\tchunkId = chunkIds[i];\n \t\t\tif(installedChunks[chunkId]) {\n \t\t\t\tresolves.push(installedChunks[chunkId][0]);\n \t\t\t}\n \t\t\tinstalledChunks[chunkId] = 0;\n \t\t}\n \t\tfor(moduleId in moreModules) {\n \t\t\tif(Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {\n \t\t\t\tmodules[moduleId] = moreModules[moduleId];\n \t\t\t}\n \t\t}\n \t\tif(parentJsonpFunction) parentJsonpFunction(data);\n\n \t\twhile(resolves.length) {\n \t\t\tresolves.shift()();\n \t\t}\n\n \t\t// add entry modules from loaded chunk to deferred list\n \t\tdeferredModules.push.apply(deferredModules, executeModules || []);\n\n \t\t// run deferred modules when all chunks ready\n \t\treturn checkDeferredModules();\n \t};\n \tfunction checkDeferredModules() {\n \t\tvar result;\n \t\tfor(var i = 0; i < deferredModules.length; i++) {\n \t\t\tvar deferredModule = deferredModules[i];\n \t\t\tvar fulfilled = true;\n \t\t\tfor(var j = 1; j < deferredModule.length; j++) {\n \t\t\t\tvar depId = deferredModule[j];\n \t\t\t\tif(installedChunks[depId] !== 0) fulfilled = false;\n \t\t\t}\n \t\t\tif(fulfilled) {\n \t\t\t\tdeferredModules.splice(i--, 1);\n \t\t\t\tresult = __webpack_require__(__webpack_require__.s = deferredModule[0]);\n \t\t\t}\n \t\t}\n \t\treturn result;\n \t}\n\n \t// The module cache\n \tvar installedModules = {};\n\n \t// object to store loaded and loading chunks\n \t// undefined = chunk not loaded, null = chunk preloaded/prefetched\n \t// Promise = chunk loading, 0 = chunk loaded\n \tvar installedChunks = {\n \t\t1: 0\n \t};\n\n \tvar deferredModules = [];\n\n \t// The require function\n \tfunction __webpack_require__(moduleId) {\n\n \t\t// Check if module is in cache\n \t\tif(installedModules[moduleId]) {\n \t\t\treturn installedModules[moduleId].exports;\n \t\t}\n \t\t// Create a new module (and put it into the cache)\n \t\tvar module = installedModules[moduleId] = {\n \t\t\ti: moduleId,\n \t\t\tl: false,\n \t\t\texports: {}\n \t\t};\n\n \t\t// Execute the module function\n \t\tmodules[moduleId].call(module.exports, module, module.exports, __webpack_require__);\n\n \t\t// Flag the module as loaded\n \t\tmodule.l = true;\n\n \t\t// Return the exports of the module\n \t\treturn module.exports;\n \t}\n\n\n \t// expose the modules object (__webpack_modules__)\n \t__webpack_require__.m = modules;\n\n \t// expose the module cache\n \t__webpack_require__.c = installedModules;\n\n \t// define getter function for harmony exports\n \t__webpack_require__.d = function(exports, name, getter) {\n \t\tif(!__webpack_require__.o(exports, name)) {\n \t\t\tObject.defineProperty(exports, name, { enumerable: true, get: getter });\n \t\t}\n \t};\n\n \t// define __esModule on exports\n \t__webpack_require__.r = function(exports) {\n \t\tif(typeof Symbol !== 'undefined' && Symbol.toStringTag) {\n \t\t\tObject.defineProperty(exports, Symbol.toStringTag, { value: 'Module' });\n \t\t}\n \t\tObject.defineProperty(exports, '__esModule', { value: true });\n \t};\n\n \t// create a fake namespace object\n \t// mode & 1: value is a module id, require it\n \t// mode & 2: merge all properties of value into the ns\n \t// mode & 4: return value when already ns object\n \t// mode & 8|1: behave like require\n \t__webpack_require__.t = function(value, mode) {\n \t\tif(mode & 1) value = __webpack_require__(value);\n \t\tif(mode & 8) return value;\n \t\tif((mode & 4) && typeof value === 'object' && value && value.__esModule) return value;\n \t\tvar ns = Object.create(null);\n \t\t__webpack_require__.r(ns);\n \t\tObject.defineProperty(ns, 'default', { enumerable: true, value: value });\n \t\tif(mode & 2 && typeof value != 'string') for(var key in value) __webpack_require__.d(ns, key, function(key) { return value[key]; }.bind(null, key));\n \t\treturn ns;\n \t};\n\n \t// getDefaultExport function for compatibility with non-harmony modules\n \t__webpack_require__.n = function(module) {\n \t\tvar getter = module && module.__esModule ?\n \t\t\tfunction getDefault() { return module['default']; } :\n \t\t\tfunction getModuleExports() { return module; };\n \t\t__webpack_require__.d(getter, 'a', getter);\n \t\treturn getter;\n \t};\n\n \t// Object.prototype.hasOwnProperty.call\n \t__webpack_require__.o = function(object, property) { return Object.prototype.hasOwnProperty.call(object, property); };\n\n \t// __webpack_public_path__\n \t__webpack_require__.p = \"\";\n\n \tvar jsonpArray = window[\"webpackJsonp\"] = window[\"webpackJsonp\"] || [];\n \tvar oldJsonpFunction = jsonpArray.push.bind(jsonpArray);\n \tjsonpArray.push = webpackJsonpCallback;\n \tjsonpArray = jsonpArray.slice();\n \tfor(var i = 0; i < jsonpArray.length; i++) webpackJsonpCallback(jsonpArray[i]);\n \tvar parentJsonpFunction = oldJsonpFunction;\n\n\n \t// run deferred modules from other chunks\n \tcheckDeferredModules();\n"],"mappings":"AACA","sourceRoot":""} -------------------------------------------------------------------------------- /src/DensityChart/PlayButton.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | 4 | // These icon paths were copied from antd 5 | const PAUSE_ICON_PATH = `M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 6 | 64 512 64zm0 820c-205.4 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 7 | 372zm-88-532h-48c-4.4 0-8 3.6-8 8v304c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V360c0-4.4-3.6-8-8-8zm224 8 | 0h-48c-4.4 0-8 3.6-8 8v304c0 4.4 3.6 8 8 8h48c4.4 0 8-3.6 8-8V360c0-4.4-3.6-8-8-8z`; 9 | 10 | const PLAY_ICON_PATHS = [ 11 | `M512 64C264.6 64 64 264.6 64 512s200.6 448 448 448 448-200.6 448-448S759.4 64 512 64zm0 820c-205.4 12 | 0-372-166.6-372-372s166.6-372 372-372 372 166.6 372 372-166.6 372-372 372z`, 13 | `M719.4 499.1l-296.1-215A15.9 15.9 0 0 0 398 297v430c0 13.1 14.8 20.5 25.3 12.9l296.1-215a15.9 14 | 15.9 0 0 0 0-25.8zm-257.6 134V390.9L628.5 512 461.8 633.1z` 15 | ]; 16 | 17 | /** 18 | * PlayButton 19 | * 20 | * Renders a play button that allows to play a time-lapse of the events 21 | * in the histogram. 22 | * 23 | * @author Beatriz Malveiro Jorge (beatriz.jorge@feedzai.com) 24 | * @author Victor Fernandes (victor.fernandes@feedzai.com) 25 | * @author Luis Cardoso (luis.cardoso@feedzai.com) 26 | */ 27 | 28 | export default class PlayButton extends PureComponent { 29 | static propTypes = { 30 | width: PropTypes.number.isRequired, 31 | brushDomainMax: PropTypes.number.isRequired, 32 | brushDomainMin: PropTypes.number.isRequired, 33 | densityChartXScale: PropTypes.func.isRequired, 34 | moveBrush: PropTypes.func.isRequired, 35 | frameStep: PropTypes.number, 36 | frameDelay: PropTypes.number 37 | }; 38 | 39 | static defaultProps = { 40 | frameStep: 0.025, 41 | frameDelay: 500 42 | }; 43 | 44 | state = { 45 | play: false 46 | }; 47 | 48 | /** 49 | * Handles click on play button. Defines start and end for 50 | * the domain-lapse and triggers _playLapse to play frames 51 | * at set intervals. 52 | * 53 | * @private 54 | */ 55 | _onClickPlay = () => { 56 | const { width, densityChartXScale, brushDomainMax, brushDomainMin, frameStep } = this.props; 57 | const brushedMaxRange = densityChartXScale(brushDomainMax); 58 | const brushedMinRange = densityChartXScale(brushDomainMin); 59 | const frameStart = brushedMinRange; 60 | 61 | const playEnd = width; 62 | const playStep = width * frameStep; 63 | 64 | this.frameEnd = frameStart; 65 | 66 | if (brushedMaxRange === playEnd) { 67 | this.frameEnd = frameStart; 68 | } else { 69 | this.frameEnd = brushedMaxRange; 70 | } 71 | 72 | this.setState({ 73 | play: true 74 | }, () => this._playLapseAtInterval(frameStart, playEnd, playStep)); 75 | }; 76 | 77 | /** 78 | * Handles click on stop button. Will clear interval 79 | * of funtion playInterval playing domain-lapse frames 80 | * 81 | * @private 82 | */ 83 | _onClickStop = () => { 84 | this._stopLapse(); 85 | }; 86 | 87 | /** 88 | * Plays a frame of the domain-lapse. Updates subset of domain 89 | * to be displayed and moves brush to new domain. 90 | * 91 | * @param {Number} start 92 | * @param {Number} end 93 | * @param {Number} step 94 | * @private 95 | */ 96 | _playFrame(start, end, step) { 97 | // If end of frame is at end of play region then stop domain-lapse 98 | 99 | if (this.frameEnd >= end) { 100 | this._stopLapse(); 101 | return; 102 | } 103 | 104 | // Check if adding a step will surprass max domain. 105 | if (this.frameEnd + step >= end) { 106 | // If so, set max frame to be max domain 107 | this.frameEnd = end; 108 | } else { 109 | // Otherwise just add a step 110 | this.frameEnd += step; 111 | } 112 | const domain = [start, this.frameEnd]; 113 | 114 | // Move brush to new frame location 115 | this.props.moveBrush(domain); 116 | } 117 | 118 | /** 119 | * Plays domain-lapse by calling _playFrame at interval 120 | * to play each frame. 121 | * 122 | * @param {Number} start 123 | * @param {Number} end 124 | * @param {Number} step 125 | * @private 126 | */ 127 | _playLapseAtInterval(start, end, step) { 128 | this.playInterval = setInterval(() => { 129 | this._playFrame(start, end, step); 130 | }, this.props.frameDelay); 131 | } 132 | 133 | /** 134 | * Stops domain-lapse at current frame. This 135 | * is done by clearing the timeInterval in this.playInterval. 136 | * 137 | * @private 138 | */ 139 | _stopLapse() { 140 | this.setState({ 141 | play: false 142 | }, () => clearInterval(this.playInterval)); 143 | } 144 | 145 | render() { 146 | const onClick = this.state.play ? this._onClickStop : this._onClickPlay; 147 | 148 | let iconElement; 149 | 150 | if (this.state.play) { 151 | iconElement = ( 152 | 153 | 154 | 155 | ); 156 | } else { 157 | iconElement = ( 158 | 159 | 160 | 161 | 162 | ); 163 | } 164 | 165 | return (<> 166 | 171 | ); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Brushable Histogram 2 | 3 | [![Build Status](https://travis-ci.com/feedzai/brushable-histogram.svg?branch=master)](https://travis-ci.com/feedzai/brushable-histogram) 4 | [![Coverage Status](https://coveralls.io/repos/github/feedzai/brushable-histogram/badge.svg?branch=master)](https://coveralls.io/github/feedzai/brushable-histogram?branch=master) 5 | 6 | The brushable histogram renders a time histogram with a preview of the whole data below it, that can be used both to 7 | pan and zoom in on specific intervals as well as to see an overview of the distribution of the data ([demo](https://feedzai.github.io/brushable-histogram/)). 8 | 9 | 10 | ![](https://i.imgur.com/VUYAnxy.gif?raw=true) 11 | 12 | Brushable histogram is a React component and works as an uncontrolled component. 13 | 14 | Kudos to [Beatriz Malveiro](https://github.com/biamalveiro) for the ideia and first proptotype and to [Victor Fernandes](https://github.com/victorfern91) for improvements to that first version. 15 | 16 | ## Instalation 17 | 18 | ```shell 19 | npm install --save @feedzai/brushable-histogram 20 | ``` 21 | 22 | Note that by default no style is included. If you want you can use the css at `@feedzai/brushable-histogram/lib/css/brushable-histogram.css` to get the same style as in the examples. 23 | 24 | ## Usage example 25 | 26 | ```js 27 | import React, { PureComponent, Fragment } from "react"; 28 | import Histogram from "@feedzai/brushable-histogram"; 29 | import "@feedzai/brushable-histogram/lib/css/brushable-histogram.css"; 30 | 31 | const xAccessor = (datapoint) => datapoint.timestamp; 32 | const yAccessor = (datapoint) => datapoint.total; 33 | 34 | function histogramTooltipBar(bar) { 35 | return ( 36 | 37 |
38 | {Math.floor(bar.yValue)} Events 39 |
40 |
41 | ); 42 | } 43 | 44 | export default class HistogramExample extends PureComponent { 45 | render() { 46 | return ( 47 | 62 | ); 63 | } 64 | } 65 | ``` 66 | 67 | For more advanced use cases please checkout the examples in the `stories` folder. 68 | 69 | 70 | ## Props 71 | 72 | ### `data` 73 | **Type** `Array.` **Required** 74 | 75 | The data to render in the histogram. The properties of each element that will be used to render the histogram will be defined in the `xAccessor` and `yAccessor` props. 76 | 77 | ### `xAccessor` 78 | **Type** `Function` **Required** 79 | 80 | A function that will receive an array element as argument and that should return the value of the x axis for that element. A possible example would be `({timestamp}) => timestamp`. 81 | 82 | **Important** The histogram assumes that `xAccessor` will return an unique value for each `data` element. 83 | 84 | ### `yAccessor` 85 | **Type** `Function` **Required** 86 | 87 | A function that will receive an array element as argument and that should return the value of the y axis for that element. A possible example would be `({amount}) => amount`. 88 | 89 | **Important** currently the histogram only support positive values. 90 | 91 | ### `xAxisFormatter` 92 | **Type** `Function` **Default** `(value) => String(value)` 93 | 94 | A function that will receive the value of the x axis returned by `xAccessor` and should return the formatted value as a string that will be displayed in the chart. 95 | 96 | ### `yAxisFormatter` 97 | **Type** `Function` **Default** Only renders integer numbers. 98 | 99 | A function that will receive the value of the y axis returned by `yAccessor` and should return the formatted value as a string that will be displayed in the chart. 100 | 101 | ### `height` 102 | **Type** `number` **Default** `100` 103 | 104 | The height in pixels that the histogram will have. Currently this does not take into account the height used by the summary chart (TODO: make this the real height). 105 | 106 | ## `onIntervalChange` 107 | **Type** `Function` **Default** `() => {}` 108 | 109 | This callback will be called when the selected intervall changes. 110 | 111 | ## `tooltipBarCustomization` 112 | **Type** `Function` **Default** `() => {}` 113 | 114 | To render a tooltip when the mouse hovers it this prop should be passed with a function that returns a React Element. This function will receive as an argument the data object relative to that column. 115 | 116 | ## `brushDomain` 117 | **Type** `Object` **Default** `undefined` 118 | A custom brush domain. Should have two properties: `min` and `max` both timestamps in milliseconds. Please note that if the user 119 | changes the brush domain by interacting with the brush bar this will be ignored until you pass it a different one. 120 | 121 | ## How to install it? 122 | `npm install brushable-histogram` --save 123 | 124 | ## Repo Organization 125 | ``` 126 | - (root folder) 127 | | 128 | |\_ .storybook - This is the place of the storybook configurations (you should not need to change this often) 129 | | 130 | |\_ src - Source files including unit tests and the default scss 131 | | 132 | |\_ stories - Stories that showcase the usage of the component. 133 | ``` 134 | ## Develop process 135 | 136 | ### `npm` tasks 137 | 138 | #### Development tasks 139 | - `npm run storybook` - generate the component interactive (access to the storybook server using `http://localhost:9000`) 140 | - `npm run test` - run the unit tests (using jest) 141 | - `npm run test:watch` - run the unit tests in watch mode (using jest) 142 | - `npm run lint` - run the ESLint linter 143 | 144 | #### Deployment tasks 145 | 146 | **NOTE:** Those tasks should be executed only on the `master` branch. 147 | 148 | - `npm run publish:dry` - runs all the publish steps but doesn't actualy publishes 149 | - `npm run publish:major` - creates a tag and publish the X.0.0 version 150 | - `npm run publish:minor` - creates a tag and publish the X.Y.0 version 151 | - `npm run publish:patch` - creates a tag and publish the X.Y.Z version 152 | -------------------------------------------------------------------------------- /src/Histogram/Histogram.test.js: -------------------------------------------------------------------------------- 1 | import React, { Fragment } from "react"; 2 | import { timeFormat } from "d3-time-format"; 3 | import { mount } from "enzyme"; 4 | import { Histogram } from "./Histogram"; 5 | 6 | import { smallSample } from "../../stories/sampleData"; 7 | 8 | jest.mock("../canvasRenderUtils"); 9 | 10 | // The calculation of the histogram bar positions can vary a bit 11 | // depending on the system clock so we need to mock it to make sure 12 | // we have no suprises in ci. 13 | jest.mock("./histogramBinCalculator"); 14 | 15 | // The calcule of the bar positions and width depended a bit on the 16 | // system clock. To avoid that dependency we mock the module that 17 | // calculates those things. 18 | jest.mock("./histogramBarGeometry"); 19 | const formatMinute = timeFormat("%I:%M"); 20 | 21 | function histogramYAxisFormatter(value) { 22 | if (value > 0 && Number.isInteger(value)) { 23 | return value; 24 | } 25 | return ""; 26 | } 27 | 28 | function histogramTooltipBar(bar) { 29 | return ( 30 | 31 |
32 | {bar.yValue} Events 33 |
34 |
35 | {`${formatMinute(bar.x0)} - ${formatMinute(bar.x1)}`} 36 |
37 |
38 | ); 39 | } 40 | 41 | let wrapper, instance, onIntervalChangeSpy; 42 | 43 | beforeEach(() => { 44 | onIntervalChangeSpy = jest.fn(); 45 | 46 | wrapper = mount( datapoint.timestamp} 51 | xAxisFormatter={formatMinute} 52 | yAccessor={(datapoint) => datapoint.total} 53 | yAxisFormatter={histogramYAxisFormatter} 54 | tooltipBarCustomization={histogramTooltipBar} 55 | onIntervalChange={onIntervalChangeSpy} 56 | />); 57 | 58 | instance = wrapper.instance(); 59 | }); 60 | 61 | describe("getDerivedStateFromProps", () => { 62 | it("should throw an error if the height is less than the minimum height", () => { 63 | const testFunction = () => { 64 | Histogram.getDerivedStateFromProps({ size: { height: 10 } }); 65 | }; 66 | 67 | expect(testFunction).toThrow(Error); 68 | }); 69 | 70 | it("should return null if the width is zero", () => { 71 | expect(Histogram.getDerivedStateFromProps({ size: { width: 0 } })).toBe(null); 72 | }); 73 | }); 74 | 75 | describe("componentDidUpdate", () => { 76 | it("should update the the scales and zoom if the data changed", () => { 77 | instance._createScaleAndZoom = jest.fn(); 78 | 79 | instance.componentDidUpdate(Object.assign({}, instance.props, { 80 | data: [{ 81 | "timestamp": 1000, 82 | "total": 3 83 | }] 84 | })); 85 | 86 | expect(instance._createScaleAndZoom).toHaveBeenCalledTimes(1); 87 | }); 88 | 89 | it("should update the the scales and zoom if the width changed", () => { 90 | instance._createScaleAndZoom = jest.fn(); 91 | 92 | instance.componentDidUpdate(Object.assign({}, instance.props, { 93 | size: { 94 | width: 100 95 | } 96 | })); 97 | 98 | expect(instance._createScaleAndZoom).toHaveBeenCalledTimes(1); 99 | }); 100 | 101 | it("should update the the scales and zoom if the acessors changed", () => { 102 | instance._createScaleAndZoom = jest.fn(); 103 | 104 | instance.componentDidUpdate(Object.assign({}, instance.props, { 105 | yAccessor: (datapoint) => datapoint.total 106 | })); 107 | 108 | expect(instance._createScaleAndZoom).toHaveBeenCalledTimes(1); 109 | }); 110 | 111 | it("should not update the the scales and zoom if nothing relevant changed", () => { 112 | instance._createScaleAndZoom = jest.fn(); 113 | 114 | instance.componentDidUpdate(Object.assign({}, instance.props)); 115 | 116 | expect(instance._createScaleAndZoom).toHaveBeenCalledTimes(0); 117 | }); 118 | }); 119 | 120 | describe("componentWillUnmount", () => { 121 | it("should unsubscribe to the zoom", () => { 122 | instance.zoom = { 123 | on: jest.fn() 124 | }; 125 | 126 | instance.componentWillUnmount(); 127 | 128 | expect(instance.zoom.on).toHaveBeenCalledWith("zoom", null); 129 | }); 130 | }); 131 | 132 | describe("_onResizeZoom", () => { 133 | it("should zoom event to be triggered", () => { 134 | instance._updateBrushedDomainAndReRenderTheHistogramPlot = jest.fn(); 135 | instance.state.overallTimeDomain = { 136 | max: 153416440004 137 | }; 138 | 139 | 140 | // Simulate a wheek event to test the resize event 141 | instance.histogramChartRef.current 142 | .dispatchEvent(new WheelEvent("wheel", { deltaY: -100 })); 143 | 144 | expect(instance._updateBrushedDomainAndReRenderTheHistogramPlot).toHaveBeenCalledTimes(1); 145 | }); 146 | }); 147 | 148 | describe("_onMouseEnterHistogramBar", () => { 149 | it("should update the state to reflect the selected bar", () => { 150 | instance._onMouseEnterHistogramBar({ 151 | currentTarget: { 152 | getAttribute: () => "0", 153 | getBoundingClientRect: () => ({}) 154 | } 155 | }); 156 | 157 | expect(instance.state.showHistogramBarTooltip).toBe(true); 158 | }); 159 | }); 160 | 161 | describe("_onMouseLeaveHistogramBar", () => { 162 | it("should update the state to reflect that no bar is selected", () => { 163 | instance._onMouseEnterHistogramBar({ 164 | currentTarget: { 165 | getAttribute: () => "0", 166 | getBoundingClientRect: () => ({}) 167 | } 168 | }); 169 | 170 | expect(instance.state.showHistogramBarTooltip).toBe(true); 171 | 172 | instance._onMouseLeaveHistogramBar(); 173 | 174 | expect(instance.state.showHistogramBarTooltip).toBe(false); 175 | }); 176 | }); 177 | 178 | describe("_renderDensityChart", () => { 179 | let histogramBarGeometryMock; 180 | 181 | beforeEach(() => { 182 | // clear histogramBarGeometryMock 183 | histogramBarGeometryMock = require("./histogramBarGeometry"); 184 | }); 185 | 186 | it("should render an histogram bar", () => { 187 | expect( 188 | instance._renderHistogramBars([{ 189 | x0: { 190 | getTime: () => "fake-time" 191 | } 192 | }]) 193 | ).toMatchSnapshot(); 194 | }); 195 | 196 | it("should not render an histogram bar if the height is negative", () => { 197 | histogramBarGeometryMock.calculatePositionAndDimensions = jest.fn().mockImplementation(() => ({ 198 | x: 0, 199 | y: 0, 200 | width: 100, 201 | height: -1 202 | })); 203 | 204 | expect( 205 | instance._renderHistogramBars([{ 206 | x0: { 207 | getTime: () => "fake-time" 208 | } 209 | }]) 210 | ).toEqual([null]); 211 | }); 212 | 213 | it("should not render an histogram bar if the width is negative", () => { 214 | histogramBarGeometryMock.calculatePositionAndDimensions = jest.fn().mockImplementation(() => ({ 215 | x: 0, 216 | y: 0, 217 | width: -1, 218 | height: 100 219 | })); 220 | 221 | expect( 222 | instance._renderHistogramBars([{ 223 | x0: { 224 | getTime: () => "fake-time" 225 | } 226 | }]) 227 | ).toEqual([null]); 228 | }); 229 | }); 230 | 231 | describe("render", () => { 232 | it("does a baseline render", () => { 233 | expect(wrapper).toMatchSnapshot(); 234 | }); 235 | 236 | it("renders an empty chart if no data is passed", () => { 237 | jest.spyOn(Date, "now").mockImplementation(() => 1479427200000); 238 | 239 | const testWrapper = mount( datapoint.timestamp} 244 | xAxisFormatter={formatMinute} 245 | yAccessor={(datapoint) => datapoint.total} 246 | yAxisFormatter={histogramYAxisFormatter} 247 | tooltipBarCustomization={histogramTooltipBar} 248 | onIntervalChange={onIntervalChangeSpy} 249 | />); 250 | 251 | expect(testWrapper).toMatchSnapshot(); 252 | }); 253 | }); 254 | -------------------------------------------------------------------------------- /src/utils.js: -------------------------------------------------------------------------------- 1 | import { timeFormat } from "d3-time-format"; 2 | import { timeSecond, timeMinute, timeHour, timeDay, timeWeek, timeMonth, timeYear } from "d3-time"; 3 | import { max as d3Max, min as d3Min } from "d3-array"; 4 | import { 5 | X_AXIS_HEIGHT, 6 | BUTTON_PADDING, 7 | DENSITY_CHART_HEIGHT_PX, 8 | PADDING 9 | } from "./constants"; 10 | 11 | /** 12 | * utils 13 | * 14 | * Contains utility methods. 15 | * 16 | * @author Beatriz Malveiro Jorge (beatriz.jorge@feedzai.com) 17 | * @author Victor Fernandes (victor.fernandes@feedzai.com) 18 | * @author Luis Cardoso (luis.cardoso@feedzai.com) 19 | */ 20 | 21 | /** 22 | * Returns true if the given value is an Object. 23 | * @param {*} obj 24 | * @returns {boolean} 25 | */ 26 | export function isObject(obj) { 27 | return Object(obj) === obj; 28 | } 29 | 30 | /** 31 | * Returns true if the two given objects are equal deeply. 32 | * @param {*} obj1 33 | * @param {*} obj2 34 | * @returns {boolean} 35 | */ 36 | function isEqual(obj1, obj2) { 37 | return JSON.stringify(obj1) === JSON.stringify(obj2); 38 | } 39 | 40 | /** 41 | * The default histogram y axis formatter. Only returns integer values. 42 | * @param {number} value 43 | * @returns {String} 44 | */ 45 | export function histogramDefaultYAxisFormatter(value) { 46 | if (value > 0 && Number.isInteger(value)) { 47 | return value; 48 | } 49 | return ""; 50 | } 51 | 52 | const formatMillisecond = timeFormat(".%L"), 53 | formatSecond = timeFormat(":%S"), 54 | formatMinute = timeFormat("%I:%M"), 55 | formatHour = timeFormat("%I %p"), 56 | formatDay = timeFormat("%a %d"), 57 | formatWeek = timeFormat("%b %d"), 58 | formatMonth = timeFormat("%B"), 59 | formatYear = timeFormat("%Y"); 60 | 61 | /** 62 | * Formats a date. This is the histogram default x axis formatter. 63 | * 64 | * This code is adapted from the D3 documentation. 65 | * 66 | * @param {Date} date 67 | * @returns {string} 68 | */ 69 | export function multiDateFormat(date) { 70 | let formatter; 71 | 72 | if (timeSecond(date) < date) { 73 | formatter = formatMillisecond; 74 | } else if (timeMinute(date) < date) { 75 | formatter = formatSecond; 76 | } else if (timeHour(date) < date) { 77 | formatter = formatMinute; 78 | } else if (timeDay(date) < date) { 79 | formatter = formatHour; 80 | } else if (timeMonth(date) < date) { 81 | if (timeWeek(date) < date) { 82 | formatter = formatDay; 83 | } else { 84 | formatter = formatWeek; 85 | } 86 | } else if (timeYear(date) < date) { 87 | formatter = formatMonth; 88 | } else { 89 | formatter = formatYear; 90 | } 91 | 92 | return formatter(date); 93 | } 94 | 95 | /** 96 | * Compares the x and y histogram data in two arrays and returns whenever they are the same. 97 | * @param {function} xAcessor The function that will return the x value. 98 | * @param {function} yAcessor The function that will return the y value. 99 | * @param {Array.} data1 The first data array. 100 | * @param {Array.} data2 The second data array. 101 | * @returns {boolean} 102 | */ 103 | export function isHistogramDataEqual(xAcessor, yAcessor, data1, data2) { 104 | if (Array.isArray(data1) === false || Array.isArray(data2) === false) { 105 | return false; 106 | } 107 | 108 | if (data1.length !== data2.length) { 109 | return false; 110 | } 111 | 112 | for (let i = 0; i < data1.length; i++) { 113 | if (xAcessor(data1[i]) !== xAcessor(data2[i])) { 114 | return false; 115 | } 116 | if (yAcessor(data1[i]) !== yAcessor(data2[i])) { 117 | return false; 118 | } 119 | } 120 | 121 | return true; 122 | } 123 | 124 | /** 125 | * Converts a Date object to unix timestamp if the parameter is 126 | * indeed a date, if it's not then just return the value. 127 | * @param {Date|number} date 128 | * @returns {number} 129 | */ 130 | export function dateToTimestamp(date) { 131 | return date instanceof Date ? date.getTime() : date; 132 | } 133 | 134 | /** 135 | * Compares the props with the given names in the two prop objects and 136 | * returns whenever they have the same value (shallow comparison). 137 | * 138 | * @param {Object} props 139 | * @param {Object} prevProps 140 | * @param {Array.} propNames 141 | * @returns {boolean} 142 | */ 143 | export function havePropsChanged(props, prevProps, propNames) { 144 | for (let i = 0; i < propNames.length; i++) { 145 | const propName = propNames[i]; 146 | 147 | if (prevProps.hasOwnProperty(propName) && props[propName] !== prevProps[propName]) { 148 | return true; 149 | } 150 | } 151 | 152 | return false; 153 | } 154 | 155 | /** 156 | * Receives the size the component should have, the padding and the how much vertical space the 157 | * histogram and the density plots should take and calculates the charts sizes and positions 158 | * 159 | * @param {Object} props 160 | * @returns {Object} 161 | * @private 162 | */ 163 | export function calculateChartsPositionsAndSizing(props) { 164 | const { height, renderPlayButton, spaceBetweenCharts, size } = props; 165 | const width = size.width; 166 | 167 | let playButtonPadding = 0; 168 | 169 | if (renderPlayButton) { 170 | playButtonPadding = (width > (PADDING + PADDING)) ? BUTTON_PADDING : 0; 171 | } 172 | 173 | const histogramHeight = height - DENSITY_CHART_HEIGHT_PX - spaceBetweenCharts; 174 | 175 | return { 176 | histogramChartDimensions: { 177 | width: (width - PADDING), 178 | height: histogramHeight, 179 | heightForBars: histogramHeight - X_AXIS_HEIGHT 180 | }, 181 | densityChartDimensions: { 182 | width: width - (PADDING * 4) - playButtonPadding, 183 | height: DENSITY_CHART_HEIGHT_PX 184 | } 185 | }; 186 | } 187 | 188 | /** 189 | * Calculates the size of the histogram and density charts and the domain. 190 | * @param {Object} props 191 | * @param {Array.} previousData 192 | * @param {Object} previousBrushTimeDomain 193 | * @param {Object} [previousBrushDomainFromProps] 194 | * @returns {Object} 195 | */ 196 | export function calculateChartSizesAndDomain(props, previousData, previousBrushTimeDomain, 197 | previousBrushDomainFromProps) { 198 | const { histogramChartDimensions, densityChartDimensions } = calculateChartsPositionsAndSizing(props); 199 | 200 | let nextState = { 201 | histogramChartDimensions, 202 | densityChartDimensions 203 | }; 204 | 205 | if (props.data.length === 0) { 206 | const now = dateToTimestamp(Date.now()); 207 | 208 | return { 209 | ...nextState, 210 | data: [], 211 | brushTimeDomain: { 212 | min: now, 213 | max: now 214 | }, 215 | overallTimeDomain: { 216 | min: now, 217 | max: now 218 | }, 219 | brushDomainFromProps: props.brushDomain 220 | }; 221 | } 222 | 223 | const hasDataChanged = !isHistogramDataEqual(props.xAccessor, props.yAccessor, props.data, previousData); 224 | 225 | // We allow the user to pass a custom brush domain via props, however we only want to honor that 226 | // as long as the user didn't interact with the brush via the UI. 227 | const brushDomainChanged = isObject(props.brushDomain) 228 | && !isEqual(props.brushDomain, previousBrushDomainFromProps); 229 | 230 | // If the new information received is different we need to verify if there is any update in the max and min 231 | // values for the brush domain. 232 | if (hasDataChanged) { 233 | // We need to store the date so that we can compare it to new data comming from `props` 234 | // to see if we need to recalculate the domain 235 | nextState = { ...nextState, data: props.data }; 236 | 237 | const min = d3Min(props.data, props.xAccessor); 238 | 239 | // We're incrementing 1 millisecond in order avoid the last data point to have no width on the histogram 240 | const max = d3Max(props.data, props.xAccessor) + 1; 241 | 242 | // If the brush domain changed we could 243 | if (min !== previousBrushTimeDomain.min || max !== previousBrushTimeDomain.max) { 244 | nextState = { 245 | ...nextState, 246 | brushTimeDomain: { 247 | min: dateToTimestamp(min), 248 | max: dateToTimestamp(max) 249 | }, 250 | overallTimeDomain: { 251 | min: dateToTimestamp(min), 252 | max: dateToTimestamp(max) 253 | } 254 | }; 255 | } 256 | } 257 | 258 | if (brushDomainChanged) { 259 | nextState.brushTimeDomain = props.brushDomain; 260 | } 261 | 262 | nextState.brushDomainFromProps = props.brushDomain; 263 | 264 | return nextState; 265 | } 266 | -------------------------------------------------------------------------------- /src/DensityChart/DensityChart.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import PlayButton from "./PlayButton"; 4 | import { event as d3Event, select as d3Select } from "d3-selection"; 5 | import { 6 | clearCanvas, 7 | drawRect, 8 | getRenderContext 9 | } from "../canvasRenderUtils"; 10 | import { 11 | havePropsChanged 12 | } from "../utils"; 13 | import { brushX } from "d3-brush"; 14 | 15 | /** 16 | * DensityChart 17 | * 18 | * Plots a density strip plot for context when brushing and zooming the histogram. 19 | * 20 | * @author Beatriz Malveiro Jorge (beatriz.jorge@feedzai.com) 21 | * @author Victor Fernandes (victor.fernandes@feedzai.com) 22 | * @author Luis Cardoso (luis.cardoso@feedzai.com) 23 | */ 24 | 25 | export default class DensityChart extends PureComponent { 26 | static propTypes = { 27 | data: PropTypes.arrayOf(PropTypes.object).isRequired, 28 | width: PropTypes.number.isRequired, 29 | height: PropTypes.number.isRequired, 30 | padding: PropTypes.number.isRequired, 31 | overallTimeDomainMax: PropTypes.number, 32 | brushDomainMin: PropTypes.number.isRequired, 33 | brushDomainMax: PropTypes.number.isRequired, 34 | densityChartXScale: PropTypes.func.isRequired, 35 | onDomainChanged: PropTypes.func.isRequired, 36 | xAccessor: PropTypes.func.isRequired, 37 | frameStep: PropTypes.number, 38 | frameDelay: PropTypes.number, 39 | brushDensityChartColor: PropTypes.string, 40 | brushDensityChartFadedColor: PropTypes.string, 41 | renderPlayButton: PropTypes.bool 42 | }; 43 | 44 | static defaultProps = { 45 | renderPlayButton: true, 46 | overallTimeDomainMax: -Infinity, 47 | brushDensityChartColor: "rgba(33, 150, 243, 0.2)", 48 | brushDensityChartFadedColor: "rgba(176, 190, 197, 0.2)" 49 | }; 50 | 51 | constructor(props) { 52 | super(props); 53 | 54 | this.densityChartRef = React.createRef(); 55 | this.densityBrushRef = React.createRef(); 56 | } 57 | 58 | componentDidMount() { 59 | this.densityChartCanvasContext = getRenderContext(this.densityChartRef.current); 60 | 61 | const { width, height, densityChartXScale } = this.props; 62 | 63 | this.brush = brushX() 64 | .extent([ 65 | [0, 0], 66 | [width, height] 67 | ]) 68 | .on("brush end", this._onResizeBrush); 69 | 70 | this._updateBrush(); 71 | 72 | this._moveBrush(densityChartXScale.range()); 73 | 74 | this._drawDensityChart(); 75 | } 76 | 77 | componentDidUpdate(prevProps) { 78 | // We only need to re-render the density chart if the data, the weight, the height or 79 | // the chart x scale have changed. 80 | if (this._shouldRedrawDensityChart(prevProps)) { 81 | let min = this.props.brushDomainMin; 82 | let max = this.props.brushDomainMax; 83 | 84 | const { densityChartXScale, width, height } = this.props; 85 | 86 | if (max >= this.props.overallTimeDomainMax) { 87 | const delta = this.props.brushDomainMax - this.props.brushDomainMin; 88 | 89 | min = this.props.overallTimeDomainMax - delta; 90 | max = this.props.overallTimeDomainMax; 91 | } 92 | 93 | // We need to resize the max value of the brush when the screen has resized 94 | if (prevProps.width !== width || prevProps.height !== height) { 95 | this.brush 96 | .extent([ 97 | [0, 0], 98 | [width, height] 99 | ]); 100 | } 101 | 102 | this._updateBrush(); 103 | 104 | this._moveBrush([ 105 | densityChartXScale(min), 106 | densityChartXScale(max) 107 | ]); 108 | 109 | this._drawDensityChart(); 110 | } 111 | } 112 | 113 | componentWillUnmount() { 114 | clearInterval(this.playInterval); 115 | this.brush.on("brush end", null); // This is the way to unbind events in d3 116 | } 117 | 118 | /** 119 | * Handles brush events. It will update this.state.brushedDomain according to the 120 | * transformation on the event. 121 | * 122 | * @private 123 | */ 124 | _onResizeBrush = () => { 125 | // This occurs always when the user change the brush domain manually 126 | 127 | const event = this._getD3Event(); 128 | 129 | if (event.sourceEvent && event.sourceEvent.type === "zoom") { 130 | return; 131 | } 132 | 133 | let brushSelection; 134 | 135 | if (Array.isArray(event.selection)) { 136 | brushSelection = event.selection; 137 | } else { 138 | // When we don't have any selection we should select everything 139 | brushSelection = this.props.densityChartXScale.range(); 140 | } 141 | 142 | this.props.onDomainChanged(brushSelection); 143 | }; 144 | 145 | /** 146 | * Returns the D3 event object 147 | * 148 | * Used for stubbing in tests. 149 | * 150 | * @returns {Object|null} 151 | */ 152 | _getD3Event() { 153 | return d3Event; 154 | } 155 | 156 | /** 157 | * Reapplies the brush 158 | * @private 159 | */ 160 | _updateBrush() { 161 | if (this.props.data.length === 0) { 162 | return; 163 | } 164 | 165 | d3Select(this.densityBrushRef.current) 166 | .call(this.brush); 167 | } 168 | 169 | /** 170 | * Moves brush on density strip plot to given domain 171 | * @private 172 | * @param {Array} domain 173 | */ 174 | _moveBrush = (domain) => { 175 | if (this.props.data.length === 0) { 176 | return; 177 | } 178 | 179 | d3Select(this.densityBrushRef.current) 180 | .call(this.brush.move, domain); 181 | }; 182 | 183 | /** 184 | * Returns whenever it is necessary to re-render the density chart, based on the current and previous 185 | * props. 186 | * @param {Object} prevProps 187 | * @returns {boolean} 188 | */ 189 | _shouldRedrawDensityChart(prevProps) { 190 | return havePropsChanged(this.props, prevProps, [ 191 | "brushDomainMin", 192 | "brushDomainMax", 193 | "data", 194 | "width", 195 | "height", 196 | "densityChartXScale" 197 | ]); 198 | } 199 | 200 | /** 201 | * Draws density strip plot in canvas. 202 | * (Using canvas instead of svg for performance reasons as number of datapoints 203 | * can be very large) 204 | * 205 | * @private 206 | */ 207 | _drawDensityChart() { 208 | const { 209 | width, 210 | height, 211 | densityChartXScale, 212 | brushDomainMax, 213 | brushDomainMin, 214 | xAccessor, 215 | data, 216 | brushDensityChartColor, 217 | brushDensityChartFadedColor 218 | } = this.props; 219 | 220 | clearCanvas(this.densityChartCanvasContext, width, height); 221 | 222 | for (let i = 0; i < data.length; ++i) { 223 | const x = xAccessor(data[i]); 224 | const isInsideOfBrushDomain = x >= brushDomainMin && x < brushDomainMax; 225 | 226 | drawRect( 227 | this.densityChartCanvasContext, // canvas context 228 | densityChartXScale(x), // x 229 | 0, // y 230 | 2, // width 231 | height, // height 232 | { 233 | fillStyle: isInsideOfBrushDomain ? brushDensityChartColor : brushDensityChartFadedColor 234 | } 235 | ); 236 | } 237 | } 238 | 239 | /** 240 | * Renders the play button that allows to replay a time-lapse of the events. 241 | * @returns {React.Element|null} 242 | */ 243 | _renderPlayButton() { 244 | const { width, densityChartXScale, brushDomainMax, brushDomainMin, 245 | frameStep, frameDelay, renderPlayButton } = this.props; 246 | 247 | if (!renderPlayButton) { 248 | return null; 249 | } 250 | 251 | return ( 252 | 261 | ); 262 | } 263 | 264 | render() { 265 | let leftPadding = 0; 266 | 267 | const { width, height, padding } = this.props; 268 | 269 | if (!this.props.renderPlayButton) { 270 | leftPadding = padding * 2; 271 | } 272 | 273 | const densityChartCanvasStyle = { left: leftPadding }; 274 | 275 | return ( 276 |
277 | {this._renderPlayButton()} 278 |
279 | 287 | 295 |
296 |
297 | ); 298 | } 299 | } 300 | -------------------------------------------------------------------------------- /src/Histogram/__snapshots__/Histogram.test.js.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`_renderDensityChart should render an histogram bar 1`] = ` 4 | Array [ 5 | , 14 | ] 15 | `; 16 | 17 | exports[`render does a baseline render 1`] = ` 18 | 116 |
119 | 129 | 132 | 136 | 140 | 141 | 200 |
203 | 212 | 237 | 238 |
246 | 257 | 270 |
271 |
272 |
273 |
274 |
275 | `; 276 | 277 | exports[`render renders an empty chart if no data is passed 1`] = ` 278 | 333 |
336 | 346 | 349 | 353 | 357 | 358 | 374 |
377 |
385 | 396 | 409 |
410 |
411 |
412 |
413 |
414 | `; 415 | -------------------------------------------------------------------------------- /src/utils.test.js: -------------------------------------------------------------------------------- 1 | import { 2 | isObject, 3 | histogramDefaultYAxisFormatter, 4 | multiDateFormat, 5 | isHistogramDataEqual, 6 | dateToTimestamp, 7 | calculateChartsPositionsAndSizing, 8 | calculateChartSizesAndDomain, 9 | havePropsChanged 10 | } from "./utils"; 11 | import { max as d3Max, min as d3Min } from "d3-array"; 12 | import { smallSample } from "../stories/sampleData"; 13 | 14 | let xAccessor, yAccessor, previousBrushDomain; 15 | 16 | // We are doing this because `timeMonth` returns dates with an 1 hour offset 17 | // locally and in ci, even though the timezone is the same. 18 | jest.mock("d3-time", () => ({ 19 | timeSecond: (date) => date, 20 | timeMinute: (date) => date, 21 | timeHour: (date) => date, 22 | timeDay: (date) => date, 23 | timeWeek: (date) => date, 24 | timeMonth: (date) => date, 25 | timeYear: (date) => date 26 | })); 27 | 28 | beforeEach(() => { 29 | const brushDomainMin = d3Min(smallSample, xAccessor); 30 | const brushDomainMax = d3Max(smallSample, xAccessor) + 1; 31 | 32 | xAccessor = (elm) => elm.timestamp; 33 | yAccessor = (elm) => elm.total; 34 | previousBrushDomain = { max: brushDomainMax, min: brushDomainMin }; 35 | }); 36 | 37 | describe("isObject", () => { 38 | it("returns true if an object is passed", () => { 39 | expect(isObject({})).toBe(true); 40 | expect(isObject([])).toBe(true); 41 | expect(isObject(() => {})).toBe(true); 42 | }); 43 | 44 | it("returns false if a non object is passed", () => { 45 | expect(isObject(1)).toBe(false); 46 | expect(isObject("")).toBe(false); 47 | expect(isObject(null)).toBe(false); 48 | expect(isObject(undefined)).toBe(false); 49 | }); 50 | }); 51 | 52 | describe("histogramDefaultYAxisFormatter", () => { 53 | it("returns the value if it is an integer bigger than zero", () => { 54 | expect(histogramDefaultYAxisFormatter(1)).toBe(1); 55 | }); 56 | 57 | it("returns the value if it is an integer smaller or equal to zero", () => { 58 | expect(histogramDefaultYAxisFormatter(-1)).toBe(""); 59 | expect(histogramDefaultYAxisFormatter(0)).toBe(""); 60 | }); 61 | 62 | it("returns an empty string if the value is decimal", () => { 63 | expect(histogramDefaultYAxisFormatter(1.1)).toBe(""); 64 | }); 65 | 66 | it("returns an empty string if the value is not a number", () => { 67 | expect(histogramDefaultYAxisFormatter({})).toBe(""); 68 | }); 69 | }); 70 | 71 | describe("multiDateFormat", () => { 72 | it("returns a string representation of the date", () => { 73 | expect(multiDateFormat(new Date(1533164400000))).toBe("2018"); 74 | }); 75 | }); 76 | 77 | describe("isHistogramDataEqual", () => { 78 | it("returns true if the data is equal for x and y", () => { 79 | expect(isHistogramDataEqual((elm) => elm.timestamp, (elm) => elm.amount, [{ 80 | timestamp: 1, 81 | amount: 2 82 | }, { 83 | timestamp: 2, 84 | amount: 3 85 | }], [{ 86 | timestamp: 1, 87 | amount: 2 88 | }, { 89 | timestamp: 2, 90 | amount: 3 91 | }])).toBe(true); 92 | }); 93 | 94 | it("returns false if the data is not equal for x", () => { 95 | expect(isHistogramDataEqual((elm) => elm.timestamp, (elm) => elm.amount, [{ 96 | timestamp: 1, 97 | amount: 2 98 | }, { 99 | timestamp: 2, 100 | amount: 3 101 | }], [{ 102 | timestamp: 4, 103 | amount: 2 104 | }, { 105 | timestamp: 2, 106 | amount: 3 107 | }])).toBe(false); 108 | }); 109 | 110 | it("returns false if the data is not equal for y", () => { 111 | expect(isHistogramDataEqual((elm) => elm.timestamp, (elm) => elm.amount, [{ 112 | timestamp: 1, 113 | amount: 2 114 | }, { 115 | timestamp: 2, 116 | amount: 3 117 | }], [{ 118 | timestamp: 1, 119 | amount: 2 120 | }, { 121 | timestamp: 2, 122 | amount: 8 123 | }])).toBe(false); 124 | }); 125 | 126 | it("returns false if any of the parameters is not an array", () => { 127 | expect(isHistogramDataEqual((elm) => elm.timestamp, (elm) => elm.amount, null, [{ 128 | timestamp: 1, 129 | amount: 2 130 | }, { 131 | timestamp: 2, 132 | amount: 8 133 | }])).toBe(false); 134 | 135 | expect(isHistogramDataEqual((elm) => elm.timestamp, (elm) => elm.amount, [{ 136 | timestamp: 1, 137 | amount: 2 138 | }, { 139 | timestamp: 2, 140 | amount: 8 141 | }], null)).toBe(false); 142 | }); 143 | }); 144 | 145 | describe("dateToTimestamp", () => { 146 | it("returns the correspondent timestamp if a date object is passed", () => { 147 | expect(dateToTimestamp(new Date(1533164400000))).toBe(1533164400000); 148 | }); 149 | 150 | it("returns the number if a number is passed", () => { 151 | expect(dateToTimestamp(1533164400000)).toBe(1533164400000); 152 | }); 153 | }); 154 | 155 | describe("havePropsChanged", () => { 156 | it("returns true if a prop has changed", () => { 157 | expect(havePropsChanged({ name: "bob", age: 12 }, { name: "gary" }, ["name", "age"])).toBe(true); 158 | }); 159 | 160 | it("returns false if no prop has changed", () => { 161 | expect(havePropsChanged({ name: "bob", age: 12 }, { name: "bob" }, ["name", "age"])).toBe(false); 162 | }); 163 | 164 | it("returns false if no listed prop has changed", () => { 165 | expect(havePropsChanged({ name: "gary", age: 12 }, { name: "bob" }, ["age"])).toBe(false); 166 | expect(havePropsChanged({ name: "gary", age: 12 }, { name: "bob", age: 12 }, ["age"])).toBe(false); 167 | }); 168 | }); 169 | 170 | describe("calculateChartsPositionsAndSizing", () => { 171 | it("calculate the sizes correctly if the play button is rendered", () => { 172 | expect(calculateChartsPositionsAndSizing({ 173 | height: 150, 174 | renderPlayButton: true, 175 | spaceBetweenCharts: 15, 176 | size: { 177 | width: 1000 178 | } 179 | })).toEqual({ 180 | "densityChartDimensions": { 181 | "height": 20, 182 | "width": 940 183 | }, 184 | "histogramChartDimensions": { 185 | "height": 115, 186 | "heightForBars": 97, 187 | "width": 990 188 | } 189 | }); 190 | }); 191 | 192 | it("calculate the sizes correctly if the play button is not rendered", () => { 193 | expect(calculateChartsPositionsAndSizing({ 194 | height: 150, 195 | renderPlayButton: false, 196 | spaceBetweenCharts: 15, 197 | size: { 198 | width: 1000 199 | } 200 | })).toEqual({ 201 | "densityChartDimensions": { 202 | "height": 20, 203 | "width": 960 204 | }, 205 | "histogramChartDimensions": { 206 | "height": 115, 207 | "heightForBars": 97, 208 | "width": 990 209 | } 210 | }); 211 | }); 212 | }); 213 | 214 | describe("calculateChartSizesAndDomain", () => { 215 | it("returns the data if the data has changed", () => { 216 | expect(calculateChartSizesAndDomain({ 217 | height: 150, 218 | renderPlayButton: false, 219 | spaceBetweenCharts: 15, 220 | size: { 221 | width: 1000 222 | }, 223 | data: smallSample, 224 | xAccessor: xAccessor, 225 | yAccessor: yAccessor 226 | }, smallSample.slice(1), previousBrushDomain)).toEqual({ 227 | "data": smallSample, 228 | "densityChartDimensions": { 229 | "height": 20, 230 | "width": 960 231 | }, 232 | "histogramChartDimensions": { 233 | "height": 115, 234 | "heightForBars": 97, 235 | "width": 990 236 | } 237 | }); 238 | }); 239 | 240 | it("should return the data and domain if the domain has changed", () => { 241 | expect(calculateChartSizesAndDomain({ 242 | height: 150, 243 | renderPlayButton: false, 244 | spaceBetweenCharts: 15, 245 | size: { 246 | width: 1000 247 | }, 248 | data: smallSample, 249 | xAccessor: xAccessor, 250 | yAccessor: yAccessor 251 | }, smallSample.slice(1), { min: 1533164500146, max: 1533167401146 })).toEqual({ 252 | "data": smallSample, 253 | "brushTimeDomain": { 254 | "max": 1534164400001, 255 | "min": 1533309900034 256 | }, 257 | "overallTimeDomain": { 258 | "max": 1534164400001, 259 | "min": 1533309900034 260 | }, 261 | "densityChartDimensions": { 262 | "height": 20, 263 | "width": 960 264 | }, 265 | "histogramChartDimensions": { 266 | "height": 115, 267 | "heightForBars": 97, 268 | "width": 990 269 | } 270 | }); 271 | }); 272 | 273 | it("should return the empty data even if the previousData had some value", () => { 274 | const ret = calculateChartSizesAndDomain({ 275 | height: 150, 276 | renderPlayButton: false, 277 | spaceBetweenCharts: 15, 278 | size: { 279 | width: 1000 280 | }, 281 | data: [], 282 | xAccessor: xAccessor, 283 | yAccessor: yAccessor 284 | }, smallSample, { min: 1533164500146, max: 1533167401146 }); 285 | 286 | expect(ret.data).toEqual([]); 287 | }); 288 | 289 | it("returns doesn't return data and domain if the data has not changed", () => { 290 | expect(calculateChartSizesAndDomain({ 291 | height: 150, 292 | renderPlayButton: false, 293 | spaceBetweenCharts: 15, 294 | size: { 295 | width: 1000 296 | }, 297 | data: smallSample, 298 | xAccessor: xAccessor, 299 | yAccessor: yAccessor 300 | }, smallSample, previousBrushDomain)).toEqual({ 301 | "densityChartDimensions": { 302 | "height": 20, 303 | "width": 960 304 | }, 305 | "histogramChartDimensions": { 306 | "height": 115, 307 | "heightForBars": 97, 308 | "width": 990 309 | } 310 | }); 311 | }); 312 | 313 | it("overrides the brushDomain if one is passed from the props", () => { 314 | const previousBrushTimeDomain = { min: 1533164500146, max: 1533167401146 }; 315 | const brushDomain = { min: 1533164500146, max: 1533167411146 }; 316 | 317 | expect(calculateChartSizesAndDomain({ 318 | height: 150, 319 | renderPlayButton: false, 320 | spaceBetweenCharts: 15, 321 | size: { 322 | width: 1000 323 | }, 324 | data: smallSample, 325 | xAccessor: xAccessor, 326 | yAccessor: yAccessor, 327 | brushDomain: brushDomain 328 | }, smallSample, previousBrushDomain, previousBrushTimeDomain)).toEqual({ 329 | "densityChartDimensions": { 330 | "height": 20, 331 | "width": 960 332 | }, 333 | "histogramChartDimensions": { 334 | "height": 115, 335 | "heightForBars": 97, 336 | "width": 990 337 | }, 338 | brushTimeDomain: brushDomain, 339 | brushDomainFromProps: brushDomain 340 | }); 341 | }); 342 | 343 | it("does not overridde the brushDomain if the one is passed from the props if the same as the last one", () => { 344 | const previousBrushTimeDomain = { min: 1533164500146, max: 1533167401146 }; 345 | 346 | expect(calculateChartSizesAndDomain({ 347 | height: 150, 348 | renderPlayButton: false, 349 | spaceBetweenCharts: 15, 350 | size: { 351 | width: 1000 352 | }, 353 | data: smallSample, 354 | xAccessor: xAccessor, 355 | yAccessor: yAccessor, 356 | brushDomain: previousBrushTimeDomain 357 | }, smallSample, previousBrushDomain, previousBrushTimeDomain)).toEqual({ 358 | "densityChartDimensions": { 359 | "height": 20, 360 | "width": 960 361 | }, 362 | "histogramChartDimensions": { 363 | "height": 115, 364 | "heightForBars": 97, 365 | "width": 990 366 | }, 367 | brushDomainFromProps: previousBrushTimeDomain 368 | }); 369 | }); 370 | }); 371 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright 2018 Feedzai (www.feedzai.com) 2 | 3 | This software is licensed under the Apache License, Version 2.0 (the "Apache License") or the GNU 4 | Lesser General Public License version 3 (the "GPL License"). You may choose either license to govern 5 | your use of this software only upon the condition that you accept all of the terms of either the Apache 6 | License or the LGPL License. 7 | 8 | You may obtain a copy of the Apache License and the LGPL License at: 9 | 10 | http://www.apache.org/licenses/LICENSE-2.0.txt 11 | http://www.gnu.org/licenses/lgpl-3.0.txt 12 | 13 | Unless required by applicable law or agreed to in writing, software distributed under the Apache License 14 | or the LGPL License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, 15 | either express or implied. See the Apache License and the LGPL License for the specific language governing 16 | permissions and limitations under the Apache License and the LGPL License. 17 | 18 | LICENSE #1: Apache License, Version 2.0 19 | 20 | LICENSE #2: LGPL v3 21 | 22 | ************************************************************************************* 23 | 24 | LICENSE #1: 25 | 26 | Apache License 27 | Version 2.0, January 2004 28 | http://www.apache.org/licenses/ 29 | 30 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 31 | 32 | 1. Definitions. 33 | 34 | "License" shall mean the terms and conditions for use, reproduction, 35 | and distribution as defined by Sections 1 through 9 of this document. 36 | 37 | "Licensor" shall mean the copyright owner or entity authorized by 38 | the copyright owner that is granting the License. 39 | 40 | "Legal Entity" shall mean the union of the acting entity and all 41 | other entities that control, are controlled by, or are under common 42 | control with that entity. For the purposes of this definition, 43 | "control" means (i) the power, direct or indirect, to cause the 44 | direction or management of such entity, whether by contract or 45 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 46 | outstanding shares, or (iii) beneficial ownership of such entity. 47 | 48 | "You" (or "Your") shall mean an individual or Legal Entity 49 | exercising permissions granted by this License. 50 | 51 | "Source" form shall mean the preferred form for making modifications, 52 | including but not limited to software source code, documentation 53 | source, and configuration files. 54 | 55 | "Object" form shall mean any form resulting from mechanical 56 | transformation or translation of a Source form, including but 57 | not limited to compiled object code, generated documentation, 58 | and conversions to other media types. 59 | 60 | "Work" shall mean the work of authorship, whether in Source or 61 | Object form, made available under the License, as indicated by a 62 | copyright notice that is included in or attached to the work 63 | (an example is provided in the Appendix below). 64 | 65 | "Derivative Works" shall mean any work, whether in Source or Object 66 | form, that is based on (or derived from) the Work and for which the 67 | editorial revisions, annotations, elaborations, or other modifications 68 | represent, as a whole, an original work of authorship. For the purposes 69 | of this License, Derivative Works shall not include works that remain 70 | separable from, or merely link (or bind by name) to the interfaces of, 71 | the Work and Derivative Works thereof. 72 | 73 | "Contribution" shall mean any work of authorship, including 74 | the original version of the Work and any modifications or additions 75 | to that Work or Derivative Works thereof, that is intentionally 76 | submitted to Licensor for inclusion in the Work by the copyright owner 77 | or by an individual or Legal Entity authorized to submit on behalf of 78 | the copyright owner. For the purposes of this definition, "submitted" 79 | means any form of electronic, verbal, or written communication sent 80 | to the Licensor or its representatives, including but not limited to 81 | communication on electronic mailing lists, source code control systems, 82 | and issue tracking systems that are managed by, or on behalf of, the 83 | Licensor for the purpose of discussing and improving the Work, but 84 | excluding communication that is conspicuously marked or otherwise 85 | designated in writing by the copyright owner as "Not a Contribution." 86 | 87 | "Contributor" shall mean Licensor and any individual or Legal Entity 88 | on behalf of whom a Contribution has been received by Licensor and 89 | subsequently incorporated within the Work. 90 | 91 | 2. Grant of Copyright License. Subject to the terms and conditions of 92 | this License, each Contributor hereby grants to You a perpetual, 93 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 94 | copyright license to reproduce, prepare Derivative Works of, 95 | publicly display, publicly perform, sublicense, and distribute the 96 | Work and such Derivative Works in Source or Object form. 97 | 98 | 3. Grant of Patent License. Subject to the terms and conditions of 99 | this License, each Contributor hereby grants to You a perpetual, 100 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 101 | (except as stated in this section) patent license to make, have made, 102 | use, offer to sell, sell, import, and otherwise transfer the Work, 103 | where such license applies only to those patent claims licensable 104 | by such Contributor that are necessarily infringed by their 105 | Contribution(s) alone or by combination of their Contribution(s) 106 | with the Work to which such Contribution(s) was submitted. If You 107 | institute patent litigation against any entity (including a 108 | cross-claim or counterclaim in a lawsuit) alleging that the Work 109 | or a Contribution incorporated within the Work constitutes direct 110 | or contributory patent infringement, then any patent licenses 111 | granted to You under this License for that Work shall terminate 112 | as of the date such litigation is filed. 113 | 114 | 4. Redistribution. You may reproduce and distribute copies of the 115 | Work or Derivative Works thereof in any medium, with or without 116 | modifications, and in Source or Object form, provided that You 117 | meet the following conditions: 118 | 119 | (a) You must give any other recipients of the Work or 120 | Derivative Works a copy of this License; and 121 | 122 | (b) You must cause any modified files to carry prominent notices 123 | stating that You changed the files; and 124 | 125 | (c) You must retain, in the Source form of any Derivative Works 126 | that You distribute, all copyright, patent, trademark, and 127 | attribution notices from the Source form of the Work, 128 | excluding those notices that do not pertain to any part of 129 | the Derivative Works; and 130 | 131 | (d) If the Work includes a "NOTICE" text file as part of its 132 | distribution, then any Derivative Works that You distribute must 133 | include a readable copy of the attribution notices contained 134 | within such NOTICE file, excluding those notices that do not 135 | pertain to any part of the Derivative Works, in at least one 136 | of the following places: within a NOTICE text file distributed 137 | as part of the Derivative Works; within the Source form or 138 | documentation, if provided along with the Derivative Works; or, 139 | within a display generated by the Derivative Works, if and 140 | wherever such third-party notices normally appear. The contents 141 | of the NOTICE file are for informational purposes only and 142 | do not modify the License. You may add Your own attribution 143 | notices within Derivative Works that You distribute, alongside 144 | or as an addendum to the NOTICE text from the Work, provided 145 | that such additional attribution notices cannot be construed 146 | as modifying the License. 147 | 148 | You may add Your own copyright statement to Your modifications and 149 | may provide additional or different license terms and conditions 150 | for use, reproduction, or distribution of Your modifications, or 151 | for any such Derivative Works as a whole, provided Your use, 152 | reproduction, and distribution of the Work otherwise complies with 153 | the conditions stated in this License. 154 | 155 | 5. Submission of Contributions. Unless You explicitly state otherwise, 156 | any Contribution intentionally submitted for inclusion in the Work 157 | by You to the Licensor shall be under the terms and conditions of 158 | this License, without any additional terms or conditions. 159 | Notwithstanding the above, nothing herein shall supersede or modify 160 | the terms of any separate license agreement you may have executed 161 | with Licensor regarding such Contributions. 162 | 163 | 6. Trademarks. This License does not grant permission to use the trade 164 | names, trademarks, service marks, or product names of the Licensor, 165 | except as required for reasonable and customary use in describing the 166 | origin of the Work and reproducing the content of the NOTICE file. 167 | 168 | 7. Disclaimer of Warranty. Unless required by applicable law or 169 | agreed to in writing, Licensor provides the Work (and each 170 | Contributor provides its Contributions) on an "AS IS" BASIS, 171 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 172 | implied, including, without limitation, any warranties or conditions 173 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 174 | PARTICULAR PURPOSE. You are solely responsible for determining the 175 | appropriateness of using or redistributing the Work and assume any 176 | risks associated with Your exercise of permissions under this License. 177 | 178 | 8. Limitation of Liability. In no event and under no legal theory, 179 | whether in tort (including negligence), contract, or otherwise, 180 | unless required by applicable law (such as deliberate and grossly 181 | negligent acts) or agreed to in writing, shall any Contributor be 182 | liable to You for damages, including any direct, indirect, special, 183 | incidental, or consequential damages of any character arising as a 184 | result of this License or out of the use or inability to use the 185 | Work (including but not limited to damages for loss of goodwill, 186 | work stoppage, computer failure or malfunction, or any and all 187 | other commercial damages or losses), even if such Contributor 188 | has been advised of the possibility of such damages. 189 | 190 | 9. Accepting Warranty or Additional Liability. While redistributing 191 | the Work or Derivative Works thereof, You may choose to offer, 192 | and charge a fee for, acceptance of support, warranty, indemnity, 193 | or other liability obligations and/or rights consistent with this 194 | License. However, in accepting such obligations, You may act only 195 | on Your own behalf and on Your sole responsibility, not on behalf 196 | of any other Contributor, and only if You agree to indemnify, 197 | defend, and hold each Contributor harmless for any liability 198 | incurred by, or claims asserted against, such Contributor by reason 199 | of your accepting any such warranty or additional liability. 200 | 201 | ************************************************************************************* 202 | 203 | LICENSE #2: 204 | 205 | GNU LESSER GENERAL PUBLIC LICENSE 206 | Version 3, 29 June 2007 207 | 208 | Copyright (C) 2007 Free Software Foundation, Inc. 209 | Everyone is permitted to copy and distribute verbatim copies 210 | of this license document, but changing it is not allowed. 211 | 212 | 213 | This version of the GNU Lesser General Public License incorporates 214 | the terms and conditions of version 3 of the GNU General Public 215 | License, supplemented by the additional permissions listed below. 216 | 217 | 0. Additional Definitions. 218 | 219 | As used herein, "this License" refers to version 3 of the GNU Lesser 220 | General Public License, and the "GNU GPL" refers to version 3 of the GNU 221 | General Public License. 222 | 223 | "The Library" refers to a covered work governed by this License, 224 | other than an Application or a Combined Work as defined below. 225 | 226 | An "Application" is any work that makes use of an interface provided 227 | by the Library, but which is not otherwise based on the Library. 228 | Defining a subclass of a class defined by the Library is deemed a mode 229 | of using an interface provided by the Library. 230 | 231 | A "Combined Work" is a work produced by combining or linking an 232 | Application with the Library. The particular version of the Library 233 | with which the Combined Work was made is also called the "Linked 234 | Version". 235 | 236 | The "Minimal Corresponding Source" for a Combined Work means the 237 | Corresponding Source for the Combined Work, excluding any source code 238 | for portions of the Combined Work that, considered in isolation, are 239 | based on the Application, and not on the Linked Version. 240 | 241 | The "Corresponding Application Code" for a Combined Work means the 242 | object code and/or source code for the Application, including any data 243 | and utility programs needed for reproducing the Combined Work from the 244 | Application, but excluding the System Libraries of the Combined Work. 245 | 246 | 1. Exception to Section 3 of the GNU GPL. 247 | 248 | You may convey a covered work under sections 3 and 4 of this License 249 | without being bound by section 3 of the GNU GPL. 250 | 251 | 2. Conveying Modified Versions. 252 | 253 | If you modify a copy of the Library, and, in your modifications, a 254 | facility refers to a function or data to be supplied by an Application 255 | that uses the facility (other than as an argument passed when the 256 | facility is invoked), then you may convey a copy of the modified 257 | version: 258 | 259 | a) under this License, provided that you make a good faith effort to 260 | ensure that, in the event an Application does not supply the 261 | function or data, the facility still operates, and performs 262 | whatever part of its purpose remains meaningful, or 263 | 264 | b) under the GNU GPL, with none of the additional permissions of 265 | this License applicable to that copy. 266 | 267 | 3. Object Code Incorporating Material from Library Header Files. 268 | 269 | The object code form of an Application may incorporate material from 270 | a header file that is part of the Library. You may convey such object 271 | code under terms of your choice, provided that, if the incorporated 272 | material is not limited to numerical parameters, data structure 273 | layouts and accessors, or small macros, inline functions and templates 274 | (ten or fewer lines in length), you do both of the following: 275 | 276 | a) Give prominent notice with each copy of the object code that the 277 | Library is used in it and that the Library and its use are 278 | covered by this License. 279 | 280 | b) Accompany the object code with a copy of the GNU GPL and this license 281 | document. 282 | 283 | 4. Combined Works. 284 | 285 | You may convey a Combined Work under terms of your choice that, 286 | taken together, effectively do not restrict modification of the 287 | portions of the Library contained in the Combined Work and reverse 288 | engineering for debugging such modifications, if you also do each of 289 | the following: 290 | 291 | a) Give prominent notice with each copy of the Combined Work that 292 | the Library is used in it and that the Library and its use are 293 | covered by this License. 294 | 295 | b) Accompany the Combined Work with a copy of the GNU GPL and this license 296 | document. 297 | 298 | c) For a Combined Work that displays copyright notices during 299 | execution, include the copyright notice for the Library among 300 | these notices, as well as a reference directing the user to the 301 | copies of the GNU GPL and this license document. 302 | 303 | d) Do one of the following: 304 | 305 | 0) Convey the Minimal Corresponding Source under the terms of this 306 | License, and the Corresponding Application Code in a form 307 | suitable for, and under terms that permit, the user to 308 | recombine or relink the Application with a modified version of 309 | the Linked Version to produce a modified Combined Work, in the 310 | manner specified by section 6 of the GNU GPL for conveying 311 | Corresponding Source. 312 | 313 | 1) Use a suitable shared library mechanism for linking with the 314 | Library. A suitable mechanism is one that (a) uses at run time 315 | a copy of the Library already present on the user's computer 316 | system, and (b) will operate properly with a modified version 317 | of the Library that is interface-compatible with the Linked 318 | Version. 319 | 320 | e) Provide Installation Information, but only if you would otherwise 321 | be required to provide such information under section 6 of the 322 | GNU GPL, and only to the extent that such information is 323 | necessary to install and execute a modified version of the 324 | Combined Work produced by recombining or relinking the 325 | Application with a modified version of the Linked Version. (If 326 | you use option 4d0, the Installation Information must accompany 327 | the Minimal Corresponding Source and Corresponding Application 328 | Code. If you use option 4d1, you must provide the Installation 329 | Information in the manner specified by section 6 of the GNU GPL 330 | for conveying Corresponding Source.) 331 | 332 | 5. Combined Libraries. 333 | 334 | You may place library facilities that are a work based on the 335 | Library side by side in a single library together with other library 336 | facilities that are not Applications and are not covered by this 337 | License, and convey such a combined library under terms of your 338 | choice, if you do both of the following: 339 | 340 | a) Accompany the combined library with a copy of the same work based 341 | on the Library, uncombined with any other library facilities, 342 | conveyed under the terms of this License. 343 | 344 | b) Give prominent notice with the combined library that part of it 345 | is a work based on the Library, and explaining where to find the 346 | accompanying uncombined form of the same work. 347 | 348 | 6. Revised Versions of the GNU Lesser General Public License. 349 | 350 | The Free Software Foundation may publish revised and/or new versions 351 | of the GNU Lesser General Public License from time to time. Such new 352 | versions will be similar in spirit to the present version, but may 353 | differ in detail to address new problems or concerns. 354 | 355 | Each version is given a distinguishing version number. If the 356 | Library as you received it specifies that a certain numbered version 357 | of the GNU Lesser General Public License "or any later version" 358 | applies to it, you have the option of following the terms and 359 | conditions either of that published version or of any later version 360 | published by the Free Software Foundation. If the Library as you 361 | received it does not specify a version number of the GNU Lesser 362 | General Public License, you may choose any version of the GNU Lesser 363 | General Public License ever published by the Free Software Foundation. 364 | 365 | If the Library as you received it specifies that a proxy can decide 366 | whether future versions of the GNU Lesser General Public License shall 367 | apply, that proxy's public statement of acceptance of any version is 368 | permanent authorization for you to choose that version for the 369 | Library. 370 | -------------------------------------------------------------------------------- /src/Histogram/Histogram.js: -------------------------------------------------------------------------------- 1 | import React, { PureComponent } from "react"; 2 | import PropTypes from "prop-types"; 3 | import { max as d3Max } from "d3-array"; 4 | import { scaleTime, scaleLinear } from "d3-scale"; 5 | import { event as d3Event, select as d3Select } from "d3-selection"; 6 | import { axisBottom as d3AxisBottom, axisLeft as d3AxisLeft } from "d3-axis"; 7 | import { timeMillisecond } from "d3-time"; 8 | import { withSize } from "react-sizeme"; 9 | import { 10 | histogramDefaultYAxisFormatter, 11 | multiDateFormat, 12 | isHistogramDataEqual, 13 | dateToTimestamp, 14 | calculateChartSizesAndDomain 15 | } from "../utils"; 16 | import { 17 | X_AXIS_PADDING, 18 | Y_AXIS_PADDING, 19 | BARS_TICK_RATIO, 20 | MIN_ZOOM_VALUE, 21 | MIN_TOTAL_HEIGHT, 22 | PADDING 23 | } from "../constants"; 24 | import histogramBinCalculator from "./histogramBinCalculator"; 25 | import { calculatePositionAndDimensions } from "./histogramBarGeometry"; 26 | import { zoom as d3Zoom, zoomIdentity as d3ZoomIdentity } from "d3-zoom"; 27 | import DensityChart from "../DensityChart/DensityChart"; 28 | import BarTooltip from "./BarTooltip"; 29 | 30 | /** 31 | * Histogram 32 | * 33 | * Plots an histogram with zoom and brush features on the x domain. 34 | * Also plots a density strip plot for context when brushing and zooming the histogram. 35 | * 36 | * @author Beatriz Malveiro Jorge (beatriz.jorge@feedzai.com) 37 | * @author Victor Fernandes (victor.fernandes@feedzai.com) ("productization" process) 38 | * @author Luis Cardoso (luis.cardoso@feedzai.com) 39 | */ 40 | 41 | export class Histogram extends PureComponent { 42 | static propTypes = { 43 | data: PropTypes.array.isRequired, 44 | size: PropTypes.shape({ 45 | width: PropTypes.number.isRequired 46 | }).isRequired, 47 | defaultBarCount: PropTypes.number, 48 | xAccessor: PropTypes.func.isRequired, 49 | xAxisFormatter: PropTypes.func, 50 | yAccessor: PropTypes.func.isRequired, 51 | spaceBetweenCharts: PropTypes.number, 52 | barOptions: PropTypes.object, 53 | yAxisTicks: PropTypes.number, 54 | yAxisFormatter: PropTypes.func, 55 | brushDensityChartColor: PropTypes.string, 56 | brushDensityChartFadedColor: PropTypes.string, 57 | tooltipBarCustomization: PropTypes.func, 58 | onIntervalChange: PropTypes.func, 59 | minZoomUnit: PropTypes.number, 60 | frameStep: PropTypes.number, 61 | frameDelay: PropTypes.number, 62 | renderPlayButton: PropTypes.bool 63 | }; 64 | 65 | static defaultProps = { 66 | data: [], 67 | height: MIN_TOTAL_HEIGHT, 68 | padding: 10, 69 | defaultBarCount: 18, 70 | barOptions: { 71 | margin: 1 72 | }, 73 | spaceBetweenCharts: 10, 74 | yAxisTicks: 3, 75 | xAxisFormatter: multiDateFormat, 76 | yAxisFormatter: histogramDefaultYAxisFormatter, 77 | tooltipBarCustomization: null, 78 | onIntervalChange: () => {}, 79 | minZoomUnit: 1000, 80 | renderPlayButton: true 81 | }; 82 | 83 | static getDerivedStateFromProps(props, state) { 84 | if (props.height < MIN_TOTAL_HEIGHT) { 85 | throw new Error(`The minimum height is ${MIN_TOTAL_HEIGHT}px.`); 86 | } 87 | 88 | // Sometimes the width will be zero, for example when switching between storybook 89 | // stories. In those cases we don't want to do anything so that the histogram 90 | // does not enter into an invalid state. 91 | if (props.size.width === 0) { 92 | return null; 93 | } 94 | 95 | const nextState = calculateChartSizesAndDomain(props, state.data, state.brushTimeDomain, 96 | state.brushDomainFromProps); 97 | 98 | return Object.keys(nextState).length > 0 ? nextState : null; 99 | } 100 | 101 | constructor(props) { 102 | super(props); 103 | 104 | this.histogramChartRef = React.createRef(); 105 | this.histogramXAxisRef = React.createRef(); 106 | this.histogramYAxisRef = React.createRef(); 107 | 108 | // We need to compute the widths and domain right at the constructor because we 109 | // need them to compute the scales correctly, which are needed in the children 110 | this.state = Object.assign({ 111 | timeHistogramBars: [], 112 | selectedBarPosition: {}, 113 | showHistogramBarTooltip: false 114 | }, calculateChartSizesAndDomain(props, [], { 115 | max: -Infinity, 116 | min: Infinity 117 | })); 118 | 119 | this._createScaleAndZoom(); 120 | } 121 | 122 | componentDidMount() { 123 | this._setUpZoomAndChartScales(); 124 | } 125 | 126 | componentDidUpdate(prevProps) { 127 | const hasWidthChanged = prevProps.size.width !== this.props.size.width; 128 | const hasDataChanged = prevProps.data.length !== this.props.data.length 129 | || !isHistogramDataEqual(this.props.xAccessor, this.props.yAccessor, prevProps.data, this.props.data); 130 | const hasAcessorsChanged = this.props.xAccessor !== prevProps.xAccessor 131 | || this.props.yAccessor !== prevProps.yAccessor; 132 | 133 | if (hasWidthChanged || hasDataChanged || hasAcessorsChanged) { 134 | this._createScaleAndZoom(); 135 | this._setUpZoomAndChartScales(); 136 | } 137 | } 138 | 139 | componentWillUnmount() { 140 | this.zoom.on("zoom", null); // This is the way to unbind events in d3 141 | } 142 | 143 | /** 144 | * Handles a domain change in the density chart. 145 | * 146 | * @param {Array} brushSelection 147 | * @private 148 | */ 149 | _onDensityChartDomainChanged = (brushSelection) => { 150 | const brushSelectionMin = brushSelection[0]; 151 | const brushSelectionMax = brushSelection[1]; 152 | 153 | // converts for a time-scale 154 | const brushedDomain = brushSelection.map(this.densityChartXScale.invert); 155 | 156 | d3Select(this.histogramChartRef.current).call(this.zoom.transform, d3ZoomIdentity 157 | .scale(this.state.densityChartDimensions.width / (brushSelectionMax - brushSelectionMin)) 158 | .translate(-brushSelection[0], 0)); 159 | 160 | this._updateBrushedDomainAndReRenderTheHistogramPlot(brushedDomain); 161 | }; 162 | 163 | /** 164 | * Handles resizing and zoom events. This functions triggers whenever a zoom or brush 165 | * action is performed on the histogram. 166 | * Sets new domain for histogram bar chart 167 | * Will call _updateHistogramScales after to set scales and then redraw plots. 168 | * 169 | * @private 170 | */ 171 | _onResizeZoom = () => { 172 | // This is an early return in order to avoid processing brush event 173 | if (d3Event.sourceEvent && d3Event.sourceEvent.target.name === "brush") { 174 | return; 175 | } 176 | 177 | const { transform } = d3Event; 178 | 179 | // We apply the zoom transformation to rescale densityChartScale. 180 | // Then we get the new domain, this is the new domain for the histogram x scale 181 | const brushedDomain = transform.rescaleX(this.densityChartXScale).domain(); 182 | 183 | // if the max value of the brushed domain is greater than the max value of the overallTimeDomain imposed 184 | // by the data we should avoid the scrolling in that area because it doesn't make any sense. 185 | if (brushedDomain[1] >= this.state.overallTimeDomain.max) { 186 | // Here we get the delta of the brush domain 187 | const brushDomainInterval = brushedDomain[1].getTime() - brushedDomain[0].getTime(); 188 | 189 | // And apply that in this min value of the brush domain in order to keep that interval 190 | brushedDomain[0] = timeMillisecond(this.state.overallTimeDomain.max - brushDomainInterval); 191 | brushedDomain[1] = timeMillisecond(this.state.overallTimeDomain.max); 192 | } 193 | 194 | this._updateBrushedDomainAndReRenderTheHistogramPlot(brushedDomain); 195 | }; 196 | 197 | /** 198 | * Handles the mouse entering an histogram bar. 199 | * 200 | * @param {Object} evt 201 | * @private 202 | */ 203 | _onMouseEnterHistogramBar = (evt) => { 204 | // In order to access into the information in the `SyntheticEvent` inside of the setState callback it inspect 205 | // necessary store the currentTarget value in a constant. https://reactjs.org/docs/events.html#event-pooling 206 | const currentTarget = evt.currentTarget; 207 | const index = +currentTarget.getAttribute("dataindex"); // The `+` converts "1" to 1 208 | 209 | this.setState((state) => { 210 | const bar = state.timeHistogramBars[index]; 211 | 212 | return { 213 | showHistogramBarTooltip: true, 214 | currentBar: bar, 215 | selectedBarPosition: currentTarget.getBoundingClientRect() 216 | }; 217 | }); 218 | }; 219 | 220 | /** 221 | * Handles the mouse leaving an histogram bar. 222 | * @private 223 | */ 224 | _onMouseLeaveHistogramBar = () => { 225 | this.setState({ 226 | showHistogramBarTooltip: false 227 | }); 228 | }; 229 | 230 | /** 231 | * Creates the density chart x axis scale and the histogram zoom. 232 | * @private 233 | */ 234 | _createScaleAndZoom() { 235 | const { min, max } = this.state.overallTimeDomain; 236 | const { width, height } = this.state.histogramChartDimensions; 237 | 238 | this.densityChartXScale = scaleTime() 239 | .domain([ min, max ]) 240 | .range([ 0, this.state.densityChartDimensions.width ]); 241 | 242 | // max zoom is the ratio of the initial domain extent to the minimum unit we want to zoom to. 243 | const MAX_ZOOM_VALUE = (max - min) / this.props.minZoomUnit; 244 | 245 | this.zoom = d3Zoom() 246 | .scaleExtent([MIN_ZOOM_VALUE, MAX_ZOOM_VALUE]) 247 | .translateExtent([ 248 | [0, 0], 249 | [width, height] 250 | ]) 251 | .extent([ 252 | [0, 0], 253 | [width, height] 254 | ]) 255 | .on("zoom", this._onResizeZoom); 256 | } 257 | 258 | /** 259 | * Sets up the zoom and the chart scales. 260 | * @private 261 | */ 262 | _setUpZoomAndChartScales() { 263 | d3Select(this.histogramChartRef.current).call(this.zoom); 264 | 265 | this._updateHistogramChartScales(); 266 | } 267 | 268 | /** 269 | * Check if brushed domain changed and if so, updates the component state 270 | * and calls prop function for interval change. 271 | * 272 | * @param {Array} brushedDomain 273 | * @private 274 | */ 275 | _updateBrushedDomainAndReRenderTheHistogramPlot(brushedDomain) { 276 | const brushedDomainMin = dateToTimestamp(brushedDomain[0]); 277 | const brushedDomainMax = dateToTimestamp(brushedDomain[1]); 278 | 279 | this.setState({ 280 | brushTimeDomain: { 281 | min: brushedDomainMin, 282 | max: brushedDomainMax 283 | }, 284 | showHistogramBarTooltip: false 285 | }, this._updateHistogramChartScales); 286 | 287 | const fullDomain = this.densityChartXScale.domain(); 288 | 289 | const isFullDomain = fullDomain[0].getTime() === brushedDomainMin 290 | && fullDomain[1].getTime() === brushedDomainMax; 291 | 292 | this.props.onIntervalChange([ brushedDomainMin, brushedDomainMax ], isFullDomain); 293 | } 294 | 295 | /** 296 | * Defines X and Y scale for histogram bar chart and creates bins for histogram 297 | * Checks if plot is timebased and sets X axis accordingly. 298 | * 299 | * @private 300 | */ 301 | _updateHistogramChartScales() { 302 | this.histogramChartXScale = scaleTime(); 303 | 304 | this.histogramChartXScale 305 | .domain([ this.state.brushTimeDomain.min, this.state.brushTimeDomain.max ]) 306 | .range([ 307 | this.state.histogramChartDimensions.width * X_AXIS_PADDING, 308 | this.state.histogramChartDimensions.width * (1 - X_AXIS_PADDING) 309 | ]) 310 | .nice(this.props.defaultBarCount); 311 | 312 | // Calculating the time histogram bins 313 | const timeHistogramBars = histogramBinCalculator({ 314 | xAccessor: this.props.xAccessor, 315 | yAccessor: this.props.yAccessor, 316 | histogramChartXScale: this.histogramChartXScale, 317 | defaultBarCount: this.props.defaultBarCount, 318 | data: this.props.data 319 | }); 320 | 321 | let maxY; 322 | 323 | if (this.props.data.length === 0) { 324 | maxY = 1; 325 | } else { 326 | maxY = d3Max(timeHistogramBars, (bin) => bin.yValue); 327 | } 328 | 329 | // Setting the histogram y-axis domain scale 330 | this.histogramChartYScale = scaleLinear() 331 | .domain([0, maxY]) 332 | .range([this.state.histogramChartDimensions.heightForBars, 0]); 333 | 334 | this.setState({ 335 | timeHistogramBars 336 | }, () => { 337 | this._renderHistogramAxis(); 338 | }); 339 | } 340 | 341 | /** 342 | * Renders histogram bars from array of histogram bins. 343 | * 344 | * @param {Array} timeHistogramBars 345 | * @returns {Array.|null} 346 | * @private 347 | */ 348 | _renderHistogramBars(timeHistogramBars) { 349 | return timeHistogramBars.map((bar, index) => { 350 | const { width, height, x, y } = calculatePositionAndDimensions({ 351 | xScale: this.histogramChartXScale, 352 | yScale: this.histogramChartYScale, 353 | heightForBars: this.state.histogramChartDimensions.heightForBars, 354 | margin: this.props.barOptions.margin, 355 | bar 356 | }); 357 | 358 | // Do not render the histogram bars when they have negative values for the 359 | // width and height 360 | if (height <= 0 || width <= 0) { 361 | return null; 362 | } 363 | 364 | // If there is no tooltip we don't need the mouse enter and leave handlers 365 | const hasTooltipBarCustomatizations = typeof this.props.tooltipBarCustomization === "function"; 366 | 367 | return ( 368 | 378 | ); 379 | }); 380 | } 381 | 382 | /** 383 | * This function will render the X and Y axis. This means it will set their scales 384 | * as well as how many ticks, their respective positions and how their text should 385 | * be formatted. 386 | * 387 | * @private 388 | */ 389 | _renderHistogramAxis() { 390 | const histogramXAxisScale = scaleTime() 391 | .domain([ 392 | this.histogramChartXScale.invert(0), 393 | this.histogramChartXScale.invert(this.state.histogramChartDimensions.width) 394 | ]) 395 | .range([0, this.state.histogramChartDimensions.width]); 396 | 397 | // Setting the x-axis histogram representation. 398 | const histogramXAxis = d3AxisBottom(histogramXAxisScale) 399 | .tickValues(this.histogramChartXScale.ticks(this.props.defaultBarCount / BARS_TICK_RATIO)) 400 | .tickFormat(this.props.xAxisFormatter); 401 | 402 | d3Select(this.histogramXAxisRef.current) 403 | .call(histogramXAxis); 404 | 405 | const histogramYAxis = d3AxisLeft(this.histogramChartYScale) 406 | .ticks(this.props.yAxisTicks) 407 | .tickSize(0) 408 | .tickFormat(this.props.yAxisFormatter); 409 | 410 | d3Select(this.histogramYAxisRef.current) 411 | .call(histogramYAxis); 412 | } 413 | 414 | /** 415 | * Renders tooltip corresponding to an histogram bin. 416 | * Receives an object with all the data of the bin and gets corresponding 417 | * bar element. Then calls the prop function histogramBarTooltipFormatter 418 | * to get the tooltip element to be rendered. Updates states with this element 419 | * and toggles showHistogramBarTooltip. 420 | * 421 | * @param {Object} currentBar 422 | * @private 423 | */ 424 | 425 | _renderBarTooltip(currentBar) { 426 | const { tooltipBarCustomization } = this.props; 427 | const { selectedBarPosition } = this.state; 428 | 429 | return ( 430 | 435 | ); 436 | } 437 | 438 | /** 439 | * Renders the histogram chart (i.e., the bars and the axis). 440 | * @returns {React.Element} 441 | */ 442 | _renderHistogramChart() { 443 | // Histogram classNames 444 | const histogramXAxisClassname = "fdz-js-graph-histogram-axis-x fdz-css-graph-histogram-axis-x"; 445 | const histogramYAxisClassname = "fdz-js-graph-histogram-axis-y fdz-css-graph-histogram-axis-y"; 446 | 447 | const { histogramChartDimensions, timeHistogramBars } = this.state; 448 | const { spaceBetweenCharts, size } = this.props; 449 | 450 | return ( 451 | 460 | {/* Rendering the histogram bars */} 461 | 462 | {this._renderHistogramBars(timeHistogramBars)} 463 | 464 | 465 | {/* Rendering the histogram x-axis */} 466 | 471 | 472 | {/* Rendering the histogram y-axis */} 473 | 478 | 479 | ); 480 | } 481 | 482 | /** 483 | * Renders the density chart. 484 | * @returns {React.Element} 485 | */ 486 | _renderDensityChart() { 487 | const { frameStep, frameDelay, xAccessor, spaceBetweenCharts, brushDensityChartColor, 488 | brushDensityChartFadedColor, renderPlayButton, data } = this.props; 489 | 490 | return ( 491 | 0} 506 | data={data} 507 | onDomainChanged={this._onDensityChartDomainChanged} 508 | /> 509 | ); 510 | } 511 | 512 | render() { 513 | return ( 514 |
515 | {this.state.showHistogramBarTooltip ? this._renderBarTooltip(this.state.currentBar) : null } 516 | {this._renderHistogramChart()} 517 | {this._renderDensityChart()} 518 |
519 | ); 520 | } 521 | } 522 | 523 | export default withSize()(Histogram); 524 | --------------------------------------------------------------------------------