├── .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 | 18 | 21 | 27 | 31 | 35 | 36 | 37 | 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 | StorybookNo PreviewSorry, 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 | 106 | 109 | 115 | 119 | 123 | 124 | 125 | 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 | 167 | 168 | {iconElement} 169 | 170 | 171 | >); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Brushable Histogram 2 | 3 | [](https://travis-ci.com/feedzai/brushable-histogram) 4 | [](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 |  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 | 217 | 220 | 226 | 230 | 234 | 235 | 236 | 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 | --------------------------------------------------------------------------------
Sorry, but you either have no stories or none are selected somehow.
If the problem persists, check the browser console, or the terminal you've run Storybook from.