Full Code of jakezatecky/d3-funnel for AI

master 1f6bf926b902 cached
36 files
132.9 KB
32.5k tokens
76 symbols
1 requests
Download .txt
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

[![npm](https://img.shields.io/npm/v/d3-funnel.svg?style=flat-square)](https://www.npmjs.com/package/d3-funnel)
[![Build Status](https://img.shields.io/github/actions/workflow/status/jakezatecky/d3-funnel/main.yml?branch=master&style=flat-square)](https://github.com/jakezatecky/d3-funnel/actions/workflows/main.yml)
[![GitHub license](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](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
<div id="funnel"></div>

<script>
    const data = [
        { label: 'Inquiries', value: 5000 },
        { label: 'Applicants', value: 2500 },
        { label: 'Admits', value: 500 },
        { label: 'Deposits', value: 200 },
    ];
    const options = {
        block: {
            dynamicHeight: true,
            minHeight: 15,
        },
    };

    const chart = new D3Funnel('#funnel');
    chart.draw(data, options);
</script>
```

## 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
================================================
<!DOCTYPE html>
<html lang="en-US">
  <head>
    <meta charset="UTF-8">
    <title>d3-funnel</title>
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <meta name="description" content="d3-funnel: A JavaScript SVG library for rendering funnel, pipeline, and pyramid charts using the D3.js framework.">
    <meta name="theme-color" content="#3498db">
    <link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/normalize/5.0.0/normalize.min.css">
    <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Open+Sans:400,700">
    <link rel="stylesheet" href="//fonts.googleapis.com/css?family=Reem+Kufi">
    <link rel="stylesheet" href="./scss/style.scss">

    <!-- Google tag (gtag.js) -->
    <script async src="https://www.googletagmanager.com/gtag/js?id=G-RMNRQBDL6G"></script>
    <script>
        window.dataLayer = window.dataLayer || [];
        function gtag(){dataLayer.push(arguments);}
        gtag('js', new Date());

        gtag('config', 'G-RMNRQBDL6G');
    </script>
  </head>
  <body>
    <section class="page-header">
      <h1 class="project-name">d3-funnel</h1>
      <h2 class="project-tagline">A JavaScript library for rendering funnel charts using the D3.js framework.</h2>
      <a href="https://github.com/jakezatecky/d3-funnel" class="btn">View on GitHub</a>
      <a href="https://github.com/jakezatecky/d3-funnel/zipball/master" class="btn">Download .zip</a>
      <a href="https://github.com/jakezatecky/d3-funnel/tarball/master" class="btn">Download .tar.gz</a>
    </section>

    <section class="main-content">
      <p>
        <strong>d3-funnel</strong> is an extensible, open-source JavaScript library for rendering
        funnel charts using the <a href="https://d3js.org/">D3.js</a> library.
      </p>
      <p>
        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.
      </p>
      <div class="demo">
        <div class="demo-funnel" id="funnel"></div>
        <div class="demo-options">
          <form>
            <label>
              <input type="checkbox" value="curved">
              Curved
            </label>
            <label>
              <input type="checkbox" value="pinched" checked>
              Pinched
            </label>
            <label>
              <input type="checkbox" value="gradient">
              Gradient
            </label>
            <label>
              <input type="checkbox" value="inverted">
              Inverted
            </label>
            <label>
              <input type="checkbox" value="hover">
              Highlight on Hover
            </label>
            <label>
              <input type="checkbox" value="tooltip">
              Tooltips
            </label>
            <label>
              <input type="checkbox" value="click">
              Click Event
            </label>
            <label>
              <input type="checkbox" value="dynamicHeight" checked>
              Dynamic Height
            </label>
            <label>
              <input type="checkbox" value="barOverlay">
              Bar Overlay
            </label>
            <label>
              <input type="checkbox" value="animation">
              Load Animation
            </label>
            <label>
              <input type="checkbox" value="label">
              Style Labels
            </label>
            <label>
              <input type="checkbox" value="color">
              Custom Color
            </label>
          </form>
        </div>
      </div>

      <footer class="site-footer">
        <span class="site-footer-owner">
          <a href="https://github.com/jakezatecky/d3-funnel">d3-funnel</a> is maintained by <a href="https://github.com/jakezatecky">jakezatecky</a>.
        </span>
        <span class="site-footer-credits">
          This page is hosted by <a href="https://pages.github.com">GitHub Pages</a>.
        </span>
      </footer>
    </section>

    <script src="./js/index.js"></script>
  </body>
</html>


================================================
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 <tspan> 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
================================================
<!DOCTYPE html>
<html lang="en">
  <head>
    <title>Mocha</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
  </head>
  <body>
    <div id="funnel"></div>
    <div id="sandbox"></div>
    <div id="mocha"></div>

    <script src="../node_modules/mocha/mocha.js"></script>
    <script>
        mocha.ui('bdd');
        mocha.reporter('spec');
        mocha.color(true);
    </script>
    <script src="./index.js"></script>
    <script>
        mocha.run();
    </script>
  </body>
</html>


================================================
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',
            },
        }),
    ],
};
Download .txt
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
Download .txt
SYMBOL INDEX (76 symbols across 9 files)

FILE: examples/src/js/index.js
  method block (line 42) | block(event, d) {
  function onChange (line 76) | function onChange() {

FILE: src/d3-funnel/Colorizer.js
  class Colorizer (line 1) | class Colorizer {
    method constructor (line 5) | constructor() {
    method setInstanceId (line 17) | setInstanceId(instanceId) {
    method setLabelFill (line 26) | setLabelFill(fill) {
    method setScale (line 35) | setScale(scale) {
    method getBlockFill (line 48) | getBlockFill(fill, index, fillType) {
    method getBlockRawFill (line 65) | getBlockRawFill(fill, index) {
    method getBlockActualFill (line 89) | getBlockActualFill(raw, index, fillType) {
    method getGradientId (line 104) | getGradientId(index) {
    method getLabelColor (line 115) | getLabelColor(labelFill) {
    method shade (line 127) | shade(color, shade) {
    method hexToRgb (line 147) | hexToRgb(color) {
    method expandHex (line 172) | expandHex(hex) {

FILE: src/d3-funnel/D3Funnel.js
  class D3Funnel (line 14) | class D3Funnel {
    method constructor (line 65) | constructor(selector) {
    method destroy (line 84) | destroy() {
    method draw (line 109) | draw(data, options = {}) {
    method initialize (line 125) | initialize(data, options) {
    method validateData (line 172) | validateData(data) {
    method getSettings (line 200) | getSettings(options) {
    method getDefaultSettings (line 226) | getDefaultSettings(containerDimensions) {
    method getContainerDimensions (line 243) | getContainerDimensions() {
    method castDimensions (line 267) | castDimensions({ chart }, containerDimensions) {
    method setBlocks (line 296) | setBlocks(data) {
    method getTotalCount (line 309) | getTotalCount(data) {
    method standardizeData (line 325) | standardizeData(data, totalCount) {
    method drawOntoDom (line 359) | drawOntoDom() {
    method makePaths (line 389) | makePaths() {
    method getDx (line 618) | getDx(bottomLeftX) {
    method getDy (line 630) | getDy() {
    method defineColorGradients (line 646) | defineColorGradients(svg) {
    method drawTopOval (line 683) | drawTopOval(svg, blockPaths) {
    method drawBlock (line 712) | drawBlock(index) {
    method getBlockPath (line 860) | getBlockPath(group, index) {
    method getOverlayPath (line 876) | getOverlayPath(group, index) {
    method addBeforeTransition (line 895) | addBeforeTransition(path, index, isOverlay) {
    method attachData (line 943) | attachData(element, data) {
    method getPathDefinition (line 957) | getPathDefinition(index) {
    method getOverlayPathDefinition (line 972) | getOverlayPathDefinition(index) {
    method onMouseOver (line 988) | onMouseOver(event, data) {
    method onMouseOut (line 1011) | onMouseOut(event, data) {
    method addBlockLabel (line 1035) | addBlockLabel(group, index) {
    method addLabelLines (line 1071) | addLabelLines(text, formattedLabel, x) {
    method getTextY (line 1095) | getTextY(paths) {

FILE: src/d3-funnel/Formatter.js
  class Formatter (line 1) | class Formatter {
    method getFormatter (line 9) | getFormatter(format) {
    method format (line 29) | format({ label, value, formattedValue = null }, formatter) {
    method stringFormatter (line 47) | stringFormatter(label, value, formattedValue, expression) {
    method getDefaultFormattedValue (line 70) | getDefaultFormattedValue(value) {

FILE: src/d3-funnel/Navigator.js
  class Navigator (line 1) | class Navigator {
    method plot (line 9) | plot(commands) {
    method makeCurvedPaths (line 25) | makeCurvedPaths(dimensions, isValueOverlay = false) {
    method makeBezierPoints (line 47) | makeBezierPoints({
    method makeBezierPath (line 97) | makeBezierPath({
    method getQuadraticBezierCurve (line 132) | getQuadraticBezierCurve(p0, p1, p2, t = 1) {
    method getLinearInterpolation (line 166) | getLinearInterpolation(p0, p1, t, axis) {
    method getQuadraticInterpolation (line 179) | getQuadraticInterpolation(p0, p1, p2, t, axis) {
    method makeStraightPaths (line 197) | makeStraightPaths({

FILE: src/d3-funnel/Utils.js
  class Utils (line 1) | class Utils {
    method isExtendableObject (line 9) | static isExtendableObject(a) {
    method extend (line 21) | static extend(a, b) {
    method convertLegacyBlock (line 52) | static convertLegacyBlock(block) {
    method getRawBlockCount (line 69) | static getRawBlockCount(block) {

FILE: test/d3-funnel/D3Funnel.js
  function getFunnel (line 14) | function getFunnel() {
  function getSvg (line 18) | function getSvg() {
  function getSvgId (line 22) | function getSvgId() {
  function getBasicData (line 26) | function getBasicData() {
  function isLetter (line 30) | function isLetter(str) {
  function getCommandPoint (line 34) | function getCommandPoint(command) {
  function getPathTopWidth (line 51) | function getPathTopWidth(path) {
  function getPathBottomWidth (line 57) | function getPathBottomWidth(path) {
  function getPathHeight (line 63) | function getPathHeight(path) {

FILE: test/test.js
  function outputStream (line 7) | function outputStream(out, stream) {

FILE: webpack.config.js
  function makeConfig (line 83) | function makeConfig({ target }) {
Condensed preview — 36 files, each showing path, character count, and a content snippet. Download the .json file or copy for the full structured content (143K chars).
[
  {
    "path": ".babelrc",
    "chars": 71,
    "preview": "{\n  \"presets\": [\n    [\"@babel/preset-env\", { \"modules\": false }]\n  ]\n}\n"
  },
  {
    "path": ".browserslistrc",
    "chars": 9,
    "preview": "defaults\n"
  },
  {
    "path": ".editorconfig",
    "chars": 172,
    "preview": "root = true\n\n[*]\nindent_style = space\nindent_size = 2\nend_of_line = lf\ncharset = utf-8\ntrim_trailing_whitespace = true\ni"
  },
  {
    "path": ".gitattributes",
    "chars": 92,
    "preview": "d3* linguist-vendored=false\nexamples/* linguist-vendored=true\ntest/* linguist-vendored=true\n"
  },
  {
    "path": ".github/dependabot.yml",
    "chars": 222,
    "preview": "version: 2\nupdates:\n- package-ecosystem: npm\n  directory: \"/\"\n  schedule:\n    interval: daily\n    time: \"10:00\"\n  open-p"
  },
  {
    "path": ".github/workflows/main.yml",
    "chars": 567,
    "preview": "name: Build\non: push\njobs:\n  build-and-test:\n    runs-on: ubuntu-latest\n    strategy:\n      matrix:\n        node: [20, 2"
  },
  {
    "path": ".gitignore",
    "chars": 84,
    "preview": "/dist\r\n/examples/dist\r\n/node_modules\r\n/test/compiled\r\npackage-lock.json\r\nyarn.lock\r\n"
  },
  {
    "path": ".mocharc.json",
    "chars": 96,
    "preview": "{\n  \"require\": [\n    \"@babel/register\",\n    \"global-jsdom/register\"\n  ],\n  \"reporter\": \"spec\"\n}\n"
  },
  {
    "path": ".stylelintrc.yml",
    "chars": 38,
    "preview": "extends:\n  - stylelint-config-takiyon\n"
  },
  {
    "path": "CHANGELOG.md",
    "chars": 8232,
    "preview": "## [v2.1.3](https://github.com/jakezatecky/d3-funnel/compare/v2.1.2...v2.1.3) (2025-09-11)\n\n### Bug Fixes\n\n* Fix issue w"
  },
  {
    "path": "LICENSE.txt",
    "chars": 1079,
    "preview": "The MIT License (MIT)\n\nCopyright (c) 2017 Jake Zatecky\n\nPermission is hereby granted, free of charge, to any person obta"
  },
  {
    "path": "README.md",
    "chars": 8886,
    "preview": "# d3-funnel\n\n[![npm](https://img.shields.io/npm/v/d3-funnel.svg?style=flat-square)](https://www.npmjs.com/package/d3-fun"
  },
  {
    "path": "eslint.config.js",
    "chars": 1242,
    "preview": "import takiyonConfig from 'eslint-config-takiyon';\nimport globals from 'globals';\n\nimport webpackConfig from './webpack."
  },
  {
    "path": "examples/src/index.html",
    "chars": 4153,
    "preview": "<!DOCTYPE html>\n<html lang=\"en-US\">\n  <head>\n    <meta charset=\"UTF-8\">\n    <title>d3-funnel</title>\n    <meta name=\"vie"
  },
  {
    "path": "examples/src/js/index.js",
    "chars": 2761,
    "preview": "import { merge } from 'lodash';\nimport D3Funnel from 'd3-funnel';\n\nconst settings = {\n    curved: {\n        chart: {\n   "
  },
  {
    "path": "examples/src/scss/_cayman.scss",
    "chars": 6144,
    "preview": "// Breakpoints\n$large-breakpoint: 64em;\n$medium-breakpoint: 42em;\n\n// Headers\n$header-heading-color: #fff !default;\n$hea"
  },
  {
    "path": "examples/src/scss/style.scss",
    "chars": 582,
    "preview": "@use \"cayman\" with (\n  $body-text-color: #444,\n  $code-text-color: #465e6a,\n  $header-bg-color: #3498db,\n  $header-bg-co"
  },
  {
    "path": "gh-deploy.sh",
    "chars": 461,
    "preview": "#!/bin/bash\nVERSION=\"$(cat ./package.json | python -c \"import sys, json; print(json.load(sys.stdin)['version'])\")\"\ngit d"
  },
  {
    "path": "index.d.ts",
    "chars": 27,
    "preview": "declare module 'd3-funnel'\n"
  },
  {
    "path": "package.json",
    "chars": 3007,
    "preview": "{\n  \"name\": \"d3-funnel\",\n  \"version\": \"2.1.3\",\n  \"description\": \"A library for rendering SVG funnel charts using D3.js\","
  },
  {
    "path": "src/d3-funnel/Colorizer.js",
    "chars": 3957,
    "preview": "class Colorizer {\n    /**\n     * @return {void}\n     */\n    constructor() {\n        this.hexExpression = /^#([0-9a-f]{3}"
  },
  {
    "path": "src/d3-funnel/D3Funnel.js",
    "chars": 34622,
    "preview": "import { easeLinear } from 'd3-ease';\nimport { range } from 'd3-array';\nimport { scaleOrdinal } from 'd3-scale';\nimport "
  },
  {
    "path": "src/d3-funnel/Formatter.js",
    "chars": 1792,
    "preview": "class Formatter {\n    /**\n     * Register the format function.\n     *\n     * @param {string|function} format\n     *\n    "
  },
  {
    "path": "src/d3-funnel/Navigator.js",
    "chars": 6504,
    "preview": "class Navigator {\n    /**\n     * Given a list of path commands, returns the compiled description.\n     *\n     * @param {"
  },
  {
    "path": "src/d3-funnel/Utils.js",
    "chars": 1963,
    "preview": "class Utils {\n    /**\n     * Determine whether the given parameter is an extendable object.\n     *\n     * @param {*} a\n "
  },
  {
    "path": "src/index.js",
    "chars": 66,
    "preview": "import D3Funnel from '#js/D3Funnel.js';\n\nexport default D3Funnel;\n"
  },
  {
    "path": "test/d3-funnel/Colorizer.js",
    "chars": 1435,
    "preview": "import { assert } from 'chai';\n\nimport Colorizer from '../../src/d3-funnel/Colorizer.js';\n\ndescribe('Colorizer', () => {"
  },
  {
    "path": "test/d3-funnel/D3Funnel.js",
    "chars": 37793,
    "preview": "import { cloneDeep } from 'lodash';\nimport {\n    range,\n    select,\n    selectAll,\n    scaleOrdinal,\n    schemeCategory1"
  },
  {
    "path": "test/d3-funnel/Navigator.js",
    "chars": 428,
    "preview": "import { assert } from 'chai';\n\nimport Navigator from '../../src/d3-funnel/Navigator.js';\n\ndescribe('Navigator', () => {"
  },
  {
    "path": "test/d3-funnel/Utils.js",
    "chars": 2449,
    "preview": "import { assert } from 'chai';\n\nimport Utils from '../../src/d3-funnel/Utils.js';\n\ndescribe('Utils', () => {\n    describ"
  },
  {
    "path": "test/index.html",
    "chars": 593,
    "preview": "<!DOCTYPE html>\n<html lang=\"en\">\n  <head>\n    <title>Mocha</title>\n    <meta http-equiv=\"Content-Type\" content=\"text/htm"
  },
  {
    "path": "test/index.js",
    "chars": 256,
    "preview": "// Because JSDom does not support SVGs properly this must run in a browser\n// https://github.com/jsdom/jsdom/issues/918\n"
  },
  {
    "path": "test/test.js",
    "chars": 1442,
    "preview": "import path from 'node:path';\n// Use Firefox because it has the most consumable console pass through\nimport { firefox } "
  },
  {
    "path": "webpack.config.examples.js",
    "chars": 1507,
    "preview": "import HtmlBundlerPlugin from 'html-bundler-webpack-plugin';\nimport path from 'node:path';\n\nconst { dirname } = import.m"
  },
  {
    "path": "webpack.config.js",
    "chars": 2072,
    "preview": "import path from 'node:path';\nimport webpack from 'webpack';\nimport { readFile } from 'node:fs/promises';\n\nconst { dirna"
  },
  {
    "path": "webpack.config.test.js",
    "chars": 1236,
    "preview": "import HtmlBundlerPlugin from 'html-bundler-webpack-plugin';\nimport path from 'node:path';\n\nconst { dirname } = import.m"
  }
]

About this extraction

This page contains the full source code of the jakezatecky/d3-funnel GitHub repository, extracted and formatted as plain text for AI agents and large language models (LLMs). The extraction includes 36 files (132.9 KB), approximately 32.5k tokens, and a symbol index with 76 extracted functions, classes, methods, constants, and types. Use this with OpenClaw, Claude, ChatGPT, Cursor, Windsurf, or any other AI tool that accepts text input. You can copy the full output to your clipboard or download it as a .txt file.

Extracted by GitExtract — free GitHub repo to text converter for AI. Built by Nikandr Surkov.

Copied to clipboard!