Repository: jakezatecky/d3-funnel
Branch: master
Commit: 1f6bf926b902
Files: 36
Total size: 132.9 KB
Directory structure:
gitextract_sgnj8eqe/
├── .babelrc
├── .browserslistrc
├── .editorconfig
├── .gitattributes
├── .github/
│ ├── dependabot.yml
│ └── workflows/
│ └── main.yml
├── .gitignore
├── .mocharc.json
├── .stylelintrc.yml
├── CHANGELOG.md
├── LICENSE.txt
├── README.md
├── eslint.config.js
├── examples/
│ └── src/
│ ├── index.html
│ ├── js/
│ │ └── index.js
│ └── scss/
│ ├── _cayman.scss
│ └── style.scss
├── gh-deploy.sh
├── index.d.ts
├── package.json
├── src/
│ ├── d3-funnel/
│ │ ├── Colorizer.js
│ │ ├── D3Funnel.js
│ │ ├── Formatter.js
│ │ ├── Navigator.js
│ │ └── Utils.js
│ └── index.js
├── test/
│ ├── d3-funnel/
│ │ ├── Colorizer.js
│ │ ├── D3Funnel.js
│ │ ├── Navigator.js
│ │ └── Utils.js
│ ├── index.html
│ ├── index.js
│ └── test.js
├── webpack.config.examples.js
├── webpack.config.js
└── webpack.config.test.js
================================================
FILE CONTENTS
================================================
================================================
FILE: .babelrc
================================================
{
"presets": [
["@babel/preset-env", { "modules": false }]
]
}
================================================
FILE: .browserslistrc
================================================
defaults
================================================
FILE: .editorconfig
================================================
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[**.js]
indent_size = 4
================================================
FILE: .gitattributes
================================================
d3* linguist-vendored=false
examples/* linguist-vendored=true
test/* linguist-vendored=true
================================================
FILE: .github/dependabot.yml
================================================
version: 2
updates:
- package-ecosystem: npm
directory: "/"
schedule:
interval: daily
time: "10:00"
open-pull-requests-limit: 10
ignore:
- dependency-name: eslint-config-takiyon
versions:
- 1.0.0
================================================
FILE: .github/workflows/main.yml
================================================
name: Build
on: push
jobs:
build-and-test:
runs-on: ubuntu-latest
strategy:
matrix:
node: [20, 22, 24]
steps:
-
uses: actions/checkout@v3
-
name: Use Node.js ${{ matrix.node }}
uses: actions/setup-node@v3
with:
node-version: ${{ matrix.node }}
-
name: Install dependencies
run: yarn install
-
name: Install Playwright
run: npx playwright install --with-deps firefox
-
name: Run tests
run: npm run test && npm run build
================================================
FILE: .gitignore
================================================
/dist
/examples/dist
/node_modules
/test/compiled
package-lock.json
yarn.lock
================================================
FILE: .mocharc.json
================================================
{
"require": [
"@babel/register",
"global-jsdom/register"
],
"reporter": "spec"
}
================================================
FILE: .stylelintrc.yml
================================================
extends:
- stylelint-config-takiyon
================================================
FILE: CHANGELOG.md
================================================
## [v2.1.3](https://github.com/jakezatecky/d3-funnel/compare/v2.1.2...v2.1.3) (2025-09-11)
### Bug Fixes
* Fix issue with default export/import in ESM modules (#267)
## [v2.1.2](https://github.com/jakezatecky/d3-funnel/compare/v2.1.0...v2.1.2) (2025-09-08)
* Release with modern optimizations
## [v2.1.0](https://github.com/jakezatecky/d3-funnel/compare/v2.0.0...v2.1.0) (2022-02-25)
### Other
* [#156]: Add TypeScript index file
## [v2.0.0](https://github.com/jakezatecky/d3-funnel/compare/v1.2.2...v2.0.0) (2021-06-04)
### Bug Fixes
* [#138]: Fix an issue with tooltip alignment in newer versions of Chrome
### Dependencies
* **Breaking**: Upgrade to using D3 v6 (changes `events.click.block(d)` to `events.click.block(event, d)`)
## [v1.2.2](https://github.com/jakezatecky/d3-funnel/compare/v1.2.1...v1.2.2) (2019-01-26)
### Performance
* [#97]: Significantly reduce package size to around 27% of its original size
## [v1.2.1](https://github.com/jakezatecky/d3-funnel/compare/v1.2.0...v1.2.1) (2018-10-13)
### Build Process
* [#93]: Fix issue where `dist/d3-funnel.js` was being minified alongside `dist/d3-funnel.min.js`
## [v1.2.0](https://github.com/jakezatecky/d3-funnel/compare/v1.1.1...v1.2.0) (2018-06-25)
### Dependencies
* [#87]: Add official support for D3 v5 (while continuing support for D3 v4)
### Bug Fixes
* [#86]: Fix issue where heights were being calculated incorrectly when the sum of values was zero
## [v1.1.1](https://github.com/jakezatecky/d3-funnel/compare/v1.1.0...v1.1.1) (2017-07-31)
This is a patch for the npm release, which was shipped without the updated `/dist` directory.
## [v1.1.0](https://github.com/jakezatecky/d3-funnel/compare/v1.0.1...v1.1.0) (2017-07-31)
Release **v1.1.0** adds a variety of new functionality to the funnel, and introduces a new data structure that allows for more flexibility on a row level than previously capable:
``` javascript
funnel.draw([{
label: 'Prospects',
value: 5000,
backgroundColor: '#d33',
}]);
```
The old structure of an array-of-arrays has been deprecated and will be removed in the **v2.0** release. Please update to the newest data structure as soon as possible. Refer to the README for the list of available options, which includes all of the capabilities previously held in the data array.
### Deprecations
* [#73]: The old array-of-arrays data structure has been deprecated in favor of a data objects
### New Features
* [#45]: Add support for tooltips via `tooltip.enabled` and `tooltip.format`
* [#71]: Add `hideLabel` option to the data object
* [#74]: Add `label.enabled` chart option
* [#79]: Add support for `HTMLElement` in the D3Funnel constructor
### Bug Fixes
* [#77]: Fix an issue where containers with zero width and/or height would not inherit from the default dimensions
## [v1.0.1](https://github.com/jakezatecky/d3-funnel/compare/v1.0.0...v1.0.1) (2017-01-30)
### Bug Fixes
* [#67]: Add missing `cursor: pointer` style to blocks when clickable
* [#70]: Fix NaN and Infinity values in block paths when height is zero and `dynamicHeight: true`
## [v1.0.0](https://github.com/jakezatecky/d3-funnel/compare/v0.8.0...v1.0.0) (2016-08-02)
This release breaks major backwards compatibility by upgrading D3 3.x to
D3 4.x. Refer to D3's [changes documentation](d3-changes) for more info.
### Behavior Changes
* [#62]: Upgrade D3 3.x to 4.x
[d3-changes]: https://github.com/d3/d3/blob/master/CHANGES.md
## [v0.8.0](https://github.com/jakezatecky/d3-funnel/compare/v0.7.7...v0.8.0) (2016-07-21)
### New Features
* [#19]: Add support for percentages in `chart.width` and `chart.height` (e.g. `'75%'`)
* [#38]: Split line break characters found in `label.format` into multiple lines
### Bug Fixes
* [#49]: Fix issue where gradient definitions could conflict with existing definitions
## [v0.7.7](https://github.com/jakezatecky/d3-funnel/compare/v0.7.6...v0.7.7) (2016-07-15)
### New Features
* [#50]: Add `block.barOverlay` option to display bar charts proportional to block value
* [#52]: Add `chart.totalCount` option to override total counts used in ratio calculations
### Other
* Simplify and clean up examples
## [v0.7.6](https://github.com/jakezatecky/d3-funnel/compare/v0.7.5...v0.7.6) (2016-07-12)
### New Features
* [#53]: Add `label.fontSize` option
* [#57]: Add `block.dynamicSlope` option to make the funnel width proportional to its value
### Bug Fixes
* [#59]: Fix issue where formatted array values were not being passed to the label formatter
## [v0.7.5](https://github.com/jakezatecky/d3-funnel/compare/v0.7.4...v0.7.5) (2015-12-19)
### New Features
* [#44]: Pass DOM node to event data
## [v0.7.4](https://github.com/jakezatecky/d3-funnel/compare/v0.7.3...v0.7.4) (2015-12-11)
### Build Changes
* [#42]: Use ES6 imports and exports in source files
* [#43]: Require D3.js for CommonJS environments
## [v0.7.3](https://github.com/jakezatecky/d3-funnel/compare/v0.7.2...v0.7.3) (skipped)
D3Funnel v0.7.3 is an NPM-only hotfix that adds in missing compiled files.
## [v0.7.2](https://github.com/jakezatecky/d3-funnel/compare/v0.7.1...v0.7.2) (2015-11-18)
### Bug Fixes
* [#41]: Fix issue where `events.click.block` would error on `null`
## [v0.7.1](https://github.com/jakezatecky/d3-funnel/compare/v0.7.0...v0.7.1) (2015-10-28)
### Behavior Changes
* Errors thrown on data validation are now more descriptive and context-aware
### Bug Fixes
* [#35]: Fix issue where gradient background would not persist after mouse out
* [#36]: Fix issue where non-SVG entities were not being removed from container
## [v0.7.0](https://github.com/jakezatecky/d3-funnel/compare/v0.6.13...v0.7.0) (2015-10-04)
D3Funnel v0.7 is a **backwards-incompatible** release that resolves some
outstanding bugs, standardizes several option names and formats, and introduces
a few new features.
No new features will be added to the v0.6 series, but minor patches will be
available for a few months.
### Behavior Changes
* [#29]: Dynamic block heights are no longer determined by their weighted area, but by their weighted height
* Heights determined by weighted area: http://jsfiddle.net/zq4L82kv/2/ (legacy v0.6.x)
* Heights determined by weighted height: http://jsfiddle.net/bawv6m0j/3/ (v0.7+)
### New Features
* [#9]: Block can now have their color scale specified in addition to data points
* [#34]: Default options are now statically available and overridable
### Bug Fixes
* [#25]: Fix issues with `isInverted` and `dynamicArea` producing odd pyramids
* [#32]: Fix issue where pinched blocks were not having the same width as `bottomWidth`
### Upgrading from v0.6.x
Several options have been renamed for standardization. Please refer to the table
below for the new equivalent option:
| Old option | New option | Notes |
| -------------- | --------------------- | --------------- |
| `animation` | `chart.animate` | |
| `bottomPinch` | `chart.bottomPinch` | |
| `bottomWidth` | `chart.bottomWidth` | |
| `curveHeight` | `chart.curve.height` | |
| `dynamicArea` | `block.dynamicHeight` | See change #29. |
| `fillType` | `block.fill.type` | |
| `height` | `chart.height` | |
| `hoverEffects` | `block.hightlight` | |
| `isCurved` | `chart.curve.enabled` | |
| `isInverted` | `chart.inverted` | |
| `onItemClick` | `events.click.block` | |
| `minHeight` | `block.minHeight` | |
| `width` | `chart.width` | |
In addition, please refer to change #29.
## [v0.6.13](https://github.com/jakezatecky/d3-funnel/compare/v0.6.12...v0.6.13) (2015-10-02)
### Bug Fixes
* [#33]: Fix issue where `package.json` pointed to the incorrect main file
## [v0.6.12](https://github.com/jakezatecky/d3-funnel/compare/0.6.11...v0.6.12) (2015-09-25)
### New Features
* [#16]: Add support for formatted labels
### Bug Fixes
* [#26]: Fix issues with closed range intervals in `bottomWidth`
* [#28]: Fix issue where short hex colors did not translate properly in color manipulations
================================================
FILE: LICENSE.txt
================================================
The MIT License (MIT)
Copyright (c) 2017 Jake Zatecky
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
================================================
FILE: README.md
================================================
# d3-funnel
[](https://www.npmjs.com/package/d3-funnel)
[](https://github.com/jakezatecky/d3-funnel/actions/workflows/main.yml)
[](https://raw.githubusercontent.com/jakezatecky/d3-funnel/master/LICENSE.txt)
**d3-funnel** is an extensible, open-source JavaScript library for rendering
funnel charts using the [D3.js][d3] library.
d3-funnel is focused on providing practical and visually appealing funnels
through a variety of customization options. Check out the [examples page][examples]
to get a showcasing of the several possible options.
# Installation
Install this library via npm, yarn, pnpm, or your preferred package manager:
```
npm install d3-funnel --save
```
You can then load this library into your app using `import`:
``` javascript
import D3Funnel from 'd3-funnel';
```
# Usage
To use this library, you must create a container element and instantiate a new
funnel chart. By default, the chart will assume the width and height of the
parent container:
``` html
```
## Options
| Option | Description | Type | Default |
| ---------------------- | ------------------------------------------------------------------------- | -------- | ----------------------- |
| `chart.width` | The width of the chart in pixels or a percentage. | mixed | Container's width |
| `chart.height` | The height of the chart in pixels or a percentage. | mixed | Container's height |
| `chart.bottomWidth` | The percent of total width the bottom should be. | number | `1 / 3` |
| `chart.bottomPinch` | How many blocks to pinch on the bottom to create a funnel "neck". | number | `0` |
| `chart.inverted` | Whether the funnel direction is inverted (like a pyramid). | bool | `false` |
| `chart.animate` | The load animation speed in milliseconds. | number | `0` (disabled) |
| `chart.curve.enabled` | Whether the funnel is curved. | bool | `false` |
| `chart.curve.height` | The curvature amount. | number | `20` |
| `chart.totalCount` | Override the total count used in ratio calculations. | number | `null` |
| `block.dynamicHeight` | Whether the block heights are proportional to their weight. | bool | `false` |
| `block.dynamicSlope` | Whether the block widths are proportional to their value decrease. | bool | `false` |
| `block.barOverlay` | Whether the blocks have bar chart overlays proportional to its weight. | bool | `false` |
| `block.fill.scale` | The background color scale as an array or function. | mixed | `d3.schemeCategory10` |
| `block.fill.type` | Either `'solid'` or `'gradient'`. | string | `'solid'` |
| `block.minHeight` | The minimum pixel height of a block. | number | `0` |
| `block.highlight` | Whether the blocks are highlighted on hover. | bool | `false` |
| `label.enabled` | Whether the block labels should be displayed. | bool | `true` |
| `label.fontFamily` | Any valid font family for the labels. | string | `null` |
| `label.fontSize` | Any valid font size for the labels. | string | `'14px'` |
| `label.fill` | Any valid hex color for the label color. | string | `'#fff'` |
| `label.format` | Either `function(label, value)` or a format string. See below. | mixed | `'{l}: {f}'` |
| `tooltip.enabled` | Whether tooltips should be enabled on hover. | bool | `false` |
| `tooltip.format` | Either `function(label, value)` or a format string. See below. | mixed | `'{l}: {f}'` |
| `events.click.block` | Callback `function(data)` for when a block is clicked. | function | `null` |
### Label/Tooltip Format
The option `label.format` can either be a function or a string. The following
keys will be substituted by the string formatter:
| Key | Description |
| ------- | ---------------------------- |
| `'{l}'` | The block's supplied label. |
| `'{v}'` | The block's raw value. |
| `'{f}'` | The block's formatted value. |
### Event Data
Block-based events are passed a `data` object containing the following elements:
| Key | Type | Description |
| --------------- | ------ | ------------------------------------- |
| index | number | The index of the block. |
| node | object | The DOM node of the block. |
| value | number | The numerical value. |
| fill | string | The background color. |
| label.raw | string | The unformatted label. |
| label.formatted | string | The result of `options.label.format`. |
| label.color | string | The label color. |
Example:
``` javascript
{
index: 0,
node: { ... },
value: 150,
fill: '#c33',
label: {
raw: 'Visitors',
formatted: 'Visitors: 150',
color: '#fff',
},
},
```
### Overriding Defaults
You may wish to override the default chart options. For example, you may wish
for every funnel to have proportional heights. To do this, simply modify the
`D3Funnel.defaults` property:
``` javascript
D3Funnel.defaults.block.dynamicHeight = true;
```
Should you wish to override multiple properties at a time, you may consider
using [lodash's][lodash-merge] `_.merge` or [jQuery's][jquery-extend] `$.extend`:
``` javascript
D3Funnel.defaults = _.merge(D3Funnel.defaults, {
block: {
dynamicHeight: true,
fill: {
type: 'gradient',
},
},
label: {
format: '{l}: ${f}',
},
});
```
## Advanced Data
In the examples above, both `label` and `value` were just to describe a block
within the funnel. A complete listing of the available options is included
below:
| Option | Type | Description | Example |
| --------------- | ------ | --------------------------------------------------------------- | ------------- |
| label | mixed | **Required.** The label to associate with the block. | `'Students'` |
| value | number | **Required.** The value (or count) to associate with the block. | `500` |
| backgroundColor | string | A row-level override for `block.fill.scale`. Hex only. | `'#008080'` |
| formattedValue | mixed | A row-level override for `label.format`. | `'USD: $150'` |
| hideLabel | bool | Whether to hide the formatted label for this block. | `true` |
| labelColor | string | A row-level override for `label.fill`. Hex only. | `'#333'` |
## API
Additional methods beyond `draw()` are accessible after instantiating the chart:
| Method | Description |
| ----------- | ----------------------------------------------- |
| `destroy()` | Removes the funnel and its events from the DOM. |
# License
MIT license.
[d3]: http://d3js.org/
[examples]: http://jakezatecky.github.io/d3-funnel/
[jQuery-extend]: https://api.jquery.com/jquery.extend/
[lodash-merge]: https://lodash.com/docs#merge
================================================
FILE: eslint.config.js
================================================
import takiyonConfig from 'eslint-config-takiyon';
import globals from 'globals';
import webpackConfig from './webpack.config.test.js';
// Resolve issue with HTML Webpack Bundler causing circular references
// https://github.com/webdiscus/html-bundler-webpack-plugin/issues/186
delete webpackConfig.plugins;
export default [
...takiyonConfig,
{
files: [
'**/*.{js,jsx}',
],
ignores: ['./node_modules/**/*'],
settings: {
// Account for webpack.resolve.module imports
'import/resolver': {
webpack: {
config: webpackConfig,
},
},
},
},
{
// Front-end files
files: [
'examples/**/*.js',
'src/**/*.js',
],
languageOptions: {
globals: globals.browser,
},
},
{
// Test files
files: ['test/**/*.js'],
languageOptions: {
globals: {
...globals.browser,
...globals.mocha,
},
},
},
{
// Build files
files: ['*.js'],
languageOptions: {
globals: globals.node,
},
},
];
================================================
FILE: examples/src/index.html
================================================
d3-funnel
d3-funnel
A JavaScript library for rendering funnel charts using the D3.js framework.
d3-funnel is an extensible, open-source JavaScript library for rendering
funnel charts using the D3.js library.
d3-funnel is focused on providing practical and visually appealing funnels through a variety
of customization options. Check out the example below to get a showcasing of the several
possible options.
================================================
FILE: examples/src/js/index.js
================================================
import { merge } from 'lodash';
import D3Funnel from 'd3-funnel';
const settings = {
curved: {
chart: {
curve: {
enabled: true,
},
},
},
pinched: {
chart: {
bottomPinch: 1,
},
},
gradient: {
block: {
fill: {
type: 'gradient',
},
},
},
inverted: {
chart: {
inverted: true,
},
},
hover: {
block: {
highlight: true,
},
},
tooltip: {
tooltip: {
enabled: true,
},
},
click: {
events: {
click: {
block(event, d) {
// eslint-disable-next-line no-alert
alert(d.label.raw);
},
},
},
},
dynamicHeight: {
block: {
dynamicHeight: true,
},
},
barOverlay: {
block: {
barOverlay: true,
},
},
animation: {
chart: {
animate: 200,
},
},
label: {
label: {
fontFamily: '"Reem Kufi", sans-serif',
fontSize: '16px',
},
},
};
const chart = new D3Funnel('#funnel');
const checkboxes = [...document.querySelectorAll('input')];
const color = document.querySelector('[value="color"]');
function onChange() {
let data = !color.checked ?
[
{ label: 'Applicants', value: 12000 },
{ label: 'Pre-screened', value: 4000 },
{ label: 'Interviewed', value: 2500 },
{ label: 'Hired', value: 1500 },
] :
[
{ label: 'Teal', value: 12000, backgroundColor: '#008080' },
{ label: 'Byzantium', value: 4000, backgroundColor: '#702963' },
{ label: 'Persimmon', value: 2500, backgroundColor: '#ff634d' },
{ label: 'Azure', value: 1500, backgroundColor: '#007fff' },
];
let options = {
chart: {
bottomWidth: 3 / 8,
},
block: {
minHeight: 25,
},
label: {
format: '{l}\n{f}',
},
};
checkboxes.forEach((checkbox) => {
if (checkbox.checked) {
options = merge(options, settings[checkbox.value]);
}
});
// Reverse data for inversion
if (options.chart.inverted) {
options.chart.bottomWidth = 1 / 3;
data = data.reverse();
}
chart.draw(data, options);
}
// Bind event listeners
checkboxes.forEach((checkbox) => {
checkbox.addEventListener('change', onChange);
});
// Trigger change event for initial render
checkboxes[0].dispatchEvent(new CustomEvent('change'));
================================================
FILE: examples/src/scss/_cayman.scss
================================================
// Breakpoints
$large-breakpoint: 64em;
$medium-breakpoint: 42em;
// Headers
$header-heading-color: #fff !default;
$header-bg-color: #159957 !default;
$header-bg-color-secondary: #155799 !default;
// Text
$font-family: // stylelint-disable-line scss/dollar-variable-colon-space-after
"Open Sans", "Helvetica Neue", "Helvetica", "Arial", sans-serif !default;
$section-headings-color: #159957 !default;
$body-text-color: #606c71 !default;
$body-link-color: #1e6bb8 !default;
$blockquote-text-color: #819198 !default;
// Code
$font-family-code: // stylelint-disable-line scss/dollar-variable-colon-space-after
"Consolas", "Liberation Mono", "Menlo", "Courier", monospace !default;
$code-bg-color: #f3f6fa !default;
$code-text-color: #567482 !default;
// Borders
$border-color: #dce6f0 !default;
$table-border-color: #e9ebec !default;
$hr-border-color: #eff0f1 !default;
@mixin large {
@media screen and (min-width: #{$large-breakpoint}) {
@content;
}
}
@mixin medium {
@media screen and (min-width: #{$medium-breakpoint}) and (max-width: #{$large-breakpoint}) {
@content;
}
}
@mixin small {
@media screen and (max-width: #{$medium-breakpoint}) {
@content;
}
}
* {
box-sizing: border-box;
}
body {
padding: 0;
margin: 0;
font-family: $font-family;
font-size: 16px;
line-height: 1.5;
color: $body-text-color;
}
a {
color: $body-link-color;
text-decoration: none;
&:hover {
text-decoration: underline;
}
}
.btn {
display: inline-block;
margin-bottom: 1rem;
color: rgba(255, 255, 255, 0.7);
background-color: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.2);
border-style: solid;
border-width: 1px;
border-radius: 0.3rem;
transition:
color 0.2s,
background-color 0.2s,
border-color 0.2s;
@include large {
padding: 0.75rem 1rem;
+ .btn {
margin-left: 1rem;
}
}
@include medium {
padding: 0.6rem 0.9rem;
font-size: 0.9rem;
+ .btn {
margin-left: 1rem;
}
}
@include small {
display: block;
width: 100%;
padding: 0.75rem;
font-size: 0.9rem;
+ .btn {
margin-top: 1rem;
margin-left: 0;
}
}
&:hover {
color: rgba(255, 255, 255, 0.8);
text-decoration: none;
background-color: rgba(255, 255, 255, 0.2);
border-color: rgba(255, 255, 255, 0.3);
}
}
.page-header {
color: $header-heading-color;
text-align: center;
background-color: $header-bg-color;
background-image: linear-gradient(
120deg,
$header-bg-color-secondary,
$header-bg-color
);
@include large {
padding: 3rem 4rem;
}
@include medium {
padding: 3rem 4rem;
}
@include small {
padding: 2rem 1rem;
}
}
.project-name {
margin-top: 0;
margin-bottom: 0.1rem;
@include large {
font-size: 3.25rem;
}
@include medium {
font-size: 2.25rem;
}
@include small {
font-size: 1.75rem;
}
}
.project-tagline {
margin-bottom: 2rem;
font-weight: normal;
opacity: 0.7;
@include large {
font-size: 1.25rem;
}
@include medium {
font-size: 1.15rem;
}
@include small {
font-size: 1rem;
}
}
.main-content {
word-wrap: break-word;
@include large {
max-width: 64rem;
padding: 2rem 6rem;
margin: 0 auto;
font-size: 1.1rem;
}
@include medium {
padding: 2rem 4rem;
font-size: 1.1rem;
}
@include small {
padding: 2rem 1rem;
font-size: 1rem;
}
:first-child {
margin-top: 0;
}
img {
max-width: 100%;
}
h1,
h2,
h3,
h4,
h5,
h6 {
margin-top: 2rem;
margin-bottom: 1rem;
font-weight: normal;
color: $section-headings-color;
}
p {
margin-bottom: 1em;
}
code {
padding: 2px 4px;
font-family: $font-family-code;
font-size: 0.9rem;
color: $code-text-color;
background-color: $code-bg-color;
border-radius: 0.3rem;
}
pre {
padding: 0.8rem;
margin-top: 0;
margin-bottom: 1rem;
font: 1rem $font-family-code;
color: $code-text-color;
word-wrap: normal;
background-color: $code-bg-color;
border: solid 1px $border-color;
border-radius: 0.3rem;
> code {
padding: 0;
margin: 0;
font-size: 0.9rem;
color: $code-text-color;
word-break: normal;
white-space: pre;
background: transparent;
border: 0;
}
}
.highlight {
margin-bottom: 1rem;
pre {
margin-bottom: 0;
word-break: normal;
}
}
.highlight pre,
pre {
padding: 0.8rem;
overflow: auto;
font-size: 0.9rem;
line-height: 1.45;
border-radius: 0.3rem;
-webkit-overflow-scrolling: touch;
}
pre code,
pre tt {
display: inline;
max-width: initial;
padding: 0;
margin: 0;
overflow: initial;
line-height: inherit;
word-wrap: normal;
background-color: transparent;
border: 0;
&::before,
&::after {
content: normal;
}
}
ul,
ol {
margin-top: 0;
}
blockquote {
padding: 0 1rem;
margin-left: 0;
color: $blockquote-text-color;
border-left: 0.3rem solid $border-color;
> :first-child {
margin-top: 0;
}
> :last-child {
margin-bottom: 0;
}
}
table {
display: block;
width: 100%;
overflow: auto;
word-break: normal;
th {
font-weight: bold;
}
th,
td {
padding: 0.5rem 1rem;
border: 1px solid $table-border-color;
}
}
dl {
padding: 0;
dt {
padding: 0;
margin-top: 1rem;
font-size: 1rem;
font-weight: bold;
}
dd {
padding: 0;
margin-bottom: 1rem;
}
}
hr {
height: 2px;
padding: 0;
margin: 1rem 0;
background-color: $hr-border-color;
border: 0;
}
}
.site-footer {
padding-top: 2rem;
margin-top: 2rem;
border-top: solid 1px $hr-border-color;
@include large {
font-size: 1rem;
}
@include medium {
font-size: 1rem;
}
@include small {
font-size: 0.9rem;
}
}
.site-footer-owner {
display: block;
font-weight: bold;
}
.site-footer-credits {
color: $blockquote-text-color;
}
================================================
FILE: examples/src/scss/style.scss
================================================
@use "cayman" with (
$body-text-color: #444,
$code-text-color: #465e6a,
$header-bg-color: #3498db,
$header-bg-color-secondary: #2c3e50,
$section-headings-color: #2079b5,
$blockquote-text-color: #576266
);
.main-content {
h1,
h2,
h3 {
font-weight: bold;
}
}
.demo {
display: flex;
align-items: center;
justify-content: center;
padding: 10px 30px;
form {
label {
display: flex;
font-weight: normal;
}
input {
margin-right: 0.5rem;
}
}
}
.demo-funnel {
width: 320px;
height: 400px;
margin-right: 50px;
}
================================================
FILE: gh-deploy.sh
================================================
#!/bin/bash
VERSION="$(cat ./package.json | python -c "import sys, json; print(json.load(sys.stdin)['version'])")"
git diff --exit-code
if [[ $? == 0 ]]
then
sed -i '\:/examples/dist:d' ./.gitignore
git add .
git commit -m "Publish v${VERSION} examples"
git push origin `git subtree split --prefix examples/dist master`:gh-pages --force
git reset HEAD~
git checkout .gitignore
else
echo "Need clean working directory to publish"
fi
================================================
FILE: index.d.ts
================================================
declare module 'd3-funnel'
================================================
FILE: package.json
================================================
{
"name": "d3-funnel",
"version": "2.1.3",
"description": "A library for rendering SVG funnel charts using D3.js",
"author": "Jake Zatecky",
"license": "MIT",
"keywords": [
"d3",
"funnel",
"pyramid",
"svg",
"chart"
],
"repository": {
"type": "git",
"url": "https://github.com/jakezatecky/d3-funnel"
},
"bugs": "https://github.com/jakezatecky/d3-funnel/issues",
"type": "module",
"main": "dist/index.cjs.js",
"module": "dist/index.esm.js",
"imports": {
"#js/*": "./src/d3-funnel/*"
},
"exports": {
".": {
"import": "./dist/index.esm.js",
"require": "./dist/index.cjs.js"
},
"./*": "./*"
},
"browser": "dist/d3-funnel.min.js",
"scripts": {
"build": "npm run build:browser && npm run build:esm && npm run build:umd",
"build:browser": "webpack --env=target=browser",
"build:esm": "webpack --env=target=esm",
"build:umd": "webpack --env=target=umd",
"build:test": "webpack --config=webpack.config.test.js",
"examples": "webpack serve --config=webpack.config.examples.js",
"format:style": "prettier --write examples/src/scss/**/*.scss",
"gh-build": "webpack --config=webpack.config.examples.js --mode=production",
"gh-deploy": "npm run gh-build && bash ./gh-deploy.sh",
"lint": "npm run lint:script && npm run lint:style",
"lint:script": "eslint src/**/*.js examples/src/**/*.js test/*.js ./test/d3-funnel/**/*.js *.js",
"lint:style": "stylelint examples/src/scss/**/*.scss",
"prepublishOnly": "npm run release",
"release": "npm run test && npm run build",
"test": "npm run lint && npm run test:script && npm run test:style-format",
"test:script": "npm run build:test && node test/test.js",
"test:style-format": "prettier --check examples/src/scss/**/*.scss"
},
"devDependencies": {
"@babel/core": "^7.0.0",
"@babel/eslint-parser": "^7.13.14",
"@babel/preset-env": "^7.0.0",
"@babel/register": "^7.0.0",
"babel-loader": "^10.0.0",
"browser-sync": "^3.0.3",
"chai": "^6.0.1",
"css-loader": "^7.0.0",
"d3": "^7.0.0",
"eslint": "^10.1.0",
"eslint-config-takiyon": "^4.0.0",
"eslint-import-resolver-webpack": "^0.13.0",
"eslint-plugin-import": "^2.7.0",
"global-jsdom": "^27.0.0",
"globals": "^16.3.0",
"html-bundler-webpack-plugin": "^4.21.1",
"jsdom": "^27.0.0",
"lodash": "^4.17.21",
"mocha": "^11.7.2",
"playwright": "^1.5.2",
"prettier": "^3.1.1",
"process": "^0.11.10",
"sass": "^1.69.5",
"sass-loader": "^16.0.5",
"sinon": "^21.0.0",
"stylelint": "^16.0.2",
"stylelint-config-takiyon": "^6.0.0",
"util": "^0.12.4",
"webpack": "^5.3.2",
"webpack-cli": "^6.0.1",
"webpack-dev-server": "^5.0.0"
},
"dependencies": {
"d3-array": "^3.0.1",
"d3-ease": "^3.0.1",
"d3-scale": "^4.0.0",
"d3-scale-chromatic": "^3.0.0",
"d3-selection": "^3.0.0",
"d3-transition": "^3.0.0",
"nanoid": "^5.0.2"
}
}
================================================
FILE: src/d3-funnel/Colorizer.js
================================================
class Colorizer {
/**
* @return {void}
*/
constructor() {
this.hexExpression = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i;
this.instanceId = null;
this.labelFill = null;
this.scale = null;
}
/**
* @param {string} instanceId
*
* @return {void}
*/
setInstanceId(instanceId) {
this.instanceId = instanceId;
}
/**
* @param {string} fill
*
* @return {void}
*/
setLabelFill(fill) {
this.labelFill = fill;
}
/**
* @param {function|Array} scale
*
* @return {void}
*/
setScale(scale) {
this.scale = scale;
}
/**
* Given a raw data block, return an appropriate color for the block.
*
* @param {string} fill
* @param {Number} index
* @param {string} fillType
*
* @return {Object}
*/
getBlockFill(fill, index, fillType) {
const raw = this.getBlockRawFill(fill, index);
return {
raw,
actual: this.getBlockActualFill(raw, index, fillType),
};
}
/**
* Return the raw hex color for the block.
*
* @param {string} fill
* @param {Number} index
*
* @return {string}
*/
getBlockRawFill(fill, index) {
// Use the block's color, if set and valid
if (this.hexExpression.test(fill)) {
return fill;
}
// Otherwise, attempt to use the array scale
if (Array.isArray(this.scale)) {
return this.scale[index];
}
// Finally, use a functional scale
return this.scale(index);
}
/**
* Return the actual background for the block.
*
* @param {string} raw
* @param {Number} index
* @param {string} fillType
*
* @return {string}
*/
getBlockActualFill(raw, index, fillType) {
if (fillType === 'solid') {
return raw;
}
return `url(#${this.getGradientId(index)})`;
}
/**
* Return the gradient ID for the given index.
*
* @param {Number} index
*
* @return {string}
*/
getGradientId(index) {
return `${this.instanceId}-gradient-${index}`;
}
/**
* Given a raw data block, return an appropriate label color.
*
* @param {string} labelFill
*
* @return {string}
*/
getLabelColor(labelFill) {
return this.hexExpression.test(labelFill) ? labelFill : this.labelFill;
}
/**
* Shade a color to the given percentage.
*
* @param {string} color A hex color.
* @param {number} shade The shade adjustment. Can be positive or negative.
*
* @return {string}
*/
shade(color, shade) {
const { R, G, B } = this.hexToRgb(color);
const t = shade < 0 ? 0 : 255;
const p = shade < 0 ? shade * -1 : shade;
const converted = 0x1000000 +
((Math.round((t - R) * p) + R) * 0x10000) +
((Math.round((t - G) * p) + G) * 0x100) +
(Math.round((t - B) * p) + B);
return `#${converted.toString(16).slice(1)}`;
}
/**
* Convert a hex color to an RGB object.
*
* @param {string} color
*
* @returns {{R: Number, G: number, B: number}}
*/
hexToRgb(color) {
let hex = color.slice(1);
if (hex.length === 3) {
hex = this.expandHex(hex);
}
const f = parseInt(hex, 16);
/* eslint-disable no-bitwise */
const R = f >> 16;
const G = (f >> 8) & 0x00FF;
const B = f & 0x0000FF;
/* eslint-enable */
return { R, G, B };
}
/**
* Expands a three character hex code to six characters.
*
* @param {string} hex
*
* @return {string}
*/
expandHex(hex) {
return hex[0] + hex[0] + hex[1] + hex[1] + hex[2] + hex[2];
}
}
export default Colorizer;
================================================
FILE: src/d3-funnel/D3Funnel.js
================================================
import { easeLinear } from 'd3-ease';
import { range } from 'd3-array';
import { scaleOrdinal } from 'd3-scale';
import { schemeCategory10 } from 'd3-scale-chromatic';
import { select } from 'd3-selection';
import 'd3-transition';
import { nanoid } from 'nanoid';
import Colorizer from '#js/Colorizer.js';
import Formatter from '#js/Formatter.js';
import Navigator from '#js/Navigator.js';
import Utils from '#js/Utils.js';
class D3Funnel {
static defaults = {
chart: {
width: 350,
height: 400,
bottomWidth: 1 / 3,
bottomPinch: 0,
inverted: false,
horizontal: false,
animate: 0,
curve: {
enabled: false,
height: 20,
shade: -0.4,
},
totalCount: null,
},
block: {
dynamicHeight: false,
dynamicSlope: false,
barOverlay: false,
fill: {
scale: scaleOrdinal(schemeCategory10).domain(range(0, 10)),
type: 'solid',
},
minHeight: 0,
highlight: false,
},
label: {
enabled: true,
fontFamily: null,
fontSize: '14px',
fill: '#fff',
format: '{l}: {f}',
},
tooltip: {
enabled: false,
format: '{l}: {f}',
},
events: {
click: {
block: null,
},
},
};
/**
* @param {string|HTMLElement} selector A selector for the container element.
*
* @return {void}
*/
constructor(selector) {
this.container = select(selector).node();
this.colorizer = new Colorizer();
this.formatter = new Formatter();
this.navigator = new Navigator();
this.id = null;
// Bind event handlers
this.onMouseOver = this.onMouseOver.bind(this);
this.onMouseOut = this.onMouseOut.bind(this);
}
/**
* Remove the funnel and its events from the DOM.
*
* @return {void}
*/
destroy() {
const container = select(this.container);
// D3's remove method appears to be sufficient for removing the events
container.selectAll('svg').remove();
// Remove other elements from container
container.selectAll('*').remove();
// Remove inner text from container
container.text('');
}
/**
* Draw the chart inside the container with the data and configuration
* specified. This will remove any previous SVG elements in the container
* and draw a new funnel chart on top of it.
*
* @param {Array} data A list of rows containing a category, a count,
* and optionally a color (in hex).
* @param {Object} options An optional configuration object to override
* defaults. See the docs.
*
* @return {void}
*/
draw(data, options = {}) {
this.destroy();
this.initialize(data, options);
this.drawOntoDom();
}
/**
* Initialize and calculate important variables for drawing the chart.
*
* @param {Array} data
* @param {Object} options
*
* @return {void}
*/
initialize(data, options) {
this.validateData(data);
const settings = this.getSettings(options);
this.id = `d3-funnel-${nanoid()}`;
// Set labels
this.labelFormatter = this.formatter.getFormatter(settings.label.format);
this.tooltipFormatter = this.formatter.getFormatter(settings.tooltip.format);
// Set color scales
this.colorizer.setInstanceId(this.id);
this.colorizer.setLabelFill(settings.label.fill);
this.colorizer.setScale(settings.block.fill.scale);
// Initialize funnel chart settings
this.settings = {
width: settings.chart.width,
height: settings.chart.height,
bottomWidth: settings.chart.width * settings.chart.bottomWidth,
bottomPinch: settings.chart.bottomPinch,
isInverted: settings.chart.inverted,
isCurved: settings.chart.curve.enabled,
curveHeight: settings.chart.curve.height,
curveShade: settings.chart.curve.shade,
addValueOverlay: settings.block.barOverlay,
animation: settings.chart.animate,
totalCount: settings.chart.totalCount,
fillType: settings.block.fill.type,
hoverEffects: settings.block.highlight,
dynamicHeight: settings.block.dynamicHeight,
dynamicSlope: settings.block.dynamicSlope,
minHeight: settings.block.minHeight,
label: settings.label,
tooltip: settings.tooltip,
onBlockClick: settings.events.click.block,
};
this.setBlocks(data);
}
/**
* @param {Array} data
*
* @return void
*/
validateData(data) {
if (Array.isArray(data) === false) {
throw new Error('Data must be an array.');
}
if (data.length === 0) {
throw new Error('Data array must contain at least one element.');
}
if (typeof data[0] !== 'object') {
throw new Error('Data array elements must be an object.');
}
if (
(Array.isArray(data[0]) && data[0].length < 2) ||
(Array.isArray(data[0]) === false && (
data[0].label === undefined || data[0].value === undefined
))
) {
throw new Error('Data array elements must contain a label and value.');
}
}
/**
* @param {Object} options
*
* @return {Object}
*/
getSettings(options) {
const containerDimensions = this.getContainerDimensions();
const defaults = this.getDefaultSettings(containerDimensions);
// Prepare the configuration settings based on the defaults
let settings = Utils.extend({}, defaults);
// Override default settings with user options
settings = Utils.extend(settings, options);
// Account for any percentage-based dimensions
settings.chart = {
...settings.chart,
...this.castDimensions(settings, containerDimensions),
};
return settings;
}
/**
* Return default settings.
*
* @param {Object} containerDimensions
*
* @return {Object}
*/
getDefaultSettings(containerDimensions) {
const settings = D3Funnel.defaults;
// Set the default width and height based on the container
settings.chart = {
...settings.chart,
...containerDimensions,
};
return settings;
}
/**
* Get the width/height dimensions of the container.
*
* @return {{width: Number, height: Number}}
*/
getContainerDimensions() {
const dimensions = {
width: parseFloat(select(this.container).style('width')),
height: parseFloat(select(this.container).style('height')),
};
// Remove container dimensions that resolve to zero
['width', 'height'].forEach((direction) => {
if (dimensions[direction] === 0) {
delete dimensions[direction];
}
});
return dimensions;
}
/**
* Cast dimensions into tangible or meaningful numbers.
*
* @param {Object} chart
* @param {Object} containerDimensions
*
* @return {{width: Number, height: Number}}
*/
castDimensions({ chart }, containerDimensions) {
const dimensions = {};
Object.keys(containerDimensions).forEach((direction) => {
const chartDimension = chart[direction];
const containerDimension = containerDimensions[direction];
if (/%$/.test(String(chartDimension))) {
// Convert string into a percentage of the container
dimensions[direction] = (parseFloat(chartDimension) / 100) * containerDimension;
} else if (chartDimension <= 0) {
// If case of non-positive number, set to a usable number
dimensions[direction] = D3Funnel.defaults.chart[direction];
} else {
dimensions[direction] = chartDimension;
}
});
return dimensions;
}
/**
* Register the raw data into a standard block format and pre-calculate
* some values.
*
* @param {Array} data
*
* @return void
*/
setBlocks(data) {
const totalCount = this.getTotalCount(data);
this.blocks = this.standardizeData(data, totalCount);
}
/**
* Return the total count of all blocks.
*
* @param {Array} data
*
* @return {Number}
*/
getTotalCount(data) {
if (this.settings.totalCount !== null) {
return this.settings.totalCount || 0;
}
return data.reduce((a, b) => a + Utils.getRawBlockCount(b), 0);
}
/**
* Convert the raw data into a standardized format.
*
* @param {Array} data
* @param {Number} totalCount
*
* @return {Array}
*/
standardizeData(data, totalCount) {
return data.map((rawBlock, index) => {
const block = Array.isArray(rawBlock) ? Utils.convertLegacyBlock(rawBlock) : rawBlock;
const ratio = totalCount > 0 ? (block.value / totalCount || 0) : 1 / data.length;
return {
index,
ratio,
value: block.value,
height: this.settings.height * ratio,
fill: this.colorizer.getBlockFill(
block.backgroundColor,
index,
this.settings.fillType,
),
label: {
enabled: !block.hideLabel,
raw: block.label,
formatted: this.formatter.format(block, this.labelFormatter),
color: this.colorizer.getLabelColor(block.labelColor),
},
tooltip: {
enabled: block.enabled,
formatted: this.formatter.format(block, this.tooltipFormatter),
},
};
});
}
/**
* Draw the chart onto the DOM.
*
* @return {void}
*/
drawOntoDom() {
// Add the SVG
this.svg = select(this.container)
.append('svg')
.attr('id', this.id)
.attr('width', this.settings.width)
.attr('height', this.settings.height);
[this.blockPaths, this.overlayPaths] = this.makePaths();
// Define color gradients
if (this.settings.fillType === 'gradient') {
this.defineColorGradients(this.svg);
}
// Add top oval if curved
if (this.settings.isCurved) {
this.drawTopOval(this.svg, this.blockPaths);
}
// Add each block
this.drawBlock(0);
}
/**
* Create the paths to be used to define the discrete funnel blocks and
* returns the results in an array.
*
* @return {Array, Array}
*/
makePaths() {
// Calculate the important fixed positions
const bottomLeftX = (this.settings.width - this.settings.bottomWidth) / 2;
const centerX = this.settings.width / 2;
let paths = [];
let overlayPaths = [];
// Calculate change in x, y direction
this.dx = this.getDx(bottomLeftX);
this.dy = this.getDy();
// Initialize velocity
let { dx, dy } = this;
// Initialize starting positions
let prevLeftX = 0;
let prevRightX = this.settings.width;
let prevHeight = 0;
// Start from the bottom for inverted
if (this.settings.isInverted) {
prevLeftX = bottomLeftX;
prevRightX = this.settings.width - bottomLeftX;
}
// Initialize next positions
let nextLeftX = 0;
let nextRightX = 0;
let nextHeight = 0;
// Move down if there is an initial curve
if (this.settings.isCurved) {
prevHeight = this.settings.curveHeight / 2;
}
let totalHeight = this.settings.height;
// This is greedy in that the block will have a guaranteed height
// and the remaining is shared among the ratio, instead of being
// shared according to the remaining minus the guaranteed
if (this.settings.minHeight !== 0) {
totalHeight = this.settings.height - (this.settings.minHeight * this.blocks.length);
}
let slopeHeight = this.settings.height;
// Correct slope height if there are blocks being pinched (and thus
// requiring a sharper curve)
if (this.settings.bottomPinch > 0) {
this.blocks.forEach((block, i) => {
let height = (totalHeight * block.ratio);
// Add greedy minimum height
if (this.settings.minHeight !== 0) {
height += this.settings.minHeight;
}
// Account for any curvature
if (this.settings.isCurved) {
height += this.settings.curveHeight / this.blocks.length;
}
if (this.settings.isInverted) {
if (i < this.settings.bottomPinch) {
slopeHeight -= height;
}
} else if (i >= this.blocks.length - this.settings.bottomPinch) {
slopeHeight -= height;
}
});
}
// The slope will determine the x points on each block iteration
// Given: slope = (y1 - y2) / (x1 - x2)
// (x1, y1) = (bottomLeftX, height)
// (x2, y2) = (0, 0)
const slope = slopeHeight / bottomLeftX;
// Create the path definition for each funnel block
// Remember to loop back to the beginning point for a closed path
this.blocks.forEach((block, i) => {
// Make heights proportional to block weight
if (this.settings.dynamicHeight) {
// Slice off the height proportional to this block
dy = totalHeight * block.ratio;
// Add greedy minimum height
if (this.settings.minHeight !== 0) {
dy += this.settings.minHeight;
}
// Account for any curvature
if (this.settings.isCurved) {
dy -= this.settings.curveHeight / this.blocks.length;
}
// Given: y = mx + b
// Given: b = 0 (when funnel), b = this.settings.height (when pyramid)
// For funnel, x_i = y_i / slope
nextLeftX = (prevHeight + dy) / slope;
// For pyramid, x_i = y_i - this.settings.height / -slope
if (this.settings.isInverted) {
nextLeftX = ((prevHeight + dy) - this.settings.height) / (-1 * slope);
}
// If bottomWidth is 0, adjust last x position (to circumvent
// errors associated with rounding)
if (this.settings.bottomWidth === 0 && i === this.blocks.length - 1) {
// For funnel, last position is the center
nextLeftX = this.settings.width / 2;
// For pyramid, last position is the origin
if (this.settings.isInverted) {
nextLeftX = 0;
}
}
// If bottomWidth is same as width, stop x velocity
if (this.settings.bottomWidth === this.settings.width) {
nextLeftX = prevLeftX;
}
// Prevent NaN or Infinite values (caused by zero heights)
if (Number.isNaN(nextLeftX) || !Number.isFinite(nextLeftX)) {
nextLeftX = 0;
}
// Calculate the shift necessary for both x points
dx = nextLeftX - prevLeftX;
if (this.settings.isInverted) {
dx = prevLeftX - nextLeftX;
}
}
// Make slope width proportional to change in block value
if (this.settings.dynamicSlope && !this.settings.isInverted) {
const nextBlockValue = this.blocks[i + 1] ?
this.blocks[i + 1].value :
block.value;
const widthRatio = nextBlockValue / block.value;
dx = (1 - widthRatio) * (centerX - prevLeftX);
}
// Stop velocity for pinched blocks
if (this.settings.bottomPinch > 0) {
// Check if we've reached the bottom of the pinch
// If so, stop changing on x
if (!this.settings.isInverted) {
if (i >= this.blocks.length - this.settings.bottomPinch) {
dx = 0;
}
// Pinch at the first blocks relating to the bottom pinch
// Revert back to normal velocity after pinch
} else {
// Revert velocity back to the initial if we are using
// static heights (prevents zero velocity if isInverted
// and bottomPinch are non-trivial and dynamicHeight is
// false)
if (!this.settings.dynamicHeight) {
({ dx } = this);
}
dx = i < this.settings.bottomPinch ? 0 : dx;
}
}
// Calculate the position of next block
nextLeftX = prevLeftX + dx;
nextRightX = prevRightX - dx;
nextHeight = prevHeight + dy;
this.blocks[i].height = dy;
// Expand outward if inverted
if (this.settings.isInverted) {
nextLeftX = prevLeftX - dx;
nextRightX = prevRightX + dx;
}
const dimensions = {
centerX,
prevLeftX,
prevRightX,
prevHeight,
nextLeftX,
nextRightX,
nextHeight,
curveHeight: this.settings.curveHeight,
ratio: block.ratio,
};
if (this.settings.isCurved) {
paths = [...paths, this.navigator.makeCurvedPaths(dimensions)];
if (this.settings.addValueOverlay) {
overlayPaths = [
...overlayPaths,
this.navigator.makeCurvedPaths(dimensions, true),
];
}
} else {
paths = [...paths, this.navigator.makeStraightPaths(dimensions)];
if (this.settings.addValueOverlay) {
overlayPaths = [
...overlayPaths,
this.navigator.makeStraightPaths(dimensions, true),
];
}
}
// Set the next block's previous position
prevLeftX = nextLeftX;
prevRightX = nextRightX;
prevHeight = nextHeight;
});
return [paths, overlayPaths];
}
/**
* @param {Number} bottomLeftX
*
* @return {Number}
*/
getDx(bottomLeftX) {
// Will be sharper if there is a pinch
if (this.settings.bottomPinch > 0) {
return bottomLeftX / (this.blocks.length - this.settings.bottomPinch);
}
return bottomLeftX / this.blocks.length;
}
/**
* @return {Number}
*/
getDy() {
// Curved chart needs reserved pixels to account for curvature
if (this.settings.isCurved) {
return (this.settings.height - this.settings.curveHeight) / this.blocks.length;
}
return this.settings.height / this.blocks.length;
}
/**
* Define the linear color gradients.
*
* @param {Object} svg
*
* @return {void}
*/
defineColorGradients(svg) {
const defs = svg.append('defs');
// Create a gradient for each block
this.blocks.forEach((block, index) => {
const color = block.fill.raw;
const shade = this.colorizer.shade(color, -0.2);
// Create linear gradient
const gradient = defs.append('linearGradient')
.attr('id', this.colorizer.getGradientId(index));
// Define the gradient stops
const stops = [
[0, shade],
[40, color],
[60, color],
[100, shade],
];
// Add the gradient stops
stops.forEach((stop) => {
gradient.append('stop')
.attr('offset', `${stop[0]}%`)
.attr('style', `stop-color: ${stop[1]}`);
});
});
}
/**
* Draw the top oval of a curved funnel.
*
* @param {Object} svg
* @param {Array} blockPaths
*
* @return {void}
*/
drawTopOval(svg, blockPaths) {
const centerX = this.settings.width / 2;
// Create path from top-most block
const paths = blockPaths[0];
const topCurve = paths[1][1] + (this.settings.curveHeight / 2);
const path = this.navigator.plot([
['M', paths[0][0], paths[0][1]],
['Q', centerX, topCurve],
[' ', paths[2][0], paths[2][1]],
['M', paths[2][0], this.settings.curveHeight / 2],
['Q', centerX, 0],
[' ', paths[0][0], this.settings.curveHeight / 2],
]);
// Draw top oval
svg.append('path')
.attr('fill', this.colorizer.shade(this.blocks[0].fill.raw, this.settings.curveShade))
.attr('d', path);
}
/**
* Draw the next block in the iteration.
*
* @param {int} index
*
* @return {void}
*/
drawBlock(index) {
if (index === this.blocks.length) {
return;
}
// Create a group just for this block
const group = this.svg.append('g');
const block = this.blocks[index];
// Fetch path element
const path = this.getBlockPath(group, index);
// Attach data to the element
this.attachData(path, block);
let overlayPath = null;
let pathColor = block.fill.actual;
if (this.settings.addValueOverlay) {
overlayPath = this.getOverlayPath(group, index);
this.attachData(overlayPath, block);
// Add data attribute to distinguish between paths
path.node().setAttribute('pathType', 'background');
overlayPath.node().setAttribute('pathType', 'foreground');
// Default path becomes background of lighter shade
pathColor = this.colorizer.shade(block.fill.raw, 0.3);
}
// Add animation components
if (this.settings.animation !== 0) {
path.transition()
.duration(this.settings.animation)
.ease(easeLinear)
.attr('fill', pathColor)
.attr('d', this.getPathDefinition(index))
.on('end', () => {
this.drawBlock(index + 1);
});
} else {
path.attr('fill', pathColor)
.attr('d', this.getPathDefinition(index));
this.drawBlock(index + 1);
}
// Add path overlay
if (this.settings.addValueOverlay) {
path.attr('stroke', this.blocks[index].fill.raw);
if (this.settings.animation !== 0) {
overlayPath.transition()
.duration(this.settings.animation)
.ease(easeLinear)
.attr('fill', block.fill.actual)
.attr('d', this.getOverlayPathDefinition(index));
} else {
overlayPath.attr('fill', block.fill.actual)
.attr('d', this.getOverlayPathDefinition(index));
}
}
// Add the hover events
if (this.settings.hoverEffects) {
[path, overlayPath].forEach((target) => {
if (!target) {
return;
}
target
.on('mouseover', this.onMouseOver)
.on('mouseout', this.onMouseOut);
});
}
// Add block click event
if (this.settings.onBlockClick !== null) {
[path, overlayPath].forEach((target) => {
if (!target) {
return;
}
target.style('cursor', 'pointer')
.on('click', this.settings.onBlockClick);
});
}
// Add tooltips
if (this.settings.tooltip.enabled) {
[path, overlayPath].forEach((target) => {
if (!target) {
return;
}
target.node().addEventListener('mouseout', () => {
if (this.tooltip) {
this.container.removeChild(this.tooltip);
this.tooltip = null;
}
});
target.node().addEventListener('mousemove', (e) => {
if (!this.tooltip) {
this.tooltip = document.createElement('div');
this.tooltip.setAttribute('class', 'd3-funnel-tooltip');
this.container.appendChild(this.tooltip);
}
this.tooltip.innerText = block.tooltip.formatted;
const width = this.tooltip.offsetWidth;
const height = this.tooltip.offsetHeight;
const rect = this.container.getBoundingClientRect();
const heightOffset = height + 5;
const containerY = rect.y + window.scrollY;
const isAbove = e.pageY - heightOffset < containerY;
const top = isAbove ? e.pageY + 5 : e.pageY - heightOffset;
const styles = [
'display: inline-block',
'position: absolute',
`left: ${e.pageX - (width / 2)}px`,
`top: ${top}px`,
`border: 1px solid ${block.fill.raw}`,
'background: rgb(255,255,255,0.75)',
'padding: 5px 15px',
'color: #000',
'font-size: 14px',
'font-weight: bold',
'text-align: center',
'cursor: default',
'pointer-events: none',
];
this.tooltip.setAttribute('style', styles.join(';'));
});
});
}
if (this.settings.label.enabled && block.label.enabled) {
this.addBlockLabel(group, index);
}
}
/**
* @param {Object} group
* @param {int} index
*
* @return {Object}
*/
getBlockPath(group, index) {
const path = group.append('path');
if (this.settings.animation !== 0) {
this.addBeforeTransition(path, index, false);
}
return path;
}
/**
* @param {Object} group
* @param {int} index
*
* @return {Object}
*/
getOverlayPath(group, index) {
const path = group.append('path');
if (this.settings.animation !== 0) {
this.addBeforeTransition(path, index, true);
}
return path;
}
/**
* Set the attributes of a path element before its animation.
*
* @param {Object} path
* @param {int} index
* @param {boolean} isOverlay
*
* @return {void}
*/
addBeforeTransition(path, index, isOverlay) {
const paths = isOverlay ? this.overlayPaths[index] : this.blockPaths[index];
let beforePath;
let beforeFill;
// Construct the top of the trapezoid and leave the other elements
// hovering around to expand downward on animation
if (!this.settings.isCurved) {
beforePath = this.navigator.plot([
['M', paths[0][0], paths[0][1]],
['L', paths[1][0], paths[1][1]],
['L', paths[1][0], paths[1][1]],
['L', paths[0][0], paths[0][1]],
]);
} else {
beforePath = this.navigator.plot([
['M', paths[0][0], paths[0][1]],
['Q', paths[1][0], paths[1][1]],
[' ', paths[2][0], paths[2][1]],
['L', paths[2][0], paths[2][1]],
['M', paths[2][0], paths[2][1]],
['Q', paths[1][0], paths[1][1]],
[' ', paths[0][0], paths[0][1]],
]);
}
if (this.settings.fillType === 'solid' && index > 0) {
// Use previous fill color, if available
beforeFill = this.blocks[index - 1].fill.actual;
} else {
// Otherwise use current background
beforeFill = this.blocks[index].fill.actual;
}
path.attr('d', beforePath)
.attr('fill', beforeFill);
}
/**
* Attach data to the target element. Also attach the current node to the
* data object.
*
* @param {Object} element
* @param {Object} data
*
* @return {void}
*/
attachData(element, data) {
const nodeData = {
...data,
node: element.node(),
};
element.data([nodeData]);
}
/**
* @param {int} index
*
* @return {string}
*/
getPathDefinition(index) {
const commands = [];
this.blockPaths[index].forEach((command) => {
commands.push([command[2], command[0], command[1]]);
});
return this.navigator.plot(commands);
}
/**
* @param {int} index
*
* @return {string}
*/
getOverlayPathDefinition(index) {
const commands = [];
this.overlayPaths[index].forEach((command) => {
commands.push([command[2], command[0], command[1]]);
});
return this.navigator.plot(commands);
}
/**
* @param {Object} event
* @param {Object} data
*
* @return {void}
*/
onMouseOver(event, data) {
const children = event.target.parentElement.childNodes;
// Highlight all paths within one block
[...children].forEach((node) => {
if (node.nodeName.toLowerCase() === 'path') {
const type = node.getAttribute('pathType') || '';
if (type === 'foreground') {
select(node).attr('fill', this.colorizer.shade(data.fill.raw, -0.5));
} else {
select(node).attr('fill', this.colorizer.shade(data.fill.raw, -0.2));
}
}
});
}
/**
* @param {Object} event
* @param {Object} data
*
* @return {void}
*/
onMouseOut(event, data) {
const children = event.target.parentElement.childNodes;
// Restore original color for all paths of a block
[...children].forEach((node) => {
if (node.nodeName.toLowerCase() === 'path') {
const type = node.getAttribute('pathType') || '';
if (type === 'background') {
const backgroundColor = this.colorizer.shade(data.fill.raw, 0.3);
select(node).attr('fill', backgroundColor);
} else {
select(node).attr('fill', data.fill.actual);
}
}
});
}
/**
* @param {Object} group
* @param {int} index
*
* @return {void}
*/
addBlockLabel(group, index) {
const paths = this.blockPaths[index];
const formattedLabel = this.blocks[index].label.formatted;
const fill = this.blocks[index].label.color;
// Center the text
const x = this.settings.width / 2;
const y = this.getTextY(paths);
const text = group.append('text')
.attr('x', x)
.attr('y', y)
.attr('fill', fill)
.attr('font-size', this.settings.label.fontSize)
.attr('text-anchor', 'middle')
.attr('dominant-baseline', 'middle')
.attr('pointer-events', 'none');
// Add font-family, if exists
if (this.settings.label.fontFamily !== null) {
text.attr('font-family', this.settings.label.fontFamily);
}
this.addLabelLines(text, formattedLabel, x);
}
/**
* Add elements for each line of the formatted label.
*
* @param {Object} text
* @param {String} formattedLabel
* @param {Number} x
*
* @return {void}
*/
addLabelLines(text, formattedLabel, x) {
const lines = formattedLabel.split('\n');
const lineHeight = 20;
// dy will signify the change from the initial height y
// We need to initially start the first line at the very top, factoring
// in the other number of lines
const initialDy = (-1 * lineHeight * (lines.length - 1)) / 2;
lines.forEach((line, i) => {
const dy = i === 0 ? initialDy : lineHeight;
text.append('tspan').attr('x', x).attr('dy', dy).text(line);
});
}
/**
* Returns the y position of the given label's text. This is determined by
* taking the mean of the bases.
*
* @param {Array} paths
*
* @return {Number}
*/
getTextY(paths) {
const { isCurved, curveHeight } = this.settings;
if (isCurved) {
return ((paths[2][1] + paths[3][1]) / 2) + ((1.5 * curveHeight) / this.blocks.length);
}
return (paths[1][1] + paths[2][1]) / 2;
}
}
export default D3Funnel;
================================================
FILE: src/d3-funnel/Formatter.js
================================================
class Formatter {
/**
* Register the format function.
*
* @param {string|function} format
*
* @return {function}
*/
getFormatter(format) {
if (typeof format === 'function') {
return format;
}
return (label, value, formattedValue) => (
this.stringFormatter(label, value, formattedValue, format)
);
}
/**
* Format the given value according to the data point or the format.
*
* @param {string} label
* @param {number} value
* @param {*} formattedValue
* @param {function} formatter
*
* @return string
*/
format({ label, value, formattedValue = null }, formatter) {
return formatter(label, value, formattedValue);
}
/**
* Format the string according to a simple expression.
*
* {l}: label
* {v}: raw value
* {f}: formatted value
*
* @param {string} label
* @param {number} value
* @param {*} formattedValue
* @param {string} expression
*
* @return {string}
*/
stringFormatter(label, value, formattedValue, expression) {
let formatted = formattedValue;
// Attempt to use supplied formatted value
// Otherwise, use the default
if (formattedValue === null) {
formatted = this.getDefaultFormattedValue(value);
}
return expression
.split('{l}')
.join(label)
.split('{v}')
.join(value)
.split('{f}')
.join(formatted);
}
/**
* @param {number} value
*
* @return {string}
*/
getDefaultFormattedValue(value) {
return value.toLocaleString();
}
}
export default Formatter;
================================================
FILE: src/d3-funnel/Navigator.js
================================================
class Navigator {
/**
* Given a list of path commands, returns the compiled description.
*
* @param {Array} commands
*
* @return {string}
*/
plot(commands) {
let path = '';
commands.forEach((command) => {
path += `${command[0]}${command[1]},${command[2]} `;
});
return path.replace(/ +/g, ' ').trim();
}
/**
* @param {Object} dimensions
* @param {boolean} isValueOverlay
*
* @return {Array}
*/
makeCurvedPaths(dimensions, isValueOverlay = false) {
const points = this.makeBezierPoints(dimensions);
if (isValueOverlay) {
return this.makeBezierPath(points, dimensions.ratio);
}
return this.makeBezierPath(points);
}
/**
* @param {Number} centerX
* @param {Number} prevLeftX
* @param {Number} prevRightX
* @param {Number} prevHeight
* @param {Number} nextLeftX
* @param {Number} nextRightX
* @param {Number} nextHeight
* @param {Number} curveHeight
*
* @return {Object}
*/
makeBezierPoints({
centerX,
prevLeftX,
prevRightX,
prevHeight,
nextLeftX,
nextRightX,
nextHeight,
curveHeight,
}) {
return {
p00: {
x: prevLeftX,
y: prevHeight,
},
p01: {
x: centerX,
y: prevHeight + (curveHeight / 2),
},
p02: {
x: prevRightX,
y: prevHeight,
},
p10: {
x: nextLeftX,
y: nextHeight,
},
p11: {
x: centerX,
y: nextHeight + curveHeight,
},
p12: {
x: nextRightX,
y: nextHeight,
},
};
}
/**
* @param {Object} p00
* @param {Object} p01
* @param {Object} p02
* @param {Object} p10
* @param {Object} p11
* @param {Object} p12
* @param {Number} ratio
*
* @return {Array}
*/
makeBezierPath({
p00,
p01,
p02,
p10,
p11,
p12,
}, ratio = 1) {
const curve0 = this.getQuadraticBezierCurve(p00, p01, p02, ratio);
const curve1 = this.getQuadraticBezierCurve(p10, p11, p12, ratio);
return [
// Top Bezier curve
[curve0.p0.x, curve0.p0.y, 'M'],
[curve0.p1.x, curve0.p1.y, 'Q'],
[curve0.p2.x, curve0.p2.y, ''],
// Right line
[curve1.p2.x, curve1.p2.y, 'L'],
// Bottom Bezier curve
[curve1.p2.x, curve1.p2.y, 'M'],
[curve1.p1.x, curve1.p1.y, 'Q'],
[curve1.p0.x, curve1.p0.y, ''],
// Left line
[curve0.p0.x, curve0.p0.y, 'L'],
];
}
/**
* @param {Object} p0
* @param {Object} p1
* @param {Object} p2
* @param {Number} t
*
* @return {Object}
*/
getQuadraticBezierCurve(p0, p1, p2, t = 1) {
// Quadratic Bezier curve syntax: M(P0) Q(P1) P2
// Where P0, P2 are the curve endpoints and P1 is the control point
// More generally, at 0 <= t <= 1, we have the following:
// Q0(t), which varies linearly from P0 to P1
// Q1(t), which varies linearly from P1 to P2
// B(t), which is interpolated linearly between Q0(t) and Q1(t)
// For an intermediate curve at 0 <= t <= 1:
// P1(t) = Q0(t)
// P2(t) = B(t)
return {
p0,
p1: {
x: this.getLinearInterpolation(p0, p1, t, 'x'),
y: this.getLinearInterpolation(p0, p1, t, 'y'),
},
p2: {
x: this.getQuadraticInterpolation(p0, p1, p2, t, 'x'),
y: this.getQuadraticInterpolation(p0, p1, p2, t, 'y'),
},
};
}
/**
* @param {Object} p0
* @param {Object} p1
* @param {Number} t
* @param {string} axis
*
* @return {Number}
*/
getLinearInterpolation(p0, p1, t, axis) {
return p0[axis] + (t * (p1[axis] - p0[axis]));
}
/**
* @param {Object} p0
* @param {Object} p1
* @param {Object} p2
* @param {Number} t
* @param {string} axis
*
* @return {Number}
*/
getQuadraticInterpolation(p0, p1, p2, t, axis) {
return (((1 - t) ** 2) * p0[axis]) +
(2 * (1 - t) * t * p1[axis]) +
((t ** 2) * p2[axis]);
}
/**
* @param {Number} prevLeftX
* @param {Number} prevRightX
* @param {Number} prevHeight
* @param {Number} nextLeftX
* @param {Number} nextRightX
* @param {Number} nextHeight
* @param {Number} ratio
* @param {boolean} isValueOverlay
*
* @return {Object}
*/
makeStraightPaths({
prevLeftX,
prevRightX,
prevHeight,
nextLeftX,
nextRightX,
nextHeight,
ratio,
}, isValueOverlay = false) {
if (isValueOverlay) {
const lengthTop = (prevRightX - prevLeftX);
const lengthBtm = (nextRightX - nextLeftX);
let rightSideTop = (lengthTop * (ratio || 0)) + prevLeftX;
let rightSideBtm = (lengthBtm * (ratio || 0)) + nextLeftX;
// Overlay should not be longer than the max length of the path
rightSideTop = Math.min(rightSideTop, lengthTop);
rightSideBtm = Math.min(rightSideBtm, lengthBtm);
return [
// Start position
[prevLeftX, prevHeight, 'M'],
// Move to right
[rightSideTop, prevHeight, 'L'],
// Move down
[rightSideBtm, nextHeight, 'L'],
// Move to left
[nextLeftX, nextHeight, 'L'],
// Wrap back to top
[prevLeftX, prevHeight, 'L'],
];
}
return [
// Start position
[prevLeftX, prevHeight, 'M'],
// Move to right
[prevRightX, prevHeight, 'L'],
// Move down
[nextRightX, nextHeight, 'L'],
// Move to left
[nextLeftX, nextHeight, 'L'],
// Wrap back to top
[prevLeftX, prevHeight, 'L'],
];
}
}
export default Navigator;
================================================
FILE: src/d3-funnel/Utils.js
================================================
class Utils {
/**
* Determine whether the given parameter is an extendable object.
*
* @param {*} a
*
* @return {boolean}
*/
static isExtendableObject(a) {
return typeof a === 'object' && a !== null && !Array.isArray(a);
}
/**
* Extends an object with the members of another.
*
* @param {Object} a The object to be extended.
* @param {Object} b The object to clone from.
*
* @return {Object}
*/
static extend(a, b) {
let result = {};
// If a is non-trivial, extend the result with it
if (Object.keys(a).length > 0) {
result = Utils.extend({}, a);
}
// Copy over the properties in b into a
Object.keys(b).forEach((prop) => {
if (Utils.isExtendableObject(b[prop])) {
if (Utils.isExtendableObject(a[prop])) {
result[prop] = Utils.extend(a[prop], b[prop]);
} else {
result[prop] = Utils.extend({}, b[prop]);
}
} else {
result[prop] = b[prop];
}
});
return result;
}
/**
* Convert the legacy block array to a block object.
*
* @param {Array} block
*
* @returns {Object}
*/
static convertLegacyBlock(block) {
return {
label: block[0],
value: Utils.getRawBlockCount(block),
formattedValue: Array.isArray(block[1]) ? block[1][1] : null,
backgroundColor: block[2],
labelColor: block[3],
};
}
/**
* Given a raw data block, return its count.
*
* @param {Array} block
*
* @return {Number}
*/
static getRawBlockCount(block) {
if (Array.isArray(block)) {
return Array.isArray(block[1]) ? block[1][0] : block[1];
}
return block.value;
}
}
export default Utils;
================================================
FILE: src/index.js
================================================
import D3Funnel from '#js/D3Funnel.js';
export default D3Funnel;
================================================
FILE: test/d3-funnel/Colorizer.js
================================================
import { assert } from 'chai';
import Colorizer from '../../src/d3-funnel/Colorizer.js';
describe('Colorizer', () => {
describe('expandHex', () => {
it('should expand a three character hex code to six characters', () => {
const hex = 'd33';
assert.equal('dd3333', (new Colorizer()).expandHex(hex));
});
});
describe('shade', () => {
it('should brighten a color by the given positive percentage', () => {
const color = '#000000';
assert.equal('#222222', (new Colorizer()).shade(color, 2 / 15));
});
it('should shade a color by the given negative percentage', () => {
const color = '#ffffff';
assert.equal('#dddddd', (new Colorizer()).shade(color, -2 / 15));
});
it('should expand a three-character hex', () => {
const color = '#fff';
assert.equal('#ffffff', (new Colorizer()).shade(color, 0));
});
});
describe('hexToRg', () => {
it('should convert a hex value to its RGB value', () => {
const color = '#007fff';
assert.deepEqual({ R: 0, G: 127, B: 255 }, (new Colorizer()).hexToRgb(color));
});
it('should expand a three-character hex', () => {
const color = '#d33';
assert.deepEqual({ R: 221, G: 51, B: 51 }, (new Colorizer()).hexToRgb(color));
});
});
});
================================================
FILE: test/d3-funnel/D3Funnel.js
================================================
import { cloneDeep } from 'lodash';
import {
range,
select,
selectAll,
scaleOrdinal,
schemeCategory10,
} from 'd3';
import { assert } from 'chai';
import sinon from 'sinon';
import D3Funnel from '../../src/d3-funnel/D3Funnel.js';
function getFunnel() {
return new D3Funnel('#funnel');
}
function getSvg() {
return select('#funnel').selectAll('svg');
}
function getSvgId() {
return document.querySelector('#funnel svg').id;
}
function getBasicData() {
return [{ label: 'Node', value: 1000 }];
}
function isLetter(str) {
return str.length === 1 && str.match(/[a-z]/i);
}
function getCommandPoint(command) {
const points = command.split(',');
const y = points[1];
let x = points[0];
// Strip any letter in front of number
if (isLetter(x[0])) {
x = x.substr(1);
}
return {
x: parseFloat(x),
y: parseFloat(y),
};
}
function getPathTopWidth(path) {
const commands = path.attr('d').split(' ');
return getCommandPoint(commands[1]).x - getCommandPoint(commands[0]).x;
}
function getPathBottomWidth(path) {
const commands = path.attr('d').split(' ');
return getCommandPoint(commands[2]).x - getCommandPoint(commands[3]).x;
}
function getPathHeight(path) {
const commands = path.attr('d').split(' ');
return getCommandPoint(commands[2]).y - getCommandPoint(commands[0]).y;
}
const defaults = cloneDeep(D3Funnel.defaults);
describe('D3Funnel', () => {
beforeEach((done) => {
// Reset any styles
select('#funnel').attr('style', null);
// Reset defaults
D3Funnel.defaults = cloneDeep(defaults);
// Clear out sandbox
document.getElementById('sandbox').innerHTML = '';
done();
});
describe('constructor', () => {
it('should instantiate without error when a query string is provided', () => {
new D3Funnel('#funnel'); // eslint-disable-line no-new
});
it('should instantiate without error when a DOM node is provided', () => {
new D3Funnel(document.querySelector('#funnel')); // eslint-disable-line no-new
});
});
describe('methods', () => {
describe('draw', () => {
it('should draw a chart on the identified target', () => {
getFunnel().draw(getBasicData());
assert.equal(1, getSvg().nodes().length);
});
it('should draw when no options are specified', () => {
getFunnel().draw(getBasicData());
assert.equal(1, getSvg().nodes().length);
});
it('should throw an error when the data is not an array', () => {
const funnel = getFunnel();
assert.throws(() => {
funnel.draw('Not array');
}, Error, 'Data must be an array.');
});
it('should throw an error when the data array does not have an element', () => {
const funnel = getFunnel();
assert.throws(() => {
funnel.draw([]);
}, Error, 'Data array must contain at least one element.');
});
it('should throw an error when the first data array element is not an object', () => {
const funnel = getFunnel();
assert.throws(() => {
funnel.draw(['Not array']);
}, Error, 'Data array elements must be an object.');
});
it('should throw an error when the first data array element does not have a value', () => {
const funnel = getFunnel();
assert.throws(() => {
funnel.draw([{ label: 'Only Label' }]);
}, Error, 'Data array elements must contain a label and value.');
});
it('should draw as many blocks as there are elements', () => {
getFunnel().draw([
{ label: 'Node A', value: 1 },
{ label: 'Node B', value: 2 },
{ label: 'Node C', value: 3 },
{ label: 'Node D', value: 4 },
]);
assert.equal(4, getSvg().selectAll('path').nodes().length);
});
it('should pass any row-specified formatted values to the label formatter', () => {
getFunnel().draw([
{ label: 'Node A', value: 1, formattedValue: 'One' },
{ label: 'Node B', value: 2 },
{ label: 'Node C', value: 1, formattedValue: 'Three' },
]);
const texts = getSvg().selectAll('text').nodes();
assert.equal('Node A: One', select(texts[0]).text());
assert.equal('Node B: 2', select(texts[1]).text());
assert.equal('Node C: Three', select(texts[2]).text());
});
it('should hide the labels of any row specified', () => {
getFunnel().draw([
{ label: 'Node A', value: 1, hideLabel: true },
{ label: 'Node B', value: 2 },
{ label: 'Node C', value: 3, hideLabel: true },
]);
const texts = getSvg().selectAll('text').nodes();
assert.equal('Node B: 2', select(texts[0]).text());
assert.equal(undefined, texts[1]);
});
it('should use colors assigned to a data element', () => {
getFunnel().draw([
{ label: 'Node A', value: 1, backgroundColor: '#111' },
{ label: 'Node B', value: 2, backgroundColor: '#222' },
{ label: 'Node C', value: 3 },
{ label: 'Node D', value: 4, backgroundColor: '#444' },
]);
const paths = getSvg().selectAll('path').nodes();
const colorScale = scaleOrdinal(schemeCategory10).domain(range(0, 10));
assert.equal('#111', select(paths[0]).attr('fill'));
assert.equal('#222', select(paths[1]).attr('fill'));
assert.equal(colorScale(2), select(paths[2]).attr('fill'));
assert.equal('#444', select(paths[3]).attr('fill'));
});
it('should use label colors assigned to a data element', () => {
getFunnel().draw([
{ label: 'A', value: 1, labelColor: '#111' },
{ label: 'B', value: 2, labelColor: '#222' },
{ label: 'C', value: 3 },
{ label: 'D', value: 4, labelColor: '#444' },
]);
const texts = getSvg().selectAll('text').nodes();
assert.equal('#111', select(texts[0]).attr('fill'));
assert.equal('#222', select(texts[1]).attr('fill'));
assert.equal('#fff', select(texts[2]).attr('fill'));
assert.equal('#444', select(texts[3]).attr('fill'));
});
it('should remove other elements from container', () => {
const container = select('#funnel');
const funnel = getFunnel();
// Make sure the container has no children
container.selectAll('*').remove();
container.append('p');
funnel.draw(getBasicData());
// Expect funnel children count plus funnel itself
const expected = getSvg().selectAll('*').size() + 1;
const actual = container.selectAll('*').size();
assert.equal(expected, actual);
});
it('should remove inner text from container', () => {
const container = select('#funnel');
const funnel = getFunnel();
// Make sure the container has no text
container.text();
container.text('to be removed');
funnel.draw(getBasicData());
// Make sure the only text in container comes from the funnel
assert.equal(getSvg().text(), container.text());
});
it('should assign a unique ID upon draw', () => {
getFunnel().draw(getBasicData());
const id = getSvgId();
assert.isTrue(document.querySelectorAll(`#${id}`).length === 1);
});
});
describe('destroy', () => {
it('should remove a drawn SVG element', () => {
const funnel = getFunnel();
funnel.draw(getBasicData());
funnel.destroy();
assert.equal(0, getSvg().nodes().length);
});
});
});
describe('defaults', () => {
it('should affect all default options', () => {
D3Funnel.defaults.label.fill = '#777';
getFunnel().draw(getBasicData());
assert.isTrue(select('#funnel text').attr('fill').indexOf('#777') > -1);
});
});
describe('options', () => {
describe('chart.width/height', () => {
it('should default to the container\'s dimensions', () => {
['width', 'height'].forEach((direction) => {
select('#funnel').style(direction, '250px');
getFunnel().draw(getBasicData());
assert.equal(250, getSvg().node().getBBox()[direction]);
});
});
it('should default to the library defaults if the container dimensions are zero', () => {
document.querySelector('#funnel').style.width = '0px';
document.querySelector('#funnel').style.height = '0px';
getFunnel().draw(getBasicData());
assert.equal(350, getSvg().node().getBBox().width);
assert.equal(400, getSvg().node().getBBox().height);
});
it('should set the funnel\'s width/height to the specified amount', () => {
['width', 'height'].forEach((direction) => {
getFunnel().draw(getBasicData(), {
chart: {
[direction]: 200,
},
});
assert.equal(200, getSvg().node().getBBox()[direction]);
});
});
it('should set the funnel\'s percent width/height to the specified amount', () => {
['width', 'height'].forEach((direction) => {
select('#funnel').style(direction, '200px');
getFunnel().draw(getBasicData(), {
chart: {
[direction]: '75%',
},
});
assert.equal(150, getSvg().node().getBBox()[direction]);
});
});
});
describe('chart.height', () => {
it('should default to the container\'s height', () => {
select('#funnel').style('height', '250px');
getFunnel().draw(getBasicData());
assert.equal(250, getSvg().node().getBBox().height);
});
it('should set the funnel\'s height to the specified amount', () => {
getFunnel().draw(getBasicData(), {
chart: {
height: 200,
},
});
assert.equal(200, getSvg().node().getBBox().height);
});
it('should set the funnel\'s percentage height to the specified amount', () => {
select('#funnel').style('height', '300px');
getFunnel().draw(getBasicData(), {
chart: {
height: '50%',
},
});
assert.equal(150, getSvg().node().getBBox().height);
});
});
describe('chart.bottomWidth', () => {
it('should set the bottom tip width to the specified percentage', () => {
getFunnel().draw(getBasicData(), {
chart: {
width: 200,
bottomWidth: 1 / 2,
},
});
assert.equal(100, getPathBottomWidth(select('path')));
});
});
describe('chart.bottomPinch', () => {
it('should set the last n number of blocks to have the width of chart.bottomWidth', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
{ label: 'C', value: 3 },
], {
chart: {
width: 450,
bottomWidth: 1 / 3,
bottomPinch: 2,
},
});
const paths = selectAll('path').nodes();
assert.equal(150, paths[1].getBBox().width);
assert.equal(150, paths[2].getBBox().width);
});
it('should maintain chart.bottomWidth when combined with block.minHeight', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
{ label: 'C', value: 3 },
], {
chart: {
width: 450,
height: 100,
bottomWidth: 1 / 3,
bottomPinch: 1,
},
block: {
dynamicHeight: true,
minHeight: 20,
},
});
const paths = selectAll('path').nodes();
assert.equal(150, paths[2].getBBox().width);
});
it('should maintain chart.bottomWidth when combined with block.dynamicHeight and curve.enabled', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
{ label: 'C', value: 3 },
{ label: 'D', value: 4 },
], {
chart: {
width: 320,
height: 400,
bottomWidth: 3 / 8,
bottomPinch: 1,
curve: {
enabled: true,
},
},
block: {
dynamicHeight: true,
},
});
const paths = selectAll('path').nodes();
assert.equal(120, paths[4].getBBox().width);
});
});
describe('chart.inverted', () => {
it('should draw the chart in a top-to-bottom arrangement by default', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
], {
chart: {
width: 200,
bottomWidth: 1 / 2,
},
});
const paths = selectAll('path').nodes();
assert.equal(200, getPathTopWidth(select(paths[0])));
assert.equal(100, getPathBottomWidth(select(paths[1])));
});
it('should draw the chart in a bottom-to-top arrangement when true', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
], {
chart: {
width: 200,
bottomWidth: 1 / 2,
inverted: true,
},
});
const paths = selectAll('path').nodes();
assert.equal(100, getPathTopWidth(select(paths[0])));
assert.equal(200, getPathBottomWidth(select(paths[1])));
});
});
describe('chart.curve.enabled', () => {
it('should create an additional path on top of the trapezoids', () => {
getFunnel().draw(getBasicData(), {
chart: {
curve: {
enabled: true,
},
},
});
assert.equal(2, selectAll('#funnel path').nodes().length);
});
it('should create a quadratic Bezier curve on each path', () => {
getFunnel().draw(getBasicData(), {
chart: {
curve: {
enabled: true,
},
},
});
const paths = selectAll('#funnel path').nodes();
const quadraticPaths = paths.filter((path) => select(path).attr('d').indexOf('Q') > -1);
assert.equal(paths.length, quadraticPaths.length);
});
});
describe('block.dynamicHeight', () => {
it('should use equal heights when false', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
], {
chart: {
height: 300,
},
});
const paths = selectAll('#funnel path').nodes();
assert.equal(150, getPathHeight(select(paths[0])));
assert.equal(150, getPathHeight(select(paths[1])));
});
it('should use proportional heights when true', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
], {
chart: {
height: 300,
},
block: {
dynamicHeight: true,
},
});
const paths = selectAll('#funnel path').nodes();
assert.equal(100, parseInt(getPathHeight(select(paths[0])), 10));
assert.equal(200, parseInt(getPathHeight(select(paths[1])), 10));
});
it('should not have NaN in the last path when bottomWidth is equal to 0%', () => {
// A very specific cooked-up example that could trigger NaN
getFunnel().draw([
{ label: 'A', value: 120 },
{ label: 'B', value: 40 },
{ label: 'C', value: 20 },
{ label: 'D', value: 15 },
], {
chart: {
height: 300,
bottomWidth: 0,
},
block: {
dynamicHeight: true,
},
});
const paths = selectAll('#funnel path').nodes();
assert.equal(-1, select(paths[3]).attr('d').indexOf('NaN'));
});
it('should not error when bottomWidth is equal to 100%', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
], {
chart: {
height: 300,
bottomWidth: 1,
},
block: {
dynamicHeight: true,
},
});
});
it('should not generate NaN or Infinite values when zero', () => {
getFunnel().draw(getBasicData(), {
chart: {
height: 0,
},
block: {
dynamicHeight: true,
},
});
selectAll('path').nodes().forEach((node) => {
const definition = String(select(node).attr('d'));
assert.equal(false, definition.indexOf('NaN') > -1 || definition.indexOf('Infinity') > -1);
});
});
it('should give all blocks equal height if the sum of values is zero', () => {
getFunnel().draw([
{ label: 'A', value: 0 },
{ label: 'B', value: 0 },
], {
chart: {
height: 300,
},
block: {
dynamicHeight: true,
},
});
const paths = selectAll('#funnel path').nodes();
assert.equal(150, getPathHeight(select(paths[0])));
assert.equal(150, getPathHeight(select(paths[1])));
});
});
describe('block.dynamicSlope', () => {
it('should give each block top width relative to its value', () => {
getFunnel().draw([
{ label: 'A', value: 100 },
{ label: 'B', value: 55 },
{ label: 'C', value: 42 },
{ label: 'D', value: 74 },
], {
chart: {
width: 100,
},
block: {
dynamicSlope: true,
},
});
const paths = selectAll('#funnel path').nodes();
assert.equal(parseFloat(getPathTopWidth(select(paths[0]))), 100);
assert.equal(parseFloat(getPathTopWidth(select(paths[1]))), 55);
assert.equal(parseFloat(getPathTopWidth(select(paths[2]))), 42);
assert.equal(parseFloat(getPathTopWidth(select(paths[3]))), 74);
});
it('should make the last block top width equal to bottom width', () => {
getFunnel().draw([
{ label: 'A', value: 100 },
{ label: 'B', value: 52 },
{ label: 'C', value: 42 },
{ label: 'D', value: 74 },
], {
chart: {
width: 100,
},
block: {
dynamicSlope: true,
},
});
const paths = selectAll('#funnel path').nodes();
assert.equal(parseFloat(getPathTopWidth(select(paths[3]))), 74);
assert.equal(parseFloat(getPathBottomWidth(select(paths[3]))), 74);
});
it('should use bottomWidth value when false', () => {
getFunnel().draw([
{ label: 'A', value: 100 },
{ label: 'B', value: 90 },
], {
chart: {
width: 100,
bottomWidth: 0.4,
},
});
const paths = selectAll('#funnel path').nodes();
assert.equal(parseFloat(getPathTopWidth(select(paths[0]))), 100);
assert.equal(parseFloat(getPathBottomWidth(select(paths[1]))), 40);
});
});
describe('block.barOverlay', () => {
it('should draw value overlay within each path', () => {
getFunnel().draw([
{ label: 'A', value: 10 },
{ label: 'B', value: 20 },
], {
block: {
barOverlay: true,
},
});
// draw 2 path for each data point
assert.equal(4, selectAll('#funnel path').nodes().length);
});
it('should draw value overlay with overridden total count', () => {
getFunnel().draw([
{ label: 'A', value: 10 },
{ label: 'B', value: 20 },
], {
chart: {
totalCount: 100,
},
block: {
barOverlay: true,
},
});
const paths = selectAll('path').nodes();
const APathFullWidth = getPathTopWidth(select(paths[0]));
const APathOverlayWidth = getPathTopWidth(select(paths[1]));
const BPathFullWidth = getPathTopWidth(select(paths[2]));
const BPathOverlayWidth = getPathTopWidth(select(paths[3]));
assert.equal(10, Math.round((APathOverlayWidth / APathFullWidth) * 100));
assert.equal(20, Math.round((BPathOverlayWidth / BPathFullWidth) * 100));
});
});
describe('block.fill.scale', () => {
it('should use a function\'s return value', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
], {
block: {
fill: {
scale: (index) => {
if (index === 0) {
return '#111';
}
return '#222';
},
},
},
});
const paths = getSvg().selectAll('path').nodes();
assert.equal('#111', select(paths[0]).attr('fill'));
assert.equal('#222', select(paths[1]).attr('fill'));
});
it('should use an array\'s return value', () => {
getFunnel().draw([
{ label: 'A', value: 1 },
{ label: 'B', value: 2 },
], {
block: {
fill: {
scale: ['#111', '#222'],
},
},
});
const paths = getSvg().selectAll('path').nodes();
assert.equal('#111', select(paths[0]).attr('fill'));
assert.equal('#222', select(paths[1]).attr('fill'));
});
});
describe('block.fill.type', () => {
it('should create gradients when set to \'gradient\'', () => {
getFunnel().draw(getBasicData(), {
block: {
fill: {
type: 'gradient',
},
},
});
const id = getSvgId();
// Cannot try to re-select the camelCased linearGradient element
// due to a Webkit bug in the current PhantomJS; workaround is
// to select the known ID of the linearGradient element
// https://bugs.webkit.org/show_bug.cgi?id=83438
assert.equal(1, selectAll(`#funnel defs #${id}-gradient-0`).nodes().length);
assert.equal(`url(#${id}-gradient-0)`, select('#funnel path').attr('fill'));
});
it('should use solid fill when not set to \'gradient\'', () => {
getFunnel().draw(getBasicData());
// Check for valid hex string
assert.isTrue(/(^#[0-9A-F]{6}$)|(^#[0-9A-F]{3}$)/i.test(
select('#funnel path').attr('fill'),
));
});
});
describe('block.minHeight', () => {
it('should give each block the minimum height specified', () => {
getFunnel().draw([
{ label: 'A', value: 299 },
{ label: 'B', value: 1 },
], {
chart: {
height: 300,
},
block: {
dynamicHeight: true,
minHeight: 10,
},
});
const paths = selectAll('#funnel path').nodes();
assert.isAbove(parseFloat(getPathHeight(select(paths[0]))), 10);
assert.isAbove(parseFloat(getPathHeight(select(paths[1]))), 10);
});
it('should decrease the height of blocks above the minimum', () => {
getFunnel().draw([
{ label: 'A', value: 299 },
{ label: 'B', value: 1 },
], {
chart: {
height: 300,
},
block: {
dynamicHeight: true,
minHeight: 10,
},
});
const paths = selectAll('#funnel path').nodes();
assert.isBelow(parseFloat(getPathHeight(select(paths[0]))), 290);
});
});
describe('block.highlight', () => {
it('should change block color on hover', () => {
const event = document.createEvent('CustomEvent');
event.initCustomEvent('mouseover', false, false, null);
getFunnel().draw([
{ label: 'A', value: 1, backgroundColor: '#fff' },
], {
block: {
highlight: true,
},
});
select('#funnel path').node().dispatchEvent(event);
// #fff * -1/5 => #cccccc
assert.equal('#cccccc', select('#funnel path').attr('fill'));
});
});
describe('label.enabled', () => {
it('should render block labels when set to true', () => {
getFunnel().draw(getBasicData(), {
label: { enabled: true },
});
assert.equal(1, selectAll('#funnel text').size());
});
it('should not render block labels when set to false', () => {
getFunnel().draw(getBasicData(), {
label: { enabled: false },
});
assert.equal(0, selectAll('#funnel text').size());
});
});
describe('label.fontFamily', () => {
it('should set the label\'s font size to the specified amount', () => {
getFunnel().draw(getBasicData(), {
label: {
fontFamily: 'Open Sans',
},
});
assert.equal('Open Sans', select('#funnel text').attr('font-family'));
});
});
describe('label.fontSize', () => {
it('should set the label\'s font size to the specified amount', () => {
getFunnel().draw(getBasicData(), {
label: {
fontSize: '16px',
},
});
assert.equal('16px', select('#funnel text').attr('font-size'));
});
});
describe('label.fill', () => {
it('should set the label\'s fill color to the specified color', () => {
getFunnel().draw(getBasicData(), {
label: {
fill: '#777',
},
});
assert.isTrue(select('#funnel text').attr('fill').indexOf('#777') > -1);
});
});
describe('label.format', () => {
it('should parse a string template', () => {
getFunnel().draw(getBasicData(), {
label: {
format: '{l} {v} {f}',
},
});
assert.equal('Node 1000 1,000', select('#funnel text').text());
});
it('should create split multiple lines into multiple tspans', () => {
getFunnel().draw(getBasicData(), {
label: {
format: '{l}\n{v}',
},
});
const tspans = selectAll('#funnel text tspan').nodes();
assert.equal('Node', select(tspans[0]).text());
assert.equal('1000', select(tspans[1]).text());
});
it('should create position multiple lines in a vertically-centered manner', () => {
getFunnel().draw(getBasicData(), {
chart: {
height: 200,
},
label: {
format: '{l}\n{v}\n{f}',
},
});
const tspans = selectAll('#funnel text tspan').nodes();
assert.equal(-20, select(tspans[0]).attr('dy'));
assert.equal(20, select(tspans[1]).attr('dy'));
assert.equal(20, select(tspans[2]).attr('dy'));
});
it('should pass values to a supplied function', () => {
getFunnel().draw(getBasicData(), {
label: {
format: (label, value, formattedValue) => `${label}/${value}/${formattedValue}`,
},
});
assert.equal('Node/1000/null', select('#funnel text').text());
});
});
describe('tooltip.enabled', () => {
it('should render a simple tooltip box when hovering over a block', () => {
const event = document.createEvent('CustomEvent');
event.initCustomEvent('mousemove', false, false, null);
getFunnel().draw(getBasicData(), {
tooltip: {
enabled: true,
},
});
select('#funnel path').node().dispatchEvent(event);
assert.notEqual(null, select('#funnel .d3-funnel-tooltip').node());
});
it('should hide the tooltip on mouseout', () => {
const mouseMove = document.createEvent('CustomEvent');
const mouseOut = document.createEvent('CustomEvent');
mouseMove.initCustomEvent('mousemove', false, false, null);
mouseOut.initCustomEvent('mouseout', false, false, null);
getFunnel().draw(getBasicData(), {
tooltip: {
enabled: true,
},
});
select('#funnel path').node().dispatchEvent(mouseMove);
select('#funnel path').node().dispatchEvent(mouseOut);
assert.equal(null, select('#funnel .d3-funnel-tooltip').node());
});
});
describe('tooltip.format', () => {
it('should render tooltips according to the format provided', () => {
const event = document.createEvent('CustomEvent');
event.initCustomEvent('mousemove', false, false, null);
getFunnel().draw(getBasicData(), {
tooltip: {
enabled: true,
format: '{l} - {v}',
},
});
select('#funnel path').node().dispatchEvent(event);
assert.equal('Node - 1000', select('#funnel .d3-funnel-tooltip').text());
});
});
describe('events.click.block', () => {
it('should invoke the callback function with the correct data', () => {
const event = document.createEvent('CustomEvent');
event.initCustomEvent('click', false, false, null);
const proxy = sinon.fake();
getFunnel().draw(getBasicData(), {
events: {
click: {
block: (e, d) => {
proxy({
index: d.index,
node: d.node,
label: d.label.raw,
value: d.value,
});
},
},
},
});
select('#funnel path').node().dispatchEvent(event);
assert.isTrue(proxy.calledWith({
index: 0,
node: select('#funnel path').node(),
label: 'Node',
value: 1000,
}));
});
it('should not trigger errors when null', () => {
const event = document.createEvent('CustomEvent');
event.initCustomEvent('click', false, false, null);
getFunnel().draw(getBasicData(), {
events: {
click: {
block: null,
},
},
});
select('#funnel path').node().dispatchEvent(event);
});
it('should set the block style to `cursor: pointer` when non-null', () => {
getFunnel().draw(getBasicData(), {
events: {
click: {
block: () => {
},
},
},
});
assert.equal('pointer', select('#funnel path').style('cursor'));
});
});
});
});
================================================
FILE: test/d3-funnel/Navigator.js
================================================
import { assert } from 'chai';
import Navigator from '../../src/d3-funnel/Navigator.js';
describe('Navigator', () => {
describe('plot', () => {
it('should concatenate a list of path commands together', () => {
const commands = [
['M', 0, 15],
['', 5, 25],
];
assert.equal('M0,15 5,25', (new Navigator()).plot(commands));
});
});
});
================================================
FILE: test/d3-funnel/Utils.js
================================================
import { assert } from 'chai';
import Utils from '../../src/d3-funnel/Utils.js';
describe('Utils', () => {
describe('extend', () => {
it('should override object a with the properties of object b', () => {
const a = {
name: 'Fluoride',
};
const b = {
name: 'Argon',
atomicNumber: 18,
};
assert.deepEqual(b, Utils.extend(a, b));
});
it('should add properties of object b to object a', () => {
const a = {
name: 'Alpha Centauri',
};
const b = {
distanceFromSol: 4.37,
stars: [{
name: 'Alpha Centauri A',
}, {
name: 'Alpha Centauri B',
}, {
name: 'Proxima Centauri',
}],
};
const merged = {
name: 'Alpha Centauri',
distanceFromSol: 4.37,
stars: [{
name: 'Alpha Centauri A',
}, {
name: 'Alpha Centauri B',
}, {
name: 'Proxima Centauri',
}],
};
assert.deepEqual(merged, Utils.extend(a, b));
});
});
describe('convertLegacyBlock', () => {
it('should translate a standard legacy block array into an object', () => {
const block = ['Terran', 200];
const { label, value } = Utils.convertLegacyBlock(block);
assert.deepEqual({ label: 'Terran', value: 200 }, { label, value });
});
it('should translate a formatted value', () => {
const block = ['Terran', [200, 'Two Hundred']];
const { formattedValue } = Utils.convertLegacyBlock(block);
assert.equal('Two Hundred', formattedValue);
});
it('should translate a background color', () => {
const block = ['Terran', 200, '#e5b81f'];
const { backgroundColor } = Utils.convertLegacyBlock(block);
assert.equal('#e5b81f', backgroundColor);
});
it('should translate a label color', () => {
const block = ['Terran', 200, null, '#e5b81f'];
const { labelColor } = Utils.convertLegacyBlock(block);
assert.equal('#e5b81f', labelColor);
});
});
});
================================================
FILE: test/index.html
================================================
Mocha
================================================
FILE: test/index.js
================================================
// Because JSDom does not support SVGs properly this must run in a browser
// https://github.com/jsdom/jsdom/issues/918
import './d3-funnel/Colorizer.js';
import './d3-funnel/D3Funnel.js';
import './d3-funnel/Navigator.js';
import './d3-funnel/Utils.js';
================================================
FILE: test/test.js
================================================
import path from 'node:path';
// Use Firefox because it has the most consumable console pass through
import { firefox } from 'playwright';
const { dirname } = import.meta;
function outputStream(out, stream) {
stream.forEach((message) => {
const formatted = `${message}\n`;
out.write(formatted);
});
}
const stream = [];
(async () => {
const browser = await firefox.launch();
const page = await browser.newPage();
let hasError = false;
// Capture Mocha spec reporter messages
page.on('console', (message) => {
const type = message.type();
const text = message.text();
// Pass message as-is to output stream
stream.push(text);
// Identify failures
if (type === 'error' || text.includes('failing')) {
hasError = true;
}
});
// Identity hard runtime errors
page.on('pageerror', (error) => {
outputStream(process.stderr, [error]);
process.exit(1);
});
// Visit the page for any errors
await page.goto(`file:${path.join(dirname, 'compiled/index.html')}`, { waitUntil: 'networkidle' });
await browser.close();
// Output log stream;
// If the exit is non-zero, all output must go to stderr to be seen in Gulp
if (hasError) {
outputStream(process.stderr, stream);
process.exitCode = 1;
} else {
outputStream(process.stdout, stream);
}
})();
================================================
FILE: webpack.config.examples.js
================================================
import HtmlBundlerPlugin from 'html-bundler-webpack-plugin';
import path from 'node:path';
const { dirname } = import.meta;
export default {
mode: 'development',
entry: {
index: path.join(dirname, 'examples/src/index.js'),
style: path.join(dirname, 'examples/src/scss/style.scss'),
},
output: {
path: path.join(dirname, 'examples/dist'),
library: {
name: 'D3Funnel',
type: 'umd',
},
},
resolve: {
extensions: ['.js'],
alias: {
'd3-funnel': path.resolve(dirname, 'src/index.js'),
},
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /(node_modules)/,
loader: 'babel-loader',
},
{
test: /\.s[ac]ss$/i,
use: [
'css-loader',
'sass-loader',
],
},
],
},
devServer: {
open: true,
static: {
directory: path.join(dirname, 'examples/dist'),
},
watchFiles: ['src/**/*', 'examples/src/**/*'],
},
plugins: [
new HtmlBundlerPlugin({
entry: {
index: 'examples/src/index.html',
},
js: {
filename: '[name].[contenthash:8].js',
},
css: {
filename: '[name].[contenthash:8].css',
},
}),
],
};
================================================
FILE: webpack.config.js
================================================
import path from 'node:path';
import webpack from 'webpack';
import { readFile } from 'node:fs/promises';
const { dirname } = import.meta;
const json = await readFile(new URL('./package.json', import.meta.url));
const pkg = JSON.parse(json.toString());
const banner = `
${pkg.name} - v${pkg.version}
Copyright (c) ${pkg.author}
Licensed under the ${pkg.license} License.
`;
const commonConfig = {
target: 'web',
entry: path.join(dirname, 'src/index.js'),
resolve: {
extensions: ['.js'],
},
module: {
rules: [
{
test: /\.js?$/,
exclude: /(node_modules)/,
loader: 'babel-loader',
},
],
},
externals: {
// Do not compile d3 with the output
// In the browser, this allows window.d3 to be used
// In Node, this will use the included d3 package
d3: 'd3',
},
plugins: [
new webpack.BannerPlugin(banner.trim()),
],
};
const configMap = {
esm: {
...commonConfig,
mode: 'none',
output: {
path: path.join(dirname, '/dist'),
filename: 'index.esm.js',
library: {
type: 'module',
},
},
experiments: {
outputModule: true,
},
},
umd: {
...commonConfig,
mode: 'none',
output: {
path: path.join(dirname, '/dist'),
filename: 'index.cjs.js',
library: {
name: 'D3Funnel',
type: 'umd',
umdNamedDefine: true,
},
},
},
browser: {
...commonConfig,
mode: 'production',
output: {
path: path.join(dirname, '/dist'),
filename: 'd3-funnel.min.js',
library: {
name: 'D3Funnel',
type: 'umd',
umdNamedDefine: true,
},
},
},
};
function makeConfig({ target }) {
return configMap[target];
}
export default makeConfig;
================================================
FILE: webpack.config.test.js
================================================
import HtmlBundlerPlugin from 'html-bundler-webpack-plugin';
import path from 'node:path';
const { dirname } = import.meta;
export default {
mode: 'development',
entry: {
index: path.join(dirname, 'test/index.js'),
},
output: {
path: path.join(dirname, 'test/compiled'),
library: {
name: 'D3Funnel',
type: 'umd',
},
},
resolve: {
extensions: ['.js'],
alias: {
'd3-funnel': path.resolve(dirname, 'src/index.js'),
},
},
module: {
rules: [
{
test: /\.jsx?$/,
exclude: /(node_modules)/,
loader: 'babel-loader',
},
{
test: /\.s[ac]ss$/i,
use: [
'css-loader',
'sass-loader',
],
},
],
},
plugins: [
new HtmlBundlerPlugin({
entry: {
index: 'test/index.html',
},
js: {
filename: '[name].[contenthash:8].js',
},
css: {
filename: '[name].[contenthash:8].css',
},
}),
],
};