├── .eslintrc
├── .github
├── ISSUE_TEMPLATE
│ └── issue.yml
└── workflows
│ └── ci.yml
├── .gitignore
├── .markdownlint.json
├── CHANGELOG.md
├── CONTRIBUTING.md
├── LICENSE.md
├── README.md
├── benchmarks
└── benchmarks.js
├── commitlint.config.cjs
├── fixtures
├── categorical-bar.js
├── circular.js
├── dot-plot.js
├── grouped-bar.js
├── line.js
├── multiline.js
├── rules.js
├── scatter-plot.js
├── single-bar.js
├── stacked-area.js
├── stacked-bar.js
└── temporal-bar.js
├── index.d.ts
├── package.json
├── source
├── accessors.js
├── audio.js
├── axes.js
├── chart.js
├── color.js
├── config.js
├── data.js
├── defs.js
├── descriptions.js
├── dimensions.js
├── download.js
├── encodings.js
├── expression.js
├── extensions.js
├── feature.js
├── fetch.js
├── format.js
├── gradient.js
├── helpers.js
├── index.css
├── interactions.js
├── keyboard.js
├── legend.js
├── lifecycle.js
├── marks.js
├── markup.js
├── memoize.js
├── menu.js
├── metadata.js
├── position.js
├── predicate.js
├── scales.js
├── sort.js
├── state.js
├── styles.js
├── table.js
├── text.js
├── time.js
├── tooltips.js
├── transform.js
├── types.d.js
├── values.js
└── views.js
├── stylelint.config.cjs
├── tests
├── browser-shim.cjs
├── index.mustache
├── integration
│ ├── aria-test.js
│ ├── axes-test.js
│ ├── categorical-bar-test.js
│ ├── circular-test.js
│ ├── description-test.js
│ ├── dimensions-test.js
│ ├── download-test.js
│ ├── keyboard-test.js
│ ├── legend-test.js
│ ├── line-test.js
│ ├── menu-test.js
│ ├── pivot-test.js
│ ├── rules-test.js
│ ├── single-bar-test.js
│ ├── size-test.js
│ ├── sort-test.js
│ ├── stacked-area-test.js
│ ├── stacked-bar-test.js
│ ├── table-test.js
│ ├── text-test.js
│ ├── time-series-bar-test.js
│ ├── tooltip-test.js
│ └── views-test.js
├── test-helpers.js
├── testem.cjs
└── unit
│ ├── axes-test.js
│ ├── color-test.js
│ ├── data-test.js
│ ├── description-test.js
│ ├── encodings-test.js
│ ├── error-test.js
│ ├── expression-test.js
│ ├── feature-test.js
│ ├── format-test.js
│ ├── helpers-test.js
│ ├── interactions-test.js
│ ├── internals-test.js
│ ├── marks-test.js
│ ├── memoize-test.js
│ ├── metadata-test.js
│ ├── mutation-test.js
│ ├── position-test.js
│ ├── predicate-test.js
│ ├── scales-test.js
│ ├── sort-test.js
│ ├── stack-test.js
│ ├── state-test.js
│ ├── support.js
│ ├── timestamp-test.js
│ ├── tooltip-test.js
│ ├── transform-test.js
│ ├── values-test.js
│ └── views-test.js
└── yarn.lock
/.eslintrc:
--------------------------------------------------------------------------------
1 | {
2 | "extends": ["standard", "plugin:jsdoc/recommended-error"],
3 | "parserOptions": {
4 | "ecmaVersion": 2022,
5 | "sourceType": "module"
6 | },
7 | "plugins": [
8 | "jsdoc"
9 | ],
10 | "env": {
11 | "browser": true,
12 | "es6": true
13 | },
14 | "rules": {
15 | "array-callback-return": "off",
16 | "prefer-const": "off",
17 | "space-before-function-paren": ["error", "never"],
18 | "quote-props": "off",
19 | "no-useless-return": "off",
20 | "object-curly-newline": "off",
21 | "indent": ["error", "tab"],
22 | "no-tabs": ["error", { "allowIndentationTabs": true} ],
23 | "no-mixed-spaces-and-tabs": [
24 | "error",
25 | "smart-tabs"
26 | ],
27 | "arrow-parens": ["error", "as-needed"],
28 | "no-return-assign": "off",
29 | "no-console": ["error", { "allow": ["warn", "error"] }],
30 | "no-unused-vars": ["error", {"destructuredArrayIgnorePattern": "^_"}],
31 | "no-prototype-builtins": "off",
32 | "jsdoc/require-returns": "off",
33 | "jsdoc/require-returns-check": "off",
34 | "jsdoc/require-returns-description": "off",
35 | "jsdoc/check-types": "off",
36 | "jsdoc/valid-types": "off",
37 | "jsdoc/no-undefined-types": "off"
38 | },
39 | "settings": {
40 | "jsdoc": {
41 | "mode": "permissive"
42 | }
43 | }
44 | }
45 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/issue.yml:
--------------------------------------------------------------------------------
1 | name: Issue
2 | description: Report a new issue.
3 | assignees:
4 | - vijithassar
5 | body:
6 | - type: textarea
7 | id: description
8 | attributes:
9 | label: Description
10 | description: Explain the problem.
11 | - type: textarea
12 | id: specification
13 | attributes:
14 | label: Vega Lite specification
15 | description: Include a JSON object that can be used to replicate the problem.
16 | render: json
17 | - type: input
18 | id: documentation
19 | attributes:
20 | label: Documentation
21 | description: Link to relevant Vega Lite documentation.
22 | - type: checkboxes
23 | id: terms
24 | attributes:
25 | label: Existing issues
26 | options:
27 | - label: I have searched existing issues and this has not yet been reported.
28 | required: true
--------------------------------------------------------------------------------
/.github/workflows/ci.yml:
--------------------------------------------------------------------------------
1 | name: ci
2 | on: [push, pull_request]
3 | jobs:
4 | check-dependencies:
5 | runs-on: ubuntu-latest
6 | timeout-minutes: 5
7 | steps:
8 | - uses: actions/checkout@v3
9 | - uses: actions/setup-node@v3
10 | with:
11 | node-version: latest
12 | - run: yarn && yarn check && yarn check --integrity && yarn check --verify-tree
13 | check-build:
14 | runs-on: ubuntu-latest
15 | timeout-minutes: 5
16 | steps:
17 | - uses: actions/checkout@v3
18 | - uses: actions/setup-node@v3
19 | with:
20 | node-version: latest
21 | - run: yarn && yarn run build
22 | lint-js:
23 | runs-on: ubuntu-latest
24 | timeout-minutes: 5
25 | steps:
26 | - uses: actions/checkout@v3
27 | - uses: actions/setup-node@v3
28 | with:
29 | node-version: latest
30 | - run: yarn && yarn run lint-js
31 | lint-styles:
32 | runs-on: ubuntu-latest
33 | timeout-minutes: 5
34 | steps:
35 | - uses: actions/checkout@v3
36 | - uses: actions/setup-node@v3
37 | with:
38 | node-version: latest
39 | - run: yarn && yarn run lint-styles
40 | lint-markdown:
41 | runs-on: ubuntu-latest
42 | timeout-minutes: 5
43 | steps:
44 | - uses: actions/checkout@v3
45 | - uses: actions/setup-node@v3
46 | with:
47 | node-version: latest
48 | - run: yarn && yarn run lint-markdown
49 | test:
50 | runs-on: ubuntu-latest
51 | timeout-minutes: 5
52 | steps:
53 | - uses: actions/checkout@v3
54 | - uses: actions/setup-node@v3
55 | with:
56 | node-version: latest
57 | - run: yarn && yarn run test
58 | lint-git:
59 | runs-on: ubuntu-latest
60 | timeout-minutes: 5
61 | steps:
62 | - uses: actions/checkout@v3
63 | with:
64 | fetch-depth: 0
65 | - uses: actions/setup-node@v3
66 | with:
67 | node-version: latest
68 | - run: yarn && yarn run lint-git
69 | types:
70 | runs-on: ubuntu-latest
71 | timeout-minutes: 5
72 | steps:
73 | - uses: actions/checkout@v3
74 | - uses: actions/setup-node@v3
75 | with:
76 | node-version: latest
77 | - run: yarn && yarn run types
78 | build:
79 | runs-on: ubuntu-latest
80 | timeout-minutes: 5
81 | steps:
82 | - uses: actions/checkout@v3
83 | - uses: actions/setup-node@v3
84 | with:
85 | node-version: latest
86 | - run: yarn && yarn run build
87 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | node_modules
3 | yarn-error.json
4 | yarn-error.log
5 | build
6 | coverage
7 | documentation
--------------------------------------------------------------------------------
/.markdownlint.json:
--------------------------------------------------------------------------------
1 | {
2 | "default": true,
3 | "line-length": false,
4 | "single-h1": false
5 | }
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # API
2 |
3 | bisonica follows the API for [Vega Lite](https://vega.github.io/vega-lite/). Not all Vega Lite features are implemented. If there's one you like, please feel free to add it!
4 |
5 | # Documentation
6 |
7 | Internals are thoroughly documented with [JSDoc](https://jsdoc.app/). To build the documentation locally, run `yarn run documentation` and then view the generated HTML files in your web browser.
8 |
9 | # Architecture
10 |
11 | Library architecture relies on the principles outlined by the [Grammar of Graphics](https://link.springer.com/book/10.1007/0-387-28695-0).
12 |
13 | # Internals
14 |
15 | ## Visual Attributes
16 |
17 | Visual attributes like color, shape, and position should always be controlled by `encodings.js`, which can itself be thought of as a composition of `accessors.js` and `scales.js` which first looks up the data value and then passes it to [`d3-scale`](https://github.com/d3/d3-scale).
18 |
19 | ## Conditionals
20 |
21 | For important conditional logic it's often useful to add a new method to `feature.js` which can then be used in place of JSON lookups. In particular, this helps make sure the conditional is properly checked when specifications use [view composition](https://vega.github.io/vega-lite/docs/composition.html).
22 |
--------------------------------------------------------------------------------
/LICENSE.md:
--------------------------------------------------------------------------------
1 | The MIT License (MIT)
2 |
3 | Copyright (c) 2020
4 |
5 | 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:
6 |
7 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8 |
9 | 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.
10 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Overview
2 |
3 | bisonica is a minimalist and accessibility-first alternative renderer for the open source [Vega Lite](https://vega.github.io/vega-lite/) standard for data visualization, a replacement for the [original `vega-lite.js` library](https://github.com/vega/vega-lite) which reads in the same [JSON configuration objects](https://vega.github.io/vega-lite/docs/) and outputs charts.
4 |
5 | # Install
6 |
7 | Install from [npm](https://www.npmjs.com/package/bisonica) using your tool of choice. For example:
8 |
9 | ```bash
10 | npm install bisonica
11 | ```
12 |
13 | # Quick Start
14 |
15 | The `chart()` function takes a Vega Lite JSON specification object and uses it to create a chart rendering function which can be run using [`d3-selection`](https://github.com/d3/d3-selection).
16 |
17 | ```javascript
18 | import { select } from 'd3';
19 | import { chart } from 'bisonica';
20 |
21 | const dimensions = {
22 | x: 500,
23 | y: 500
24 | };
25 |
26 | // create a chart rendering function
27 | const renderer = chart(specification, dimensions);
28 |
29 | // render the chart
30 | renderer(select('div'));
31 | ```
32 |
33 | Or the equivalent syntax with `selection.call()`:
34 |
35 | ```javascript
36 | // render the chart
37 | select('div').call(renderer);
38 | ```
39 |
40 | You'll probably want to load the included stylesheet? Feel free to use your own alternative if you want, though.
41 |
42 | You can load it directly:
43 |
44 | ```html
45 |
46 |
47 |
48 | ```
49 |
50 | Or import with a packager or build tool:
51 |
52 | ```javascript
53 | import "bisonica/styles.css";
54 | ```
55 |
56 | # Why?
57 |
58 | ## Accessibility
59 |
60 | When faced with an accessibility concern, bisonica typically just defaults to the most accessible option, whereas `vega-lite.js` might require more elaborate JSON in the specification object in order to achieve the same result. Some accessibility features such as the keyboard navigation and audio sonification cannot be replicated with the standard `vega-lite.js` renderer at all.
61 |
62 | ## Performance
63 |
64 | bisonica is often considerably faster than rendering the same chart using `vega-lite.js`. This will depend on the specific chart configuration and the input data, but as an example, pie charts have been clocked rendering in as little as 1.25 milliseconds.
65 |
66 | ## Customization
67 |
68 | Unlike `vega-lite.js`, bisonica renders legends as HTML next to the SVG instead of inside the SVG, and as a result they are much easier to restyle with CSS or even control with custom JavaScript behaviors.
69 |
70 | # Comparison
71 |
72 | In general, bisonica may be a good choice if you need to render straightforward charts with strong accessibility properties using Vega Lite's JSON as the backing format and you can handle writing the custom CSS that will probably be necessary to get the generated graphics over the finish line for your use case.
73 |
74 | On the other hand, the standard `vega-lite.js` renderer is definitely still the way to go if you need its more elaborate graphical options, [faceted trellis plots](https://vega.github.io/vega-lite/docs/facet.html), charts which don't rely on custom styling, a dynamic exploratory workflow powered by [evaluating string expressions](https://github.com/vega/vega-expression), or any of the features bisonica has intentionally omitted.
75 |
76 | ## Omissions
77 |
78 | bisonica is still a work in progress and as such supports only a subset of Vega Lite functionality. The supported chart forms are listed in [`marks.js`](./source/marks.js).
79 |
80 | Data loading will not [parse inline strings](https://vega.github.io/vega-lite/docs/data.html#inline).
81 |
82 | Nested fields must be looked up using dot notation (e.g. `datum.field`), not bracket notation (e.g. `datum['field']`).
83 |
84 | [Predicates](https://vega.github.io/vega-lite/docs/predicate.html) defined with string expressions only support simple comparisons (e.g. `"datum.value < 100"` or `"datum.group === 'a'"`).
85 |
86 | The [calculate transform](https://vega.github.io/vega-lite/docs/calculate.html) only supports deriving new fields with string concatenation and static functions but can't do arbitrary math. (If you need arbitrary math, do it in JavaScript and attach the results to your specification before rendering.)
87 |
88 | Escaping special characters in field names is not supported. Instead, you should mutate your data before rendering to clean up the affected field names.
89 |
90 | Advanced Vega Lite features like [`facet`](https://vega.github.io/vega-lite/docs/composition.html#faceting) and [`parameters`](https://vega.github.io/vega-lite/docs/parameter.html) are not yet available.
91 |
92 | Rendering to alternative output formats such as `` instead of `` will most likely never be supported.
93 |
94 | # Errors
95 |
96 | ## Throwing
97 |
98 | Errors inside the chart library are nested, and error handling typically appends information to `error.message` and then re-throws the same error object. This naturally has the effect of augmenting the messages generated by the runtime with written explanations meant for humans at every layer of the stack, sort of a "semantic stack trace."
99 |
100 | ## Catching
101 |
102 | To set a custom error handler, pass a function to the `.error()` method.
103 |
104 | ```javascript
105 | // create a chart renderer
106 | const renderer = chart(specification, dimensions)
107 |
108 | // error handling function
109 | const errorHandler = (error) => alert(error.message);
110 |
111 | // attach the error handling function to the chart renderer
112 | renderer.error(errorHandler);
113 | ```
114 |
115 | By default, errors are handled at the top level with `console.error`, equivalent to `renderer.error(console.error)`.
116 |
117 | ## Disabling
118 |
119 | To disable default trapping of errors and instead surface the semantic stack traces to the consumer:
120 |
121 | ```javascript
122 | // disable catching and allow errors
123 | // to propagate up to the caller
124 | renderer.error(null);
125 | ```
126 |
127 | You'll then want to handle these in your page or application.
128 |
--------------------------------------------------------------------------------
/benchmarks/benchmarks.js:
--------------------------------------------------------------------------------
1 | import bench from 'nanobench';
2 | import { specificationFixture, render } from '../tests/test-helpers.js';
3 |
4 | const fixtures = [
5 | 'categoricalBar',
6 | 'circular',
7 | 'line',
8 | 'multiline',
9 | 'scatterPlot',
10 | 'stackedArea',
11 | 'stackedBar',
12 | 'temporalBar',
13 | ];
14 |
15 | const count = 100;
16 |
17 | const bar = (time) => {
18 | const step = 50; // milliseconds per unit
19 | const units = time / step;
20 | const bar = Array.from({ length: units }).fill('■').join('');
21 | return bar;
22 | };
23 |
24 | fixtures.forEach(fixture => {
25 | const specification = specificationFixture(fixture);
26 | bench(`${fixture} × ${count}`, (b) => {
27 | b.start();
28 | for (let i = 0; i < count; i++) {
29 | render(specification)
30 | }
31 | b.log(bar(b.elapsed()));
32 | b.end();
33 | });
34 | });
35 |
--------------------------------------------------------------------------------
/commitlint.config.cjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | extends: ['@commitlint/config-conventional'],
3 | "rules": {
4 | 'body-case': [0],
5 | 'body-max-line-length': [0],
6 | 'footer-empty': [0],
7 | 'footer-max-line-length': [0],
8 | 'references-empty': [0],
9 | 'signed-off-by': [0],
10 | 'scope-case': [2, 'always', 'lower-case'],
11 | 'scope-enum': [2, 'always', ['core', 'tooling', 'tests', 'docs']],
12 | 'type-enum': [2, 'always', ['chore', 'fix', 'feat']]
13 | },
14 | };
15 |
16 | module.exports = config;
17 |
--------------------------------------------------------------------------------
/fixtures/categorical-bar.js:
--------------------------------------------------------------------------------
1 | const categoricalBarChartSpec = {
2 | $schema: 'https://vega.github.io/schema/vega-lite/v4.json',
3 | title: { text: 'Categorical Bar Chart' },
4 | data: {
5 | values: [
6 | { animal: 'rabbit', value: 31 },
7 | { animal: 'cow', value: 25 },
8 | { animal: 'snake', value: 25 },
9 | { animal: 'elephant', value: 25 },
10 | { animal: 'mouse', value: 24 }
11 | ]
12 | },
13 | mark: { type: 'bar', tooltip: true },
14 | encoding: {
15 | x: { field: 'animal', type: 'nominal' },
16 | y: { title: 'count', field: 'value', type: 'quantitative' }
17 | }
18 | }
19 |
20 | export { categoricalBarChartSpec }
21 |
--------------------------------------------------------------------------------
/fixtures/circular.js:
--------------------------------------------------------------------------------
1 | const circularChartSpec = {
2 | $schema: 'https://vega.github.io/schema/vega-lite/v4.json',
3 | title: {
4 | text: 'circular chart example'
5 | },
6 | description: 'A simple circular pie chart.',
7 | mark: { type: 'arc', tooltip: true },
8 | encoding: {
9 | theta: { field: 'value', type: 'quantitative' },
10 | color: {
11 | field: 'group',
12 | type: 'nominal'
13 | }
14 | },
15 | data: {
16 | values: [
17 | {
18 | group: 'A',
19 | value: 8
20 | },
21 | {
22 | group: 'B',
23 | value: 4
24 | },
25 | {
26 | group: 'C',
27 | value: 2
28 | },
29 | {
30 | group: 'D',
31 | value: 2
32 | },
33 | {
34 | group: 'E',
35 | value: 1
36 | },
37 | {
38 | group: 'F',
39 | value: 4
40 | },
41 | {
42 | group: 'G',
43 | value: 14
44 | },
45 | {
46 | group: 'H',
47 | value: 2
48 | }
49 | ]
50 | }
51 | }
52 |
53 | export { circularChartSpec }
54 |
--------------------------------------------------------------------------------
/fixtures/dot-plot.js:
--------------------------------------------------------------------------------
1 | const dotPlotSpec = {
2 | $schema: 'https://vega.github.io/schema/vega-lite/v4.json',
3 | title: {
4 | text: 'dot plot example'
5 | },
6 | data: {
7 | values: [
8 | { label: 'a', value: 21 },
9 | { label: 'b', value: 13 },
10 | { label: 'c', value: 8 },
11 | { label: 'd', value: 5 },
12 | { label: 'e', value: 3 },
13 | { label: 'f', value: 2 },
14 | { label: 'g', value: 1 },
15 | { label: 'h', value: 1 }
16 | ]
17 | },
18 | encoding: {
19 | y: { field: 'label', type: 'nominal', title: null },
20 | x: { field: 'value', type: 'quantitative', title: null }
21 | },
22 | mark: { type: 'point', tooltip: true, filled: true }
23 | }
24 |
25 | export { dotPlotSpec }
26 |
--------------------------------------------------------------------------------
/fixtures/grouped-bar.js:
--------------------------------------------------------------------------------
1 | const groupedBarChartSpec = {
2 | $schema: 'https://vega.github.io/schema/vega-lite/v4.json',
3 | title: {
4 | text: 'Grouped Bar Chart'
5 | },
6 | mark: 'bar',
7 | encoding: {
8 | column: {
9 | field: 'label',
10 | type: 'temporal',
11 | title: '',
12 | timeUnit: 'utcday'
13 | },
14 | y: {
15 | field: 'value',
16 | type: 'quantitative',
17 | axis: { title: 'value', grid: false }
18 | },
19 | x: {
20 | field: 'group',
21 | type: 'nominal',
22 | axis: { title: '' }
23 | },
24 | color: {
25 | field: 'group',
26 | type: 'nominal'
27 | }
28 | },
29 | data: {
30 | values: [
31 | {
32 | label: '2020-05-20',
33 | group: 'a',
34 | value: 8
35 | },
36 | {
37 | label: '2020-05-20',
38 | group: 'b',
39 | value: 4
40 | },
41 | {
42 | label: '2020-05-21',
43 | group: 'a',
44 | value: 4
45 | },
46 | {
47 | label: '2020-05-21',
48 | group: 'b',
49 | value: 34
50 | },
51 | {
52 | label: '2020-05-22',
53 | group: 'a',
54 | value: 6
55 | },
56 | {
57 | label: '2020-05-22',
58 | group: 'b',
59 | value: 6
60 | },
61 | {
62 | label: '2020-05-23',
63 | group: 'a',
64 | value: 22
65 | },
66 | {
67 | label: '2020-05-23',
68 | group: 'b',
69 | value: 46
70 | },
71 | {
72 | label: '2020-05-24',
73 | group: 'a',
74 | value: 13
75 | },
76 | {
77 | label: '2020-05-24',
78 | group: 'b',
79 | value: 14
80 | },
81 | {
82 | label: '2020-05-25',
83 | group: 'a',
84 | value: 13
85 | },
86 | {
87 | label: '2020-05-25',
88 | group: 'b',
89 | value: 14
90 | },
91 | {
92 | label: '2020-05-26',
93 | group: 'a',
94 | value: 18
95 | },
96 | {
97 | label: '2020-05-26',
98 | group: 'b',
99 | value: 21
100 | }
101 | ]
102 | }
103 | }
104 |
105 | export { groupedBarChartSpec }
106 |
--------------------------------------------------------------------------------
/fixtures/line.js:
--------------------------------------------------------------------------------
1 | const lineChartSpec = {
2 | '$schema': 'https://vega.github.io/schema/vega-lite/v4.json',
3 | 'title': {
4 | 'text': 'Line Chart Example'
5 | },
6 | 'mark': {
7 | 'type': 'line',
8 | 'point': true,
9 | 'tooltip': true
10 | },
11 | 'encoding': {
12 | 'x': {
13 | 'field': 'label',
14 | 'type': 'temporal',
15 | 'axis': {
16 | 'title': 'date',
17 | 'format': '%d'
18 | }
19 | },
20 | 'y': {
21 | 'title': 'count',
22 | 'field': 'value',
23 | 'type': 'quantitative'
24 | }
25 | },
26 | 'data': {
27 | 'values': [
28 | {
29 | 'label': '2020-05-20',
30 | 'value': 8
31 | },
32 | {
33 | 'label': '2020-05-21',
34 | 'value': 4
35 | },
36 | {
37 | 'label': '2020-05-23',
38 | 'value': 2
39 | },
40 | {
41 | 'label': '2020-05-24',
42 | 'value': 8
43 | },
44 | {
45 | 'label': '2020-05-25',
46 | 'value': 4
47 | },
48 | {
49 | 'label': '2020-05-26',
50 | 'value': 2
51 | },
52 | {
53 | 'label': '2020-05-28',
54 | 'value': 5
55 | },
56 | {
57 | 'label': '2020-05-29',
58 | 'value': 3
59 | },
60 | {
61 | 'label': '2020-05-31',
62 | 'value': 9
63 | },
64 | {
65 | 'label': '2020-06-02',
66 | 'value': 1
67 | },
68 | {
69 | 'label': '2020-06-03',
70 | 'value': 7
71 | },
72 | {
73 | 'label': '2020-06-04',
74 | 'value': 7
75 | },
76 | {
77 | 'label': '2020-06-05',
78 | 'value': 14
79 | },
80 | {
81 | 'label': '2020-06-06',
82 | 'value': 5
83 | },
84 | {
85 | 'label': '2020-06-07',
86 | 'value': 4
87 | },
88 | {
89 | 'label': '2020-06-08',
90 | 'value': 7
91 | },
92 | {
93 | 'label': '2020-06-09',
94 | 'value': 7
95 | },
96 | {
97 | 'label': '2020-06-10',
98 | 'value': 1
99 | },
100 | {
101 | 'label': '2020-06-11',
102 | 'value': 3
103 | },
104 | {
105 | 'label': '2020-06-12',
106 | 'value': 1
107 | },
108 | {
109 | 'label': '2020-06-13',
110 | 'value': 17
111 | },
112 | {
113 | 'label': '2020-06-14',
114 | 'value': 26
115 | },
116 | {
117 | 'label': '2020-06-15',
118 | 'value': 19
119 | },
120 | {
121 | 'label': '2020-06-16',
122 | 'value': 3
123 | }
124 | ]
125 | }
126 | }
127 |
128 | export { lineChartSpec }
129 |
--------------------------------------------------------------------------------
/fixtures/rules.js:
--------------------------------------------------------------------------------
1 | const rulesSpec = {
2 | data: {
3 | values: [
4 | { group: 'a', value: 2 },
5 | { group: 'b', value: 4 },
6 | { group: 'c', value: 7 }
7 | ]
8 | },
9 | title: { text: 'Rules' },
10 | mark: 'rule',
11 | encoding: {
12 | y: {
13 | field: 'value',
14 | type: 'quantitative'
15 | },
16 | size: { value: 2 },
17 | color: { field: 'group', type: 'nominal' }
18 | }
19 | }
20 |
21 | export { rulesSpec }
22 |
--------------------------------------------------------------------------------
/fixtures/scatter-plot.js:
--------------------------------------------------------------------------------
1 | const scatterPlotSpec = {
2 | $schema: 'https://vega.github.io/schema/vega-lite/v4.json',
3 | title: {
4 | text: 'scatter plot example'
5 | },
6 | data: {
7 | values: [
8 | { price: 16, count: 21, section: 'a' },
9 | { price: 19, count: 13, section: 'b' },
10 | { price: 14, count: 8, section: 'c' },
11 | { price: 3, count: 5, section: 'd' },
12 | { price: 11, count: 3, section: 'e' },
13 | { price: 20, count: 2, section: 'a' },
14 | { price: 4, count: 1, section: 'b' },
15 | { price: 6, count: 1, section: 'c' },
16 | { price: 2, count: 14, section: 'd' },
17 | { price: 12, count: 6, section: 'e' },
18 | { price: 12, count: 8, section: 'a' },
19 | { price: 9, count: 9, section: 'b' },
20 | { price: 8, count: 1, section: 'c' },
21 | { price: 11, count: 7, section: 'd' },
22 | { price: 7, count: 5, section: 'e' },
23 | { price: 6, count: 5, section: 'a' },
24 | { price: 8, count: 15, section: 'b' },
25 | { price: 4, count: 5, section: 'c' },
26 | { price: 7, count: 4, section: 'd' },
27 | { price: 2, count: 8, section: 'e' },
28 | { price: 9, count: 1, section: 'a' },
29 | { price: 12, count: 2, section: 'b' },
30 | { price: 12, count: 3, section: 'c' },
31 | { price: 16, count: 6, section: 'd' },
32 | { price: 2, count: 6, section: 'e' },
33 | { price: 6, count: 25, section: 'a' },
34 | { price: 9, count: 5, section: 'b' },
35 | { price: 7, count: 5, section: 'c' },
36 | { price: 10, count: 6, section: 'd' },
37 | { price: 15, count: 5, section: 'e' }
38 | ]
39 | },
40 | encoding: {
41 | y: { field: 'price', type: 'quantitative' },
42 | x: { field: 'count', type: 'quantitative' }
43 | },
44 | mark: { type: 'point', tooltip: true, filled: true }
45 | }
46 |
47 | export { scatterPlotSpec }
48 |
--------------------------------------------------------------------------------
/fixtures/single-bar.js:
--------------------------------------------------------------------------------
1 | const singleBarChartSpec = {
2 | 'title': {
3 | 'text': 'single bar'
4 | },
5 | 'mark': { 'type': 'bar', 'tooltip': true },
6 | 'data': {
7 | 'values': [
8 | { 'group': 'a', 'label': 'x', 'value': 100 },
9 | { 'group': 'b', 'label': 'y', 'value': 200 },
10 | { 'group': 'c', 'label': 'x', 'value': 300 },
11 | { 'group': 'd', 'label': 'w', 'value': 400 }
12 | ]
13 | },
14 | 'encoding': {
15 | 'y': {
16 | 'type': 'quantitative',
17 | 'field': 'value',
18 | 'axis': { 'title': null }
19 | },
20 | 'color': { 'field': 'group', 'type': 'nominal', 'legend': { 'title': null } }
21 | }
22 | }
23 |
24 | export { singleBarChartSpec }
25 |
--------------------------------------------------------------------------------
/fixtures/temporal-bar.js:
--------------------------------------------------------------------------------
1 | const temporalBarChartSpec = {
2 | $schema: 'https://vega.github.io/schema/vega-lite/v4.json',
3 | title: { text: 'temporal bar chart' },
4 | data: {
5 | values: [
6 | { value: 10, date: '2010' },
7 | { value: 20, date: '2011' },
8 | { value: 10, date: '2012' },
9 | { value: 30, date: '2013' },
10 | { value: 50, date: '2014' },
11 | { value: 10, date: '2015' }
12 | ]
13 | },
14 |
15 | mark: { type: 'bar', tooltip: true },
16 | encoding: {
17 | y: { field: 'value', type: 'quantitative' },
18 | x: { field: 'date', type: 'temporal', timeUnit: 'utcyear', axis: { format: '%Y' } }
19 | }
20 | }
21 |
22 | export { temporalBarChartSpec }
23 |
--------------------------------------------------------------------------------
/index.d.ts:
--------------------------------------------------------------------------------
1 | export function chart(s: object, dimensions?: {x: number, y: number}): Function;
2 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "bisonica",
3 | "version": "0.1.15",
4 | "type": "module",
5 | "exports": {
6 | ".": "./source/chart.js",
7 | "./styles.css": "./source/index.css"
8 | },
9 | "repository": {
10 | "type": "git",
11 | "url": "https://github.com/vijithassar/bisonica.git"
12 | },
13 | "types": "index.d.ts",
14 | "scripts": {
15 | "build": "esbuild --bundle --format=esm --external:d3 --outfile=build/bisonica.js source/chart.js",
16 | "postbuild": "yarn run archive",
17 | "archive": "zip -jr build/bisonica-$(npm view bisonica version).zip build/bisonica.js source/index.css",
18 | "documentation": "jsdoc --recurse source --destination documentation --template node_modules/docdash",
19 | "lint-git": "commitlint --to=HEAD --from=11cd541",
20 | "lint-js": "eslint source fixtures tests/**/*.js tests/*.js",
21 | "lint-styles": "stylelint source/index.css",
22 | "lint-markdown": "markdownlint ./**/*.md --ignore CHANGELOG.md --ignore LICENSE.md --ignore node_modules",
23 | "check-markdown-links": "markdown-link-check README.md CONTRIBUTING.md",
24 | "test": "qunit --require jsdom-global/register --require ./tests/browser-shim.cjs tests/**/*.js",
25 | "test-serve": "testem --file ./tests/testem.cjs",
26 | "test-ci": "testem --file ./tests/testem.cjs ci",
27 | "types": "tsc --noEmit --allowJs --checkJs --moduleResolution node --target ES2022 ./source/chart.js",
28 | "release": "npx standard-version",
29 | "coverage": "c8 --all --src source --exclude fixtures --exclude tests yarn run test",
30 | "audit": "yarn audit",
31 | "prerelease": "yarn run audit",
32 | "benchmark": "node --require jsdom-global/register --require ./tests/browser-shim.cjs benchmarks/benchmarks.js"
33 | },
34 | "devDependencies": {
35 | "@commitlint/cli": "^17.0.2",
36 | "@commitlint/config-conventional": "^17.0.2",
37 | "@types/d3": "^7.4.0",
38 | "c8": "^7.12.0",
39 | "d3": "^7.6.1",
40 | "docdash": "^2.0.1",
41 | "esbuild": "^0.14.43",
42 | "eslint": "^8.17.0",
43 | "eslint-config-standard": "^17.0.0",
44 | "eslint-plugin-import": "^2.26.0",
45 | "eslint-plugin-jsdoc": "^46.4.2",
46 | "eslint-plugin-n": "^15.5.1",
47 | "eslint-plugin-promise": "^6.1.1",
48 | "esm": "^3.2.25",
49 | "jsdoc": "^4.0.2",
50 | "jsdom": "^20.0.0",
51 | "jsdom-global": "^3.0.2",
52 | "markdown-link-check": "^3.11.2",
53 | "markdownlint-cli": "^0.35.0",
54 | "nanobench": "^3.0.0",
55 | "qunit": "^2.19.3",
56 | "standard-version": "^9.5.0",
57 | "stylelint": "^14.9.1",
58 | "stylelint-config-standard": "^26.0.0",
59 | "testem": "^3.8.0",
60 | "typescript": "^4.7.3"
61 | },
62 | "peerDependencies": {
63 | "d3": "^6.7.0 || ^7.0.0"
64 | }
65 | }
66 |
--------------------------------------------------------------------------------
/source/accessors.js:
--------------------------------------------------------------------------------
1 | /**
2 | * generate functions which look up a field from an input object for a data point
3 | * @module accessors
4 | * @see {@link module:encodings}
5 | * @see {@link module:scales}
6 | */
7 |
8 | import { layoutDirection } from './marks.js'
9 | import { encodingChannelCovariateCartesian, encodingChannelQuantitativeCartesian, encodingType, encodingValue } from './encodings.js'
10 | import { feature } from './feature.js'
11 | import { mark } from './helpers.js'
12 | import { memoize } from './memoize.js'
13 | import { parseTime } from './time.js'
14 |
15 | /**
16 | * generate accessor methods which can look up
17 | * data points for a particular chart type
18 | * @param {object} s Vega Lite specification
19 | * @param {mark} [type] mark type
20 | * @return {object} accessor methods
21 | */
22 | const _createAccessors = (s, type = null) => {
23 | const key = type || mark(s)
24 |
25 | const accessors = {}
26 |
27 | // create an accessor function with standard property lookup behavior
28 | const accessor = channel => d => encodingValue(s, channel)(d)
29 |
30 | // helper to quickly create standard accessors based solely on channel names
31 | const standard = (...channels) => {
32 | channels.forEach(channel => {
33 | // this needs to check encoding hash directly instead of using
34 | // the encodingField() helper in order to account for datum and
35 | // value encodings
36 | if (s.encoding?.[channel]) {
37 | accessors[channel] = accessor(channel)
38 | }
39 | })
40 | }
41 |
42 | if (key === 'series') {
43 | accessors.color = d => d.key
44 | }
45 |
46 | if (['bar', 'area'].includes(key)) {
47 | const start = d => d[0]
48 | const lane = d => d.data.key
49 |
50 | if (layoutDirection(s) === 'horizontal') {
51 | accessors.x = start
52 | if (feature(s).hasEncodingY()) {
53 | accessors.y = lane
54 | }
55 | } else if (layoutDirection(s) === 'vertical') {
56 | accessors.y = start
57 | if (feature(s).hasEncodingX()) {
58 | accessors.x = lane
59 | }
60 | }
61 |
62 | accessors.start = d => (d[1] ? d : [d[0], d[0]])
63 |
64 | accessors.length = d => {
65 | return isNaN(d[1]) ? 0 : d[1] - d[0]
66 | }
67 | }
68 |
69 | if (key === 'arc') {
70 | accessors.theta = d => d.data.value
71 | accessors.color = d => d.data.key
72 | if (feature(s).hasRadius()) {
73 | accessors.radius = d => {
74 | return encodingValue(s, 'radius')(d.data)
75 | }
76 | }
77 | }
78 |
79 | if (key === 'rule') {
80 | standard('x', 'y', 'color')
81 | }
82 |
83 | if (['point', 'square', 'circle'].includes(key)) {
84 | standard('x', 'y', 'color', 'size')
85 | }
86 |
87 | if (key === 'line') {
88 | const quantitative = encodingChannelQuantitativeCartesian(s)
89 | const covariate = encodingChannelCovariateCartesian(s)
90 |
91 | accessors[quantitative] = d => d.value
92 | accessors[covariate] = feature(s).isTemporal() ? d => parseTime(d.period) : accessor(covariate)
93 | accessors.color = d => (feature(s).hasColor() ? encodingValue(s, 'color')(d) : null)
94 | }
95 |
96 | if (key === 'text') {
97 | standard('x', 'y', 'color', 'text', 'size')
98 | }
99 |
100 | if (key === 'image') {
101 | standard('x', 'y', 'url')
102 | }
103 |
104 | if (!s.encoding) {
105 | return accessors
106 | }
107 |
108 | Object.entries(s.encoding).forEach(([channel, encoding]) => {
109 | if (encoding === null) {
110 | return
111 | }
112 | if (encoding.datum) {
113 | accessors[channel] = () => encoding.datum
114 | }
115 | })
116 |
117 | Object.keys(s.encoding).forEach(channel => {
118 | if (encodingType(s, channel) === 'temporal') {
119 | const originalAccessor = accessors[channel]
120 |
121 | accessors[channel] = d => parseTime(originalAccessor(d))
122 | }
123 | })
124 |
125 | return accessors
126 | }
127 | const createAccessors = memoize(_createAccessors)
128 |
129 | export { createAccessors }
130 |
--------------------------------------------------------------------------------
/source/audio.js:
--------------------------------------------------------------------------------
1 | /**
2 | * convert data values to audio frequencies and play them with a synthesizer
3 | * @module audio
4 | */
5 |
6 | import * as d3 from 'd3'
7 | import { data } from './data.js'
8 | import { dispatchers } from './interactions.js'
9 | import { feature } from './feature.js'
10 | import { markInteractionSelector } from './marks.js'
11 | import { missingSeries, noop } from './helpers.js'
12 | import { extension } from './extensions.js'
13 |
14 | const context = new window.AudioContext()
15 |
16 | const tuning = 440
17 |
18 | const defaults = {
19 | root: tuning / 2,
20 | octaves: 2,
21 | tempo: 160
22 | }
23 |
24 | /**
25 | * root note for the musical scale
26 | * @param {object} s Vega Lite specification
27 | * @return {number} root frequency
28 | */
29 | const root = s => extension(s, 'audio')?.root || defaults.root
30 |
31 | /**
32 | * octaves spread which repeats the musical scale
33 | * @param {object} s Vega Lite specification
34 | * @return {number} number of octaves
35 | */
36 | const octaves = s => extension(s, 'audio')?.octaves || defaults.octaves
37 |
38 | /**
39 | * tempo
40 | * @param {object} s Vega Lite specification
41 | * @return {number} tempo in beats per minute
42 | */
43 | const tempo = s => extension(s, 'audio')?.tempo || defaults.tempo
44 |
45 | /**
46 | * note duration
47 | * @param {object} s Vega Lite specification
48 | * @return {number} note duration
49 | */
50 | const duration = s => 60 / tempo(s) / 2
51 |
52 | const temperament = Math.pow(2, 1 / 12)
53 |
54 | /**
55 | * generate frequencies for a chromatic scale
56 | * @param {number} root root frequency
57 | * @return {number[]} chromatic audio scale
58 | */
59 | const chromatic = root => {
60 | return Array.from({ length: 13 }).map((step, index) => root * Math.pow(temperament, index))
61 | }
62 |
63 | /**
64 | * linear division of a data range into a minor scale
65 | * @param {number} min minimum value
66 | * @param {number} max maximum value
67 | * @return {number[]} minor scale in linear data space
68 | */
69 | const minorLinear = (min, max) => {
70 | const h = (max - min) / 12
71 | const w = h * 2
72 | const steps = [0, w, h, w, w, h, w, w]
73 |
74 | return steps.map((_, index) => {
75 | return d3.sum(steps.slice(0, index)) + min
76 | })
77 | }
78 |
79 | /**
80 | * minor scale
81 | * @param {number} root root frequency
82 | * @return {number[]} minor audio scale
83 | */
84 | const minorExponential = root => {
85 | const semitones = [0, 2, 3, 5, 7, 8, 10, 12]
86 |
87 | return chromatic(root).filter((_, index) => semitones.includes(index))
88 | }
89 |
90 | /**
91 | * play a note
92 | * @param {object} s Vega Lite specification
93 | * @param {number} frequency audio frequency
94 | * @param {number} start start time
95 | */
96 | const note = (s, frequency, start) => {
97 | const oscillator = context.createOscillator()
98 | const gainNode = context.createGain()
99 |
100 | gainNode.gain.setValueAtTime(1.0, context.currentTime + start)
101 |
102 | const end = context.currentTime + start + duration(s)
103 |
104 | oscillator.connect(gainNode)
105 | gainNode.connect(context.destination)
106 |
107 | oscillator.frequency.value = frequency
108 |
109 | gainNode.gain.setValueAtTime(context.currentTime + start * 0.8, 1)
110 | gainNode.gain.linearRampToValueAtTime(0.001, end)
111 |
112 | oscillator.start(context.currentTime + start)
113 | oscillator.stop(end)
114 | }
115 |
116 | /**
117 | * repeat a scale across octaves in linear data space
118 | * @param {object} s Vega Lite specification
119 | * @param {number} min minimum value
120 | * @param {number} max maximum value
121 | * @return {number[]} multiple octave data scale
122 | */
123 | const repeatLinear = (s, min, max) => {
124 | const spread = max - min
125 | const slice = spread / octaves(s)
126 |
127 | return Array.from({ length: octaves(s) })
128 | .map((_, index) => {
129 | const start = slice * index + min
130 | const end = slice * (index + 1) + min
131 | const steps = minorLinear(start, end).map(item => item + start)
132 |
133 | return index === 0 ? steps : steps.slice(1)
134 | })
135 | .flat()
136 | }
137 |
138 | /**
139 | * repeat a scale across octaves in audio space
140 | * @param {object} s Vega Lite specification
141 | * @param {number} min minimum value
142 | * @param {number} max maximum value
143 | * @return {number[]} multiple octave audio scale
144 | */
145 | const repeatExponential = (s, min, max) => {
146 | if (max % min !== 0) {
147 | console.error('endpoints supplied for exponential scale repetition are not octaves')
148 | }
149 |
150 | return Array.from({ length: octaves(s) })
151 | .map((_, index) => {
152 | const steps = minorExponential(min * 2 ** index)
153 |
154 | return index === 0 ? steps : steps.slice(1)
155 | })
156 | .flat()
157 | }
158 |
159 | /**
160 | * handle playback of musical notes
161 | * @param {object[]} values data values
162 | * @param {object} dispatcher interaction dispatcher
163 | * @param {object} s Vega Lite specification
164 | */
165 | const notes = (values, dispatcher, s) => {
166 | const [min, max] = d3.extent(values, d => d.value)
167 | const domain = repeatLinear(s, feature(s).isBar() ? 0 : min, max)
168 | const range = repeatExponential(s, root(s), root(s) * 2 ** octaves(s))
169 | const scale = d3.scaleThreshold().domain(domain).range(range)
170 |
171 | const pitches = values.map(({ value }) => scale(value))
172 |
173 | pitches.forEach((pitch, index) => {
174 | note(s, pitch, index * duration(s))
175 | setTimeout(() => {
176 | dispatcher.call('focus', null, index, s)
177 | }, (index + 1) * duration(s) * 1000)
178 | })
179 | }
180 |
181 | /**
182 | * audio sonification
183 | * @param {object} s Vega Lite specification
184 | * @return {function(object)} audio sonification function
185 | */
186 | const audio = s => {
187 | if (s.layer) {
188 | return noop
189 | }
190 |
191 | return wrapper => {
192 | const single = data(s)?.length === 1
193 | const playable =
194 | (feature(s).isLine() && single) ||
195 | (feature(s).isBar() && single) ||
196 | feature(s).isCircular()
197 | const disabled = extension(s, 'audio') === null
198 |
199 | if (!playable || disabled) {
200 | return
201 | }
202 |
203 | let values
204 |
205 | if (feature(s).isLine()) {
206 | ({ values } = data(s)[0])
207 | } else if (feature(s).isCircular()) {
208 | values = data(s)
209 | } else if (feature(s).isBar()) {
210 | values = data(s)[0].map(item => {
211 | return { value: item.data[missingSeries()].value }
212 | })
213 | }
214 |
215 | if (!values) {
216 | return
217 | }
218 |
219 | const dispatcher = dispatchers.get(wrapper.node())
220 |
221 | dispatcher.on('play', (values, s) => {
222 | notes(values, dispatcher, s)
223 | })
224 |
225 | dispatcher.on('focus', index => {
226 | wrapper.selectAll(markInteractionSelector(s)).nodes()[index].focus()
227 |
228 | if (index === values.length - 1) {
229 | playing = false
230 | }
231 | })
232 |
233 | const play = wrapper.append('div').classed('play', true).text('▶')
234 | let playing = false
235 |
236 | play.on('click', () => {
237 | if (!playing) {
238 | dispatcher.call('play', null, values, s)
239 | playing = true
240 | }
241 | })
242 | }
243 | }
244 |
245 | export { audio }
246 |
--------------------------------------------------------------------------------
/source/chart.js:
--------------------------------------------------------------------------------
1 | /**
2 | * main wrapper function to render a chart
3 | * @module chart
4 | */
5 |
6 | import './types.d.js'
7 |
8 | import { WRAPPER_CLASS } from './config.js'
9 | import { audio } from './audio.js'
10 | import { axes } from './axes.js'
11 | import { setupNode } from './lifecycle.js'
12 | import { initializeInteractions, interactions } from './interactions.js'
13 | import { keyboard } from './keyboard.js'
14 | import { layerMarks } from './views.js'
15 | import { legend } from './legend.js'
16 | import { margin, position } from './position.js'
17 | import { marks } from './marks.js'
18 | import { testAttributes } from './markup.js'
19 | import { usermeta } from './extensions.js'
20 | import { table, tableToggle } from './table.js'
21 | import { feature } from './feature.js'
22 | import { fetchAll } from './fetch.js'
23 | import { copyMethods } from './helpers.js'
24 | import { defs } from './defs.js'
25 | import { dimensions } from './dimensions.js'
26 | import { menu } from './menu.js'
27 |
28 | /**
29 | * generate chart rendering function based on
30 | * a Vega Lite specification
31 | * @param {object} s Vega Lite specification
32 | * @param {dimensions} [_panelDimensions] chart dimensions
33 | * @return {function(object)} renderer
34 | */
35 | const render = (s, _panelDimensions) => {
36 | let tooltipHandler
37 | let errorHandler = console.error
38 | let tableRenderer = table
39 |
40 | const renderer = selection => {
41 | try {
42 | selection.html('')
43 |
44 | let panelDimensions = dimensions(s, selection.node(), _panelDimensions)
45 |
46 | selection.call(setupNode(s, panelDimensions))
47 |
48 | initializeInteractions(selection.node(), s)
49 |
50 | const chartNode = selection.select('div.chart')
51 |
52 | chartNode.call(audio(s))
53 |
54 | initializeInteractions(chartNode.node(), s)
55 |
56 | chartNode.call(menu(s))
57 |
58 | chartNode.call(tableToggle(s, tableRenderer))
59 |
60 | // render legend
61 | if (feature(s).hasLegend()) {
62 | chartNode.select('.legend').call(legend(s))
63 | }
64 | const legendHeight = chartNode.select('.legend').node()?.getBoundingClientRect().height || 0
65 |
66 | const svg = chartNode.select('svg')
67 | const imageHeight = panelDimensions.y - legendHeight
68 |
69 | svg.attr('height', Math.max(imageHeight, 0))
70 |
71 | svg.call(defs(s))
72 |
73 | const { top, right, bottom, left } = margin(s, panelDimensions)
74 |
75 | // subtract rendered height of legend from dimensions
76 | const graphicDimensions = {
77 | x: panelDimensions.x - left - right,
78 | y: imageHeight - top - bottom
79 | }
80 |
81 | if (graphicDimensions.y > 0) {
82 | const wrapper = chartNode
83 | .select('.graphic')
84 | .select('svg')
85 | .call(position(s, { x: panelDimensions.x, y: imageHeight }))
86 | .select(`g.${WRAPPER_CLASS}`)
87 |
88 | wrapper
89 | .call(axes(s, graphicDimensions))
90 | .call((s.layer ? layerMarks : marks)(s, graphicDimensions))
91 | .call(keyboard(s))
92 | .call(interactions(s))
93 | selection.call(testAttributes)
94 | }
95 | } catch (error) {
96 | errorHandler(error)
97 | }
98 | }
99 |
100 | renderer.table = t => {
101 | if (t === undefined) {
102 | return tableRenderer
103 | } else {
104 | if (typeof t === 'function') {
105 | tableRenderer = t
106 | }
107 |
108 | return renderer
109 | }
110 | }
111 |
112 | renderer.tooltip = h => {
113 | if (h === undefined) {
114 | return tooltipHandler
115 | } else {
116 | if (typeof h === 'function') {
117 | tooltipHandler = h
118 | usermeta(s)
119 | s.usermeta.tooltipHandler = true
120 | } else {
121 | throw new Error(`tooltip handler must be a function, but input was of type ${typeof h}`)
122 | }
123 |
124 | return renderer
125 | }
126 | }
127 | renderer.error = h => typeof h !== 'undefined' ? (errorHandler = h, renderer) : errorHandler
128 |
129 | return renderer
130 | }
131 |
132 | /**
133 | * convert a synchronous rendering function
134 | * into an asynchronous rendering function
135 | * @param {object} s Vega Lite specification
136 | * @param {dimensions} dimensions chart dimensions
137 | * @return {function(object)} asynchronous rendering function
138 | */
139 | const asyncRender = (s, dimensions) => {
140 | const renderer = render(s, dimensions)
141 | const fn = selection => {
142 | fetchAll(s)
143 | .then(() => {
144 | selection.call(renderer)
145 | })
146 | }
147 | copyMethods(['error', 'tooltip', 'table'], renderer, fn)
148 | return fn
149 | }
150 |
151 | /**
152 | * optionally fetch remote data, then create and run
153 | * a chart rendering function
154 | * @param {object} s Vega Lite specification
155 | * @param {dimensions} [dimensions] chart dimensions
156 | * @return {function(object)} renderer
157 | */
158 | const chart = (s, dimensions) => {
159 | if (s.data?.url || s.layer?.find(layer => layer.data?.url)) {
160 | return asyncRender(s, dimensions)
161 | } else {
162 | return render(s, dimensions)
163 | }
164 | }
165 |
166 | export { chart }
167 |
--------------------------------------------------------------------------------
/source/color.js:
--------------------------------------------------------------------------------
1 | /**
2 | * generate color palettes
3 | * @module color
4 | * @see {@link module:scales}
5 | */
6 |
7 | import * as d3 from 'd3'
8 | import { extension } from './extensions.js'
9 |
10 | const defaultColor = 'steelblue'
11 |
12 | /**
13 | * alternate luminance on a set of color objects
14 | * @param {object} color color
15 | * @param {number} index position of the color in the palette
16 | * @param {number} k severity of luminance adjustment
17 | * @return {object} color
18 | */
19 | const alternateLuminance = (color, index, k = 1) => color[index % 2 ? 'brighter' : 'darker'](k)
20 |
21 | const stops = {
22 | deuteranopia: ['#e1daae', '#ff934f', '#cc2d35', '#058ed9', '#2d3142'],
23 | protanopia: ['#e8f086', '#6fde6e', '#ff4242', '#a691ae', '#235fa4'],
24 | tritanopia: ['#dd4444', '#f48080', '#ffdcdc', '#2d676f', '#194b4f']
25 | }
26 |
27 | /**
28 | * generate a palette of accessible colors for use
29 | * as a categorical scale
30 | * @param {number} count number of colors
31 | * @param {string} variant palette variant
32 | * @return {string[]} color palette
33 | */
34 | const accessibleColors = (count, variant) => {
35 | if (!stops[variant]) {
36 | throw new Error(`unknown color palette "${variant}"`)
37 | }
38 | const interpolator = d3.piecewise(d3.interpolateRgb.gamma(2.2), stops[variant])
39 | const step = 1 / count
40 | const values = Array.from({ length: count }).map((_, index) => {
41 | return interpolator(index * step)
42 | }).map(item => {
43 | return d3.hsl(item)
44 | })
45 |
46 | let k
47 | if (variant === 'tritanopia') {
48 | k = 0.1
49 | } else {
50 | k = 0.5
51 | }
52 | return values.map((item, index) => alternateLuminance(item, index, k))
53 | }
54 |
55 | /**
56 | * create a standard color palette from the entire
57 | * available hue range for use as a categorical scale
58 | * @param {number} count number of colors
59 | * @return {string[]} color palette
60 | */
61 | const standardColors = count => {
62 | if (!count || count === 1) {
63 | return [defaultColor]
64 | }
65 | const hues = d3.range(count).map((item, index) => {
66 | const hue = 360 / count * index
67 | return hue
68 | })
69 | const swatches = hues
70 | .map(hue => d3.hcl(hue, 100, 50))
71 | .map((color, index) => alternateLuminance(color, index))
72 | .map(item => item.toString())
73 | return swatches
74 | }
75 |
76 | /**
77 | * alternate colors
78 | * @param {string[]} colors color palette
79 | * @return {string[]} alternating color palette
80 | */
81 | const alternate = colors => {
82 | if (colors.length === 1) {
83 | return colors
84 | }
85 | const count = colors.length
86 | const midpoint = Math.floor(count * 0.5)
87 | const a = colors.slice(0, midpoint)
88 | const b = colors.slice(midpoint)
89 | const ordered = d3.zip(a, b).flat()
90 | if (a.length < b.length) {
91 | ordered.push(b.pop())
92 | }
93 | return ordered
94 | }
95 |
96 | /**
97 | * look up an array of colors for a named color scheme
98 | * @param {number} _count number of colors
99 | * @param {string|object} config color scheme name or configuration object
100 | * @return {string[]} color scheme array
101 | */
102 | const scheme = (_count, config) => {
103 | const object = typeof config === 'object'
104 | const name = object ? config.name : config
105 | const count = object ? config.count : _count
106 | const key = name.slice(0, 1).toUpperCase() + name.slice(1)
107 | const scheme = d3[`scheme${key}`] || d3[`interpolate${key}`]
108 | if (!scheme) {
109 | throw new Error(`unknown color scheme ${name}`)
110 | }
111 | const callable = typeof scheme === 'function'
112 | if (callable) {
113 | return Array.from({ length: count }).map((_, index) => scheme(1 / count * index))
114 | }
115 | const nested = Array.isArray([...scheme].pop())
116 | if (!nested) {
117 | return scheme.slice(0, count)
118 | } else {
119 | const index = d3.min([count, scheme.length])
120 | const palette = scheme[index]
121 | if (!palette) {
122 | throw new Error(`color scheme ${name} does not provide a range with swatch count ${count}`)
123 | }
124 | return palette
125 | }
126 | }
127 |
128 | /**
129 | * generate a categorical color scale
130 | * @param {object} s Vega Lite specification
131 | * @param {number} count number of colors
132 | */
133 | const colors = (s, count) => {
134 | const variant = extension(s, 'color')?.variant
135 | if (variant) {
136 | return alternate(accessibleColors(count, variant))
137 | } else if (s.encoding.color?.scale?.scheme) {
138 | return scheme(count, s.encoding.color.scale.scheme)
139 | } else {
140 | return alternate(standardColors(count))
141 | }
142 | }
143 |
144 | export { colors }
145 |
--------------------------------------------------------------------------------
/source/config.js:
--------------------------------------------------------------------------------
1 | /**
2 | * static configuration values
3 | * @module config
4 | */
5 |
6 | /**
7 | * class name for outer node wrapper
8 | * @type {string}
9 | */
10 | const WRAPPER_CLASS = 'wrapper'
11 |
12 | /**
13 | * increment for positions that are not derived from data
14 | * @type {number}
15 | */
16 | const GRID = 10
17 |
18 | /**
19 | * minimum number of ticks for a continuous scale axis
20 | * @type {number}
21 | */
22 | const MINIMUM_TICK_COUNT = 3
23 |
24 | export {
25 | WRAPPER_CLASS,
26 | GRID,
27 | MINIMUM_TICK_COUNT
28 | }
29 |
--------------------------------------------------------------------------------
/source/defs.js:
--------------------------------------------------------------------------------
1 | /**
2 | * reusable nodes that are referenced but aren't directly rendered
3 | * @module defs
4 | */
5 |
6 | import { feature } from './feature.js'
7 | import { gradient } from './gradient.js'
8 | import { noop } from './helpers.js'
9 | import { memoize } from './memoize.js'
10 |
11 | /**
12 | * create a defs node
13 | * @param {object} s Vega Lite specification
14 | * @return {function(object)} defs rendering function
15 | */
16 | const _defs = s => {
17 | if (!feature(s).hasDefs()) {
18 | return noop
19 | }
20 | const renderer = selection => {
21 | if (selection.node().tagName !== 'svg') {
22 | throw new Error('defs must be rendered at the top level of the SVG node')
23 | }
24 | const defs = selection.append('defs')
25 | const fns = [gradient(s)]
26 | fns.forEach(fn => {
27 | defs.call(fn)
28 | })
29 | }
30 | return renderer
31 | }
32 | const defs = memoize(_defs)
33 |
34 | export { defs }
35 |
--------------------------------------------------------------------------------
/source/dimensions.js:
--------------------------------------------------------------------------------
1 | /**
2 | * determine rendering dimensions for a chart
3 | * @module dimensions
4 | * @see {@link module:chart}
5 | */
6 |
7 | import { feature } from './feature.js'
8 | import { isContinuous } from './helpers.js'
9 | import { markData } from './marks.js'
10 |
11 | import './types.d.js'
12 |
13 | const channels = {
14 | x: 'width',
15 | y: 'height'
16 | }
17 |
18 | /**
19 | * determine rendering size for chart
20 | * @param {object} s Vega Lite specification
21 | * @param {HTMLElement} node DOM node
22 | * @param {object} [explicitDimensions] chart dimensions
23 | * @return {dimensions} chart dimensions
24 | */
25 | const dimensions = (s, node, explicitDimensions) => {
26 | let result = { x: null, y: null }
27 | const marks = feature(s).isBar() ? markData(s)[0].length : 1
28 | Object.entries(channels).forEach(([channel, dimension]) => {
29 | if (explicitDimensions?.[channel]) {
30 | result[channel] = explicitDimensions[channel]
31 | } else if (s[dimension]) {
32 | if (s[dimension] === 'container') {
33 | result[channel] = node.getBoundingClientRect()[dimension]
34 | } else if (s[dimension].step) {
35 | result[channel] = s[dimension].step * marks
36 | } else if (typeof s[dimension] === 'number') {
37 | result[channel] = s[dimension]
38 | }
39 | }
40 | if (feature(s).isCartesian() || feature(s).isLinear()) {
41 | if (!result[channel]) {
42 | if (isContinuous(s, channel)) {
43 | result[channel] = 200
44 | } else {
45 | result[channel] = marks * 20
46 | }
47 | }
48 | }
49 | })
50 | return result
51 | }
52 |
53 | export { dimensions }
54 |
--------------------------------------------------------------------------------
/source/download.js:
--------------------------------------------------------------------------------
1 | /**
2 | * convert values into files containing raw data which the user can then download
3 | * @module download
4 | * @see {@link module:menu}
5 | */
6 |
7 | import { extension } from './extensions.js'
8 | import { values } from './values.js'
9 | import { csvFormat } from 'd3'
10 | import { memoize } from './memoize.js'
11 | import { feature } from './feature.js'
12 |
13 | /**
14 | * render download links
15 | * @param {object} s Vega Lite specification
16 | * @param {'csv'|'json'} format data format
17 | * @return {string} download url
18 | */
19 | const _download = (s, format) => {
20 | if (extension(s, 'download')?.[format] === false || !values(s) || !feature(s).hasDownload()) {
21 | return
22 | }
23 | let file
24 | if (format === 'csv') {
25 | file = new Blob([csvFormat(values(s))], { type: 'text/csv' })
26 | } else if (format === 'json') {
27 | file = new Blob([JSON.stringify(s)], { type: 'text/json' })
28 | }
29 | const url = URL.createObjectURL(file)
30 | return url
31 | }
32 | const download = memoize(_download)
33 |
34 | export { download }
35 |
--------------------------------------------------------------------------------
/source/expression.js:
--------------------------------------------------------------------------------
1 | /**
2 | * evaluate string expressions
3 | * @module expression
4 | * @see {@link module:transform}
5 | */
6 |
7 | const datumPrefix = 'datum.'
8 |
9 | /**
10 | * create a function to perform a function execution
11 | * @param {string} str a calculate expression calling a single function
12 | * @return {function} static function
13 | */
14 | const functionExpression = str => {
15 | const fns = {
16 | random: () => Math.random(),
17 | now: () => Date.now(),
18 | windowSize: () => window ? [window.innerWidth, window.innerHeight] : [undefined, undefined],
19 | screen: () => window ? window.screen : {}
20 | }
21 | return fns[str.slice(0, -2)]
22 | }
23 |
24 | /**
25 | * create a function to perform a single string interpolation
26 | * @param {string} str a calculate expression describing string interpolation
27 | * @return {function(object)} string interpolation function
28 | */
29 | const concatenate = str => {
30 | const segments = str
31 | .split('+')
32 | .map(item => item.trim())
33 | .map(item => {
34 | const interpolate = typeof item === 'string' && item.startsWith(datumPrefix)
35 | const literal = item.startsWith("'") && item.endsWith("'")
36 |
37 | if (literal) {
38 | return item.slice(1, -1)
39 | } else if (interpolate) {
40 | return item
41 | }
42 | })
43 | .filter(item => !!item)
44 |
45 | return d =>
46 | segments
47 | .map(segment => {
48 | if (segment.startsWith(datumPrefix)) {
49 | const key = segment.slice(datumPrefix.length)
50 |
51 | return d[key]
52 | } else {
53 | return segment
54 | }
55 | })
56 | .join('')
57 | }
58 |
59 | /**
60 | * determine whether a string starts and ends with quotes
61 | * @param {string} string string, possibly a string literal
62 | * @return {boolean}
63 | */
64 | const isStringLiteral = string => {
65 | return ['"', "'"]
66 | .some(character => {
67 | return string.startsWith(character) &&
68 | string.endsWith(character)
69 | })
70 | }
71 |
72 | /**
73 | * convert a predicate string expression to the equivalent object
74 | * @param {object} config predicate config with string expression
75 | * @return {object} predicate config with object
76 | */
77 | const expressionStringParse = config => {
78 | if (!config.includes(' ')) {
79 | throw new Error('string predicates must use spaces')
80 | }
81 | const [a, b, ...rest] = config.split(' ')
82 | const operators = {
83 | '==': 'equal',
84 | '===': 'equal',
85 | '>': 'gt',
86 | '>=': 'gte',
87 | '<': 'lt',
88 | '<=': 'lte'
89 | }
90 | const result = {}
91 | const field = a.split('.').pop()
92 | const operator = operators[b]
93 | const value = rest.join(' ')
94 | result.field = field
95 | result[operator] = isStringLiteral(value) ? value.slice(1, -1) : +value
96 | return result
97 | }
98 |
99 | /**
100 | * create a function to perform a single calculate expression
101 | * @param {string} str expression
102 | * @return {function} expression evaluation function
103 | */
104 | const expression = str => {
105 | if (str.slice(-2) === '()') {
106 | return functionExpression(str)
107 | } else {
108 | return concatenate(str)
109 | }
110 | }
111 |
112 | export { expression, expressionStringParse }
113 |
--------------------------------------------------------------------------------
/source/extensions.js:
--------------------------------------------------------------------------------
1 | /**
2 | * organize extended metadata stored under the usermeta property
3 | * @module extensions
4 | * @see {@link https://vega.github.io/vega-lite/docs/spec.html|vega-lite:spec}
5 | */
6 |
7 | import './types.d.js'
8 |
9 | /**
10 | * retrieve information from usermeta
11 | * @param {object} s Vega Lite specification
12 | * @param {extension} key usermeta key
13 | */
14 | const extension = (s, key) => {
15 | if (s.usermeta?.[key] !== undefined) {
16 | return s.usermeta[key]
17 | }
18 | }
19 |
20 | /**
21 | * initialize usermeta object if it doesn't
22 | * already exist
23 | * @param {object} s Vega Lite specification
24 | */
25 | const usermeta = s => {
26 | if (typeof s.usermeta !== 'object') {
27 | s.usermeta = {}
28 | }
29 | }
30 |
31 | export { extension, usermeta }
32 |
--------------------------------------------------------------------------------
/source/feature.js:
--------------------------------------------------------------------------------
1 | /**
2 | * collect conditional tests based on object lookups into reusable functions
3 | * @module feature
4 | */
5 |
6 | import { encodingType, encodingValue } from './encodings.js'
7 | import { layerTestRecursive } from './views.js'
8 | import { mark } from './helpers.js'
9 | import { memoize } from './memoize.js'
10 | import { values } from './values.js'
11 |
12 | const _feature = s => {
13 | const multicolorTest = s => {
14 | const colorValues = [
15 | ...(Array.from(new Set(values(s))) || [])
16 | .map(encodingValue(s, 'color'))
17 | .filter(item => !!item)
18 | ]
19 |
20 | return s.encoding?.color && colorValues.length > 1
21 | }
22 |
23 | const isMulticolor = layerTestRecursive(s, multicolorTest)
24 |
25 | const temporalTest = s => {
26 | return Object.keys(s.encoding || {}).some(channel => encodingType(s, channel) === 'temporal')
27 | }
28 | const isTemporal = layerTestRecursive(s, temporalTest)
29 |
30 | const tests = {
31 | isBar: s => mark(s) === 'bar',
32 | isLine: s => mark(s) === 'line',
33 | isArea: s => mark(s) === 'area',
34 | hasPoints: s => ['point', 'circle', 'square'].includes(mark(s)) || s.mark?.point === true || s.mark?.point === 'transparent',
35 | hasMarksFilled: s => {
36 | if (mark(s) === 'point') {
37 | return s.mark?.filled === true && s.mark?.point !== 'transparent'
38 | } else if (['line', 'rule'].includes(mark(s))) {
39 | return false
40 | } else {
41 | return s.mark?.filled !== false
42 | }
43 | },
44 | hasLayers: s => s.layer,
45 | isCircular: s => mark(s) === 'arc',
46 | hasDefs: s => !!s.mark?.color?.gradient,
47 | isRule: s => mark(s) === 'rule',
48 | isText: s => mark(s) === 'text',
49 | isImage: s => mark(s) === 'image',
50 | hasColor: s => s.encoding?.color,
51 | hasLinks: s => s.encoding?.href || s.mark?.href,
52 | hasData: s => s.data?.values?.length || s.data?.url,
53 | hasLegend: s => s.encoding?.color && s.encoding?.color?.legend !== null,
54 | hasLegendTitle: s => s.encoding?.color?.legend?.title !== null,
55 | hasTooltip: s => s.mark?.tooltip || s.encoding?.tooltip,
56 | hasTransforms: s => Array.isArray(s.transform),
57 | hasAxisX: s => s.encoding?.x && s.encoding?.x.axis !== null,
58 | hasAxisY: s => s.encoding?.y && s.encoding?.y.axis !== null,
59 | hasAxisLabelsY: s => s.encoding?.y?.axis?.labels !== false,
60 | hasAxisLabelsX: s => s.encoding?.x?.axis?.labels !== false,
61 | hasAxisTitleX: s => s.encoding?.x?.axis?.title !== null,
62 | hasAxisTitleY: s => s.encoding?.y?.axis?.title !== null,
63 | hasStaticText: s => s.mark?.text && !s.encoding?.text,
64 | hasTable: s => s.usermeta?.table !== null,
65 | hasDownload: s => s.usermeta?.download !== null,
66 | isCartesian: s => (s.encoding?.x && s.encoding?.y),
67 | isLinear: s => (s.encoding?.x && !s.encoding?.y) || (s.encoding?.y && !s.encoding?.x),
68 | isTemporal: () => isTemporal,
69 | isMulticolor: () => isMulticolor,
70 | hasEncodingX: s => s.encoding?.x,
71 | hasEncodingY: s => s.encoding?.y,
72 | hasEncodingColor: s => s.encoding?.color,
73 | hasRadius: s => s.encoding?.radius,
74 | isStacked: s => {
75 | return ['bar', 'area'].includes(mark(s)) &&
76 | s.encoding?.y?.stack !== null &&
77 | s.encoding?.y?.stack !== false &&
78 | s.encoding?.x?.stack !== null &&
79 | s.encoding?.x?.stack !== false &&
80 | isMulticolor
81 | }
82 | }
83 |
84 | tests.hasAxis = s => tests.hasEncodingX(s) || tests.hasEncodingY(s)
85 |
86 | tests.isTemporalBar = s => tests.isBar(s) && isTemporal
87 |
88 | const layerTests = {}
89 |
90 | Object.entries(tests).forEach(([key, test]) => {
91 | layerTests[key] = memoize(() => {
92 | return !!layerTestRecursive(s, test)
93 | })
94 | })
95 |
96 | return layerTests
97 | }
98 |
99 | /**
100 | * use simple heuristics to determine features the chart type
101 | * @param {object} s Vega Lite specification
102 | * @return {object} methods for boolean feature tests
103 | */
104 | const feature = memoize(_feature)
105 |
106 | export { feature }
107 |
--------------------------------------------------------------------------------
/source/fetch.js:
--------------------------------------------------------------------------------
1 | /**
2 | * retrieve data from remote servers
3 | * @module fetch
4 | * @see {@link module:data}
5 | * @see {@link https://vega.github.io/vega-lite/docs/data.html|vega-lite:data}
6 | */
7 |
8 | import * as d3 from 'd3'
9 |
10 | const cache = new WeakMap()
11 |
12 | /**
13 | * retrieve data from the cache
14 | * @param {object} data data definition
15 | * @return {object[]} data set
16 | */
17 | const cached = data => {
18 | if (data.url) {
19 | return cache.get(data)
20 | }
21 | }
22 |
23 | /**
24 | * fetch remote data
25 | * @param {object} data data definition
26 | * @return {Promise} data set
27 | */
28 | const fetch = data => {
29 | const extensions = ['json', 'tsv', 'csv', 'dsv']
30 | const extension = data?.format?.type ||
31 | extensions.find(item => data.url.endsWith(`.${item}`)) ||
32 | 'json'
33 | if (extension === 'dsv') {
34 | return d3.dsv(data.format.delimiter, data.url)
35 | } else {
36 | return d3[extension](data.url)
37 | }
38 | }
39 |
40 | /**
41 | * fetch and cache all remote resources for a specification
42 | * @param {object} s Vega Lite specification
43 | */
44 | const fetchAll = s => {
45 | const resources = [
46 | s.data,
47 | ...(s.layer ? s.layer.map(layer => layer.data) : [])
48 | ]
49 | .filter(Boolean)
50 | .filter(resource => resource.url)
51 | const promises = resources.map(fetch)
52 | promises.forEach((promise, index) => {
53 | promise.then(result => {
54 | const resource = resources[index]
55 | cache.set(resource, result)
56 | })
57 | })
58 | if (promises.length) {
59 | const all = Promise.allSettled(promises)
60 | return all
61 | } else {
62 | const resolver = resolve => resolve(true)
63 | const promise = new Promise(resolver)
64 | return promise
65 | }
66 | }
67 |
68 | export { cached, fetchAll }
69 |
--------------------------------------------------------------------------------
/source/format.js:
--------------------------------------------------------------------------------
1 | /**
2 | * format numbers and timestamps
3 | * @module format
4 | */
5 |
6 | import { memoize } from './memoize.js'
7 | import { identity, noop } from './helpers.js'
8 | import { encodingType } from './encodings.js'
9 |
10 | import * as d3 from 'd3'
11 |
12 | /**
13 | * create a time formatting function
14 | * @param {object|undefined} format d3 time format string
15 | * @return {function(Date)} date formatting function
16 | */
17 | const timeFormat = format => {
18 | if (!format) {
19 | return date => date.toString()
20 | }
21 | return d3.timeFormat(format)
22 | }
23 |
24 | /**
25 | * create a time formatting function in UTC
26 | * @param {object|undefined} format d3 time format string
27 | * @return {function(Date)} UTC date formatting function
28 | */
29 | const utcFormat = format => {
30 | if (!format) {
31 | return date => date.toUTCString()
32 | }
33 | return d3.utcFormat(format)
34 | }
35 |
36 | /**
37 | * create a number formatting function
38 | * @param {object|undefined} format d3 number format string
39 | * @return {function(number)} number formatting function
40 | */
41 | const numberFormat = format => {
42 | if (!format) {
43 | return number => number.toString()
44 | }
45 | return d3.format(format)
46 | }
47 |
48 | /**
49 | * create a formatting function
50 | * @param {object} config encoding or axis definition object
51 | * @return {function} formatting function
52 | */
53 | const _format = config => {
54 | if (!config || (!config.format && !config.axis?.format)) {
55 | return identity
56 | }
57 | const time = config.type === 'temporal' || config.formatType === 'time' || config.timeUnit
58 | const format = config.format || config.axis.format
59 | if (time) {
60 | const utc = !!config.timeUnit?.startsWith('utc')
61 | if (utc) {
62 | return utcFormat(format)
63 | } else {
64 | return timeFormat(format)
65 | }
66 | } else {
67 | return numberFormat(format)
68 | }
69 | }
70 | const format = memoize(_format)
71 |
72 | /**
73 | * create a formatting function based on an encoding channel
74 | * definition
75 | *
76 | * this is only valid for text and tooltip channels; most of
77 | * the time the right choice is formatAxis
78 | * @param {object} s Vega Lite specification
79 | * @param {'text'|'tooltip'} channel encoding channel
80 | */
81 | const formatChannel = (s, channel) => {
82 | return format(s.encoding[channel])
83 | }
84 |
85 | /**
86 | * create a formatting function based on an axis definition
87 | *
88 | * this doesn't mean you're necessarily formatting axis ticks;
89 | * rather, it means the formatting instruction is taken from
90 | * the axis object, which is the most common scenario
91 | * @param {object} s Vega Lite specification
92 | * @param {string} channel encoding channel
93 | */
94 | const formatAxis = (s, channel) => {
95 | const config = s.encoding[channel].axis
96 | if (config === null) {
97 | return noop
98 | }
99 | if (config === undefined) {
100 | return identity
101 | }
102 | // sidestep the format() wrapper function in cases where
103 | // the time encoding is specified at the channel level
104 | // instead of with axis.type
105 | if (encodingType(s, channel) === 'temporal' && !config.formatType) {
106 | return timeFormat(config.format)
107 | } else {
108 | return format(config)
109 | }
110 | }
111 |
112 | export { format, formatChannel, formatAxis }
113 |
--------------------------------------------------------------------------------
/source/gradient.js:
--------------------------------------------------------------------------------
1 | /**
2 | * render gradients
3 | * @module gradients
4 | * @see {@link https://vega.github.io/vega-lite/docs/gradient.html|vega-lite:gradient}
5 | */
6 |
7 | import { key, noop } from './helpers.js'
8 |
9 | /**
10 | * assemble a string key for a gradient
11 | * @param {object} s Vega Lite specification
12 | * @param {number} [index] gradient index
13 | * @return {string} gradient id
14 | */
15 | const gradientKey = (s, index = 0) => {
16 | const n = index + 1
17 | return `${key(s.title.text)}-gradient-${n}`
18 | }
19 |
20 | /**
21 | * create a gradient
22 | * @param {object} s Vega Lite specification
23 | * @return {function(object)} gradient definition rendering function
24 | */
25 | const gradient = s => {
26 | if (!s.mark?.color?.gradient) {
27 | return noop
28 | }
29 | const renderer = selection => {
30 | if (selection.node().tagName !== 'defs') {
31 | throw new Error('gradients can only be rendered into a node')
32 | }
33 | const colors = [
34 | s.mark.color,
35 | s.layer?.map(item => item.mark.color)
36 | ]
37 | .filter(Boolean)
38 | .filter(item => item.gradient)
39 | colors.forEach((color, index) => {
40 | const type = `${color.gradient}Gradient`
41 | const gradient = selection.append(type)
42 | gradient
43 | .attr('id', gradientKey(s, index))
44 | .attr('x1', color.x1)
45 | .attr('x2', color.x2)
46 | .attr('y1', color.y1)
47 | .attr('y2', color.y2)
48 | if (type === 'radialGradient') {
49 | gradient.attr('r1', color.r1)
50 | gradient.attr('r2', color.r2)
51 | }
52 | gradient
53 | .selectAll('stop')
54 | .data(color.stops)
55 | .enter()
56 | .append('stop')
57 | .attr('offset', d => d.offset)
58 | .attr('stop-color', d => d.color)
59 | })
60 | }
61 | return renderer
62 | }
63 |
64 | export { gradient, gradientKey }
65 |
--------------------------------------------------------------------------------
/source/index.css:
--------------------------------------------------------------------------------
1 | .chart {
2 | --border-private: var(--border, white);
3 | --highlight-private: var(--highlight, red);
4 | --light-private: var(--light, #ccc);
5 | --medium-private: var(--medium, #888);
6 | --dark-private: var(--dark, #444);
7 | }
8 |
9 | .legend {
10 | max-height: 12em;
11 | overflow: hidden;
12 | text-align: center;
13 | position: relative;
14 | }
15 |
16 | .legend h3 {
17 | padding: 0;
18 | margin: 0;
19 | }
20 |
21 | .graphic {
22 | display: flex;
23 | align-items: center;
24 | justify-content: center;
25 | }
26 |
27 | svg .wrapper .mark.link {
28 | cursor: pointer;
29 | }
30 |
31 | svg .wrapper .mark:focus {
32 | outline: none;
33 | }
34 |
35 | svg .wrapper .mark:not([data-highlight]) {
36 | stroke: var(--border-private);
37 | stroke-width: 1px;
38 | }
39 |
40 | .chart-donut svg text.mark {
41 | fill: var(--text-private);
42 | font-weight: 100;
43 | }
44 |
45 | svg .wrapper text.mark:not([data-highlight]) {
46 | stroke: none;
47 | }
48 |
49 | .axes text {
50 | fill: var(--medium-private);
51 | stroke: none;
52 | }
53 |
54 | .axes path {
55 | display: none;
56 | }
57 |
58 | path.mark {
59 | stroke-linecap: round;
60 | stroke-linejoin: round;
61 | }
62 |
63 | .legend ul {
64 | display: flex;
65 | flex-wrap: wrap;
66 | justify-content: center;
67 | }
68 |
69 | .chart .legend {
70 | text-align: center;
71 | }
72 |
73 | .mark[data-highlight],
74 | .point[data-highlight] {
75 | stroke: var(--highlight-private);
76 | stroke-width: 2px;
77 | }
78 |
79 | .mark.rule {
80 | stroke-width: 1px;
81 | }
82 |
83 | .legend li.category {
84 | align-items: center;
85 | display: grid;
86 | grid-auto-flow: column;
87 | grid-gap: 0.2em;
88 | line-height: 2em;
89 | padding: 0.2em 0.8em;
90 | }
91 |
92 | .legend ul.items-more {
93 | bottom: 0.2em;
94 | padding: 0.5em;
95 | position: absolute;
96 | right: 0.2em;
97 | }
98 |
99 | .legend li.category .label {
100 | margin-left: 0.2em;
101 | }
102 |
103 | .legend ul.items-more:hover {
104 | display: block;
105 | }
106 |
107 | .legend li.category .color {
108 | border-radius: 100%;
109 | height: 1em;
110 | -webkit-print-color-adjust: exact;
111 | width: 1em;
112 | }
113 |
114 | .legend li.category:not([data-highlight]) {
115 | border: 0;
116 | }
117 |
118 | .legend li.category[data-highlight] {
119 | color: var(--highlight-private);
120 | }
121 |
122 | .menu a {
123 | display: inline;
124 | font-size: small;
125 | font-weight: normal;
126 | padding-right: 1em;
127 | cursor: pointer;
128 | }
129 |
130 | .menu ul {
131 | padding: 0;
132 | }
133 |
134 | .menu li {
135 | list-style: none;
136 | display: inline;
137 | margin-right: 1rem;
138 | }
139 |
140 | .legend ul.items-more:hover h3 {
141 | border: 0;
142 | clip: rect(0 0 0 0);
143 | height: 1px;
144 | margin: -1px;
145 | overflow: hidden;
146 | padding: 0;
147 | position: absolute;
148 | width: 1px;
149 | }
150 |
151 | .legend ul.items-more:hover li {
152 | display: flex;
153 | }
154 |
155 | .legend ul.items-more:not(:hover) li {
156 | border: 0;
157 | clip: rect(0 0 0 0);
158 | height: 1px;
159 | margin: -1px;
160 | overflow: hidden;
161 | padding: 0;
162 | position: absolute;
163 | width: 1px;
164 | }
165 |
166 | .axes .title {
167 | text-anchor: middle;
168 | font-weight: bold;
169 | fill: var(--dark-private);
170 | }
171 |
172 | .axes:not(.angled) .x .tick text {
173 | text-anchor: middle;
174 | }
175 |
176 | .axes .axis.nominal.angled .tick text {
177 | text-anchor: end;
178 | }
179 |
180 | .axis.continuous .tick line {
181 | stroke: var(--light-private);
182 | }
183 |
184 | .play {
185 | font-size: x-small;
186 | padding: 0.1em 0.3em;
187 | cursor: pointer;
188 | position: absolute;
189 | right: 0;
190 | top: 0;
191 | }
192 |
193 | .play:hover {
194 | color: var(--highlight-private);
195 | }
196 |
197 | table {
198 | font-family: monospace;
199 | margin: auto;
200 | }
201 |
202 | table thead {
203 | font-weight: bold;
204 | }
205 |
206 | table td {
207 | padding: 0 0.5rem;
208 | }
209 |
210 | table td.quantitative {
211 | text-align: right;
212 | }
213 |
214 | table td div {
215 | border-radius: 100%;
216 | width: 0.5rem;
217 | height: 0.5rem;
218 | margin-left: 0.5rem;
219 | display: inline-block;
220 | }
221 |
222 | [data-highlight] {
223 | stroke: var(--highlight-private);
224 | }
225 |
--------------------------------------------------------------------------------
/source/legend.js:
--------------------------------------------------------------------------------
1 | /**
2 | * legend rendering
3 | * @module legend
4 | * @see {@link module:interactions}
5 | * @see {@link https://vega.github.io/vega-lite/docs/legend.html|vega-lite:legend}
6 | */
7 |
8 | import * as d3 from 'd3'
9 |
10 | import { dispatchers } from './interactions.js'
11 | import { encodingField } from './encodings.js'
12 | import { feature } from './feature.js'
13 | import { key, mark } from './helpers.js'
14 | import { layerPrimary } from './views.js'
15 | import { extension } from './extensions.js'
16 | import { parseScales } from './scales.js'
17 | import { renderStyles } from './styles.js'
18 | import { list } from './text.js'
19 |
20 | /**
21 | * color scale legend item
22 | * @param {object} config name and color
23 | * @return {object} DOM node
24 | */
25 | function createLegendItem(config) {
26 | const item = d3.create('li')
27 |
28 | item.attr('class', 'category pair')
29 |
30 | const color = d3.create('div')
31 |
32 | color.classed('color', true).style('background-color', config.color)
33 |
34 | const label = d3.create('div')
35 |
36 | label.classed('label', true).text(config.group)
37 | item.append(() => color.node())
38 | item.append(() => label.node())
39 |
40 | return item.node()
41 | }
42 |
43 | /**
44 | * look up the title of the legend
45 | * @param {object} s Vega Lite specification
46 | * @return {string} legend title
47 | */
48 | const legendTitle = s => {
49 | return s.encoding.color.legend?.title || s.encoding.color.title || encodingField(s, 'color')
50 | }
51 |
52 | /**
53 | * a string for identifiying the legend in the DOM
54 | * @param {object} s specification
55 | * @return {string|null} legend title identifier string
56 | */
57 | const legendIdentifier = s => {
58 | if (extension(s, 'id')) {
59 | return `legend-title-${extension(s, 'id')}`
60 | } else {
61 | return null
62 | }
63 | }
64 |
65 | /**
66 | * generate a written description for the legend
67 | * @param {object} s Vega Lite specification
68 | * @return {string} legend description
69 | */
70 | const legendDescription = s => {
71 | const description = s.encoding.color.legend?.description
72 | if (description) {
73 | return description
74 | }
75 | const domain = parseScales(s).color.domain()
76 | return `${mark(s)} legend titled '${legendTitle(s)}' with ${domain.length} values: ${list(domain)}`
77 | }
78 |
79 | /**
80 | * test whether a node is overflowing
81 | * @param {object} node DOM node
82 | * @param {number} node.clientWidth client width
83 | * @param {number} node.clientHeight client height
84 | * @param {number} node.scrollWidth scroll width
85 | * @param {number} node.scrollHeight scroll height
86 | * @return {boolean}
87 | */
88 | const isOverflown = ({ clientWidth, clientHeight, scrollWidth, scrollHeight }) => {
89 | return scrollHeight > clientHeight || scrollWidth > clientWidth
90 | }
91 |
92 | const legendStyles = {
93 | cornerRadius: 'border-radius',
94 | fillColor: 'background-color',
95 | padding: 'padding'
96 | }
97 |
98 | /**
99 | * style the legend based on the specification
100 | * @param {object} s Vega Lite specification
101 | * @return {function(object)} legend style renderer
102 | */
103 | const legendStyle = s => renderStyles(legendStyles, s.encoding?.color?.legend)
104 |
105 | /**
106 | * legend item configuration
107 | * @param {object} s Vega Lite specification
108 | * @param {number} index item in legend
109 | * @return {object} object describing the legend item
110 | */
111 | const itemConfig = (s, index) => {
112 | const scales = parseScales(s)
113 | const label = scales.color.domain()[index]
114 | const color = scales.color.range()[index]
115 | const itemConfig = {
116 | group: label,
117 | color
118 | }
119 | return itemConfig
120 | }
121 |
122 | /**
123 | * items to plot in swatch legend
124 | * @param {object} s Vega Lite specification
125 | * @return {string[]} domain for legend
126 | */
127 | const swatches = s => {
128 | return parseScales(s).color.domain()
129 | .filter(item => {
130 | return s.encoding.color?.legend?.values ? s.encoding.color.legend.values.includes(item) : true
131 | })
132 | }
133 |
134 | /**
135 | * discrete swatch legend
136 | * @param {object} s Vega Lite specification
137 | * @return {function(object)} renderer
138 | */
139 | const swatch = s => {
140 | const renderer = selection => {
141 | const titleIdentifier = feature(s).hasLegendTitle() ? selection.select('h3').attr('id') : null
142 | try {
143 | const dispatcher = dispatchers.get(selection.node())
144 |
145 | const channels = [
146 | 'color'
147 | ]
148 |
149 | if (channels.every(channel => s.encoding[channel].legend?.aria !== false)) {
150 | selection.attr('aria-description', legendDescription(s))
151 | } else {
152 | selection.attr('aria-hidden', true)
153 | }
154 |
155 | selection.call(legendStyle(s))
156 |
157 | const main = selection.append('div').classed('items-main', true).append('ul')
158 | const more = d3.create('ul').classed('items-more', true)
159 | const moreHeader = more.append('h3')
160 |
161 | main.attr('aria-labelledby', titleIdentifier)
162 |
163 | let target = main
164 |
165 | const items = swatches(s).map((_, index) => createLegendItem(itemConfig(s, index)))
166 |
167 | target.node().append(...items)
168 |
169 | const transplantItem = () => {
170 | let node = main.select('li:last-of-type').remove().node()
171 |
172 | if (node) {
173 | more.append(() => node)
174 | }
175 | }
176 |
177 | if (isOverflown(selection.node())) {
178 | while (isOverflown(selection.node())) {
179 | transplantItem()
180 | }
181 |
182 | // one more for good measure
183 | transplantItem()
184 |
185 | main.append(() => more.node())
186 | }
187 |
188 | const moreCount = more.selectAll('li').size()
189 |
190 | moreHeader.text(`+ ${moreCount} more`)
191 |
192 | // respond to mouseover events
193 |
194 | const addLegendHighlight = group => {
195 | selection
196 | .selectAll('.pair')
197 | .filter(function() {
198 | return key(d3.select(this).text()) === key(group)
199 | })
200 | .attr('data-highlight', true)
201 | }
202 |
203 | const removeLegendHighlight = () => {
204 | selection.selectAll('.pair').attr('data-highlight', null)
205 | }
206 |
207 | if (dispatcher) {
208 | dispatcher.on('addLegendHighlight', addLegendHighlight)
209 | dispatcher.on('removeLegendHighlight', removeLegendHighlight)
210 | }
211 | } catch (error) {
212 | error.message = `could not render legend - ${error.message}`
213 | throw error
214 | }
215 | }
216 |
217 | return renderer
218 | }
219 |
220 | /**
221 | * render chart legend
222 | * @param {object} _s Vega Lite specification
223 | * @return {function(object)} renderer
224 | */
225 | const legend = _s => {
226 | let s = feature(_s).hasLayers() ? layerPrimary(_s) : _s
227 | const id = legendIdentifier(_s)
228 | return selection => {
229 | if (feature(s).hasLegendTitle()) {
230 | const title = selection
231 | .append('h3')
232 | .classed('title', true)
233 | .text(legendTitle(s))
234 | if (id) {
235 | title.attr('id', id)
236 | }
237 | }
238 | if (feature(s).hasLegend() && (feature(s).isMulticolor() || feature(s).isCircular())) {
239 | selection.call(swatch(s))
240 | }
241 | }
242 | }
243 |
244 | export { legend }
245 |
--------------------------------------------------------------------------------
/source/lifecycle.js:
--------------------------------------------------------------------------------
1 | /**
2 | * add structure to initialization
3 | * @module lifecycle
4 | * @see {@link module:chart}
5 | * @see {@link module:interactions}
6 | */
7 |
8 | import { WRAPPER_CLASS } from './config.js'
9 | import { feature } from './feature.js'
10 | import { extension } from './extensions.js'
11 | import { chartLabel, chartDescription } from './descriptions.js'
12 | import { detach } from './helpers.js'
13 | import { fetchAll } from './fetch.js'
14 |
15 | /**
16 | * prepare the DOM of a specified element for rendering a chart
17 | * @param {object} s Vega Lite specification
18 | * @param {object} dimensions chart dimensions
19 | */
20 | const setupNode = (s, dimensions) => {
21 | const initializer = selection => {
22 | selection.html('')
23 |
24 | const chartNode = selection.append('div').classed('chart', true)
25 |
26 | const graphic = chartNode.append('div').classed('graphic', true)
27 |
28 | if (feature(s).hasLegend()) {
29 | chartNode.append('div').classed('legend', true)
30 | }
31 |
32 | if (feature(s).hasTable()) {
33 | chartNode.append('div').classed('table', true)
34 | }
35 |
36 | if (feature(s).hasDownload()) {
37 | chartNode.append('div').classed('menu', true)
38 | }
39 |
40 | const svg = graphic.append('svg')
41 |
42 | svg.attr('tabindex', 0)
43 |
44 | const wrapper = svg.append('g').classed(WRAPPER_CLASS, true)
45 |
46 | svg.attr('width', dimensions.x)
47 | svg.attr('role', 'document')
48 |
49 | if (!s.title?.text) {
50 | throw new Error('specification title is required')
51 | }
52 |
53 | svg.attr('aria-label', chartLabel(s))
54 | svg.attr('aria-description', chartDescription(s))
55 |
56 | if (feature(s).hasAxis()) {
57 | const axes = wrapper.append('g').classed('axes', true)
58 |
59 | axes.attr('aria-hidden', true)
60 |
61 | if (feature(s).hasEncodingX()) {
62 | axes.append('g').attr('class', 'x')
63 | }
64 |
65 | if (feature(s).hasEncodingY()) {
66 | axes.append('g').attr('class', 'y')
67 | }
68 | }
69 |
70 | const id = extension(s, 'id')
71 |
72 | if (id) {
73 | svg.attr('aria-labelledby', `title-${id}`)
74 | }
75 | }
76 |
77 | return detach(initializer)
78 | }
79 |
80 | const init = s => {
81 | return fetchAll(s)
82 | }
83 |
84 | export { setupNode, init }
85 |
--------------------------------------------------------------------------------
/source/markup.js:
--------------------------------------------------------------------------------
1 | /**
2 | * generate additional data attributes to be used in tests for DOM nodes
3 | * @module markup
4 | */
5 |
6 | const selectors = [
7 | '.graphic',
8 | 'svg',
9 | 'svg > title',
10 | '.wrapper',
11 | '.layer',
12 | '.marks',
13 | '.mark',
14 | // marks types are more specific and need to come after the more general .mark above
15 | '.marks .mark.point',
16 | '.marks .mark.text',
17 | '.axes',
18 | '.axes .x',
19 | '.axes .x .title',
20 | '.axes .x .tick',
21 | '.axes .y',
22 | '.axes .y .title',
23 | '.axes .y .tick',
24 | '.mark title',
25 | '.point title',
26 | '.tick',
27 | '.legend',
28 | '.legend .title',
29 | '.legend .pair',
30 | '.legend .items-main',
31 | '.legend .items-more',
32 | '.menu .item'
33 | ]
34 |
35 | /**
36 | * remove dot
37 | * @param {string} selector DOM selector string
38 | * @return {string} sanitized
39 | */
40 | const stripDots = selector => {
41 | const leading = selector.slice(0, 1) === '.'
42 | let result
43 |
44 | result = leading ? selector.slice(1) : selector
45 |
46 | return result.replace(/\./g, '-')
47 | }
48 |
49 | /**
50 | * filter out unwanted segments from CSS selectors
51 | * @param {string} segment DOM selector string
52 | * @return {boolean} string match
53 | */
54 | const isAllowedSegment = segment => {
55 | return segment !== '>'
56 | }
57 |
58 | /**
59 | * convert a selector string into a data attribute value
60 | * @param {string} selector DOM selector string
61 | * @return {string} attribute
62 | */
63 | const convertToTestSelector = selector => {
64 | return selector.split(' ').map(stripDots).filter(isAllowedSegment).join('-')
65 | }
66 |
67 | /**
68 | * add test selector attributes
69 | * @param {object} selection d3 selection
70 | */
71 | const testAttributes = selection => {
72 | selectors.forEach(selector => {
73 | selection.selectAll(selector).attr('data-test-selector', convertToTestSelector(selector))
74 | })
75 | }
76 |
77 | export { testAttributes }
78 |
--------------------------------------------------------------------------------
/source/memoize.js:
--------------------------------------------------------------------------------
1 | /**
2 | * cache function return values
3 | * @module memoize
4 | */
5 |
6 | const references = {
7 | count: 0,
8 | map: new WeakMap()
9 | }
10 |
11 | const argumentKey = arg => {
12 | const type = typeof arg
13 | const primitive = (type !== 'object' && type !== 'function') || arg === null || arg === undefined
14 |
15 | if (primitive) {
16 | return `${arg}`
17 | } else {
18 | const reference = references.map.get(arg)
19 | if (reference) {
20 | return `${type}-${reference}`
21 | } else {
22 | const id = `${references.count++}`
23 |
24 | references.map.set(arg, id)
25 |
26 | return `${type}-${id}`
27 | }
28 | }
29 | }
30 |
31 | const memoizeKey = args => {
32 | return args.map(argumentKey).join(' ')
33 | }
34 |
35 | /**
36 | * cache function results to avoid recomputation
37 | * @param {function} fn pure function to be cached
38 | * @return {function} memoized function which caches return values
39 | */
40 | const memoize = fn => {
41 | const cache = new Map()
42 |
43 | return function(...args) {
44 | const key = memoizeKey(args)
45 |
46 | if (cache.has(key)) {
47 | return cache.get(key)
48 | } else {
49 | const result = fn.apply(this, args)
50 |
51 | cache.set(key, result)
52 |
53 | return result
54 | }
55 | }
56 | }
57 |
58 | export { memoize }
59 |
--------------------------------------------------------------------------------
/source/menu.js:
--------------------------------------------------------------------------------
1 | /**
2 | * render a menu alongside the chart content
3 | * @module menu
4 | * @see {@link module:download}
5 | */
6 |
7 | import * as d3 from 'd3'
8 | import { feature } from './feature.js'
9 | import { extension } from './extensions.js'
10 | import { key } from './helpers.js'
11 | import { download } from './download.js'
12 |
13 | /**
14 | * create a menu configuration object for a data download
15 | * @param {object} s Vega Lite specification
16 | * @param {'csv'|'json'} format data format
17 | * @return {object} menu item configuration object
18 | */
19 | const item = (s, format) => {
20 | return { text: format, href: download(s, format) }
21 | }
22 |
23 | /**
24 | * determine menu content
25 | * @param {object} s Vega Lite specification
26 | * @return {object[]} menu item configuration objects
27 | */
28 | const items = s => {
29 | const download = feature(s).hasDownload() && extension(s, 'download')
30 | return [
31 | download?.csv !== false ? item(s, 'csv') : null,
32 | download?.json !== false ? item(s, 'json') : null,
33 | feature(s).hasTable() ? { text: 'table' } : null,
34 | ...(extension(s, 'menu')?.items ? extension(s, 'menu')?.items : [])
35 | ].filter(Boolean)
36 | }
37 |
38 | /**
39 | * render menu
40 | * @param {object} s Vega Lite specification
41 | * @return {function(object)} menu renderer
42 | */
43 | const menu = s => {
44 | return selection => {
45 | selection
46 | .select('.menu')
47 | .append('ul')
48 | .selectAll('li')
49 | .data(items(s))
50 | .enter()
51 | .append('li')
52 | .attr('data-menu', item => item ? key(item.text) : null)
53 | .classed('item', true)
54 | .each(function(item) {
55 | const tag = item.href ? 'a' : 'button'
56 | d3.select(this)
57 | .append(tag)
58 | .text(item => item.text)
59 | .attr('href', item.href ? item.href : null)
60 | })
61 | }
62 | }
63 |
64 | export { menu }
65 |
--------------------------------------------------------------------------------
/source/position.js:
--------------------------------------------------------------------------------
1 | /**
2 | * determine how to center the chart
3 | * @module position
4 | */
5 |
6 | import './types.d.js'
7 |
8 | import { GRID, WRAPPER_CLASS } from './config.js'
9 | import { feature } from './feature.js'
10 | import { longestAxisTickLabelTextWidth, rotation } from './text.js'
11 | import { memoize } from './memoize.js'
12 | import { polarToCartesian } from './helpers.js'
13 | import { maxRadius } from './marks.js'
14 | import { layerPrimary } from './views.js'
15 |
16 | const TITLE_MARGIN = GRID * 5
17 | const MARGIN_MAXIMUM = 180 + TITLE_MARGIN
18 |
19 | const axes = { x: 'bottom', y: 'left' }
20 |
21 | /**
22 | * compute margin for a circular chart
23 | * @return {margin} margin convention object
24 | */
25 | const marginCircular = () => {
26 | return {
27 | top: 0,
28 | right: 0,
29 | bottom: 0,
30 | left: 0
31 | }
32 | }
33 |
34 | /**
35 | * compute margin for Cartesian chart axis ticks
36 | * @param {object} s Vega Lite specification
37 | * @return {object} partial margin convention object
38 | */
39 | const tickMargin = s => {
40 | const textLabels = longestAxisTickLabelTextWidth(s)
41 | const result = {}
42 |
43 | Object.entries(axes).forEach(([channel, position]) => {
44 | const angle = rotation(s, channel)
45 |
46 | if (textLabels[channel] && typeof angle === 'number') {
47 | const coordinates = polarToCartesian(textLabels[channel], angle)
48 | const opposite = Object.keys(axes).find(axis => axis !== channel)
49 | const margin = Math.abs(coordinates[opposite])
50 |
51 | result[position] = Math.min(MARGIN_MAXIMUM, margin + GRID)
52 | }
53 | })
54 |
55 | return result
56 | }
57 |
58 | /**
59 | * compute margin for Cartesian chart axis title
60 | * @param {object} s Vega Lite specification
61 | * @return {{bottom: number, left: number}} partial margin convention object
62 | */
63 | const titleMargin = s => {
64 | return {
65 | bottom: feature(s).hasAxisTitleX() ? TITLE_MARGIN : 0,
66 | left: feature(s).hasAxisTitleY() ? TITLE_MARGIN : 0
67 | }
68 | }
69 |
70 | /**
71 | * compute margin for Cartesian chart
72 | * @param {object} s Vega Lite specification
73 | * @return {margin} margin convention object
74 | */
75 | const marginCartesian = s => {
76 | const defaultMargin = {
77 | top: GRID * 2,
78 | right: GRID * 2,
79 | bottom: GRID * 4,
80 | left: GRID * 4
81 | }
82 |
83 | const dynamicMargin = {}
84 |
85 | Object.values(axes).forEach(position => {
86 | dynamicMargin[position] =
87 | tickMargin(s)?.[position] + titleMargin(s)?.[position] + GRID
88 | })
89 |
90 | return {
91 | top: defaultMargin.top,
92 | right: defaultMargin.right,
93 | bottom: dynamicMargin.bottom || defaultMargin.bottom,
94 | left: dynamicMargin.left || defaultMargin.left
95 | }
96 | }
97 |
98 | const _margin = s => {
99 | if (feature(s).isCircular()) {
100 | return marginCircular()
101 | } else {
102 | return marginCartesian(layerPrimary(s))
103 | }
104 | }
105 |
106 | /**
107 | * compute margin values based on chart type
108 | * @param {object} s Vega Lite specification
109 | * @return {margin} margin convention object
110 | */
111 | const margin = memoize(_margin)
112 |
113 | /**
114 | * transform string for positioning charts
115 | * @param {object} s Vega Lite specification
116 | * @param {dimensions} dimensions chart dimensions
117 | * @return {function(object)} positioning function
118 | */
119 | const position = (s, dimensions) => {
120 | const yOffsetCircular =
121 | dimensions.x > dimensions.y ? (dimensions.y - maxRadius(dimensions) * 2) * 0.5 : 0
122 | const middle = {
123 | x: dimensions.x * 0.5,
124 | y: dimensions.y * 0.5 + yOffsetCircular
125 | }
126 |
127 | let margins
128 |
129 | const { left, top } = margin(s, dimensions)
130 |
131 | margins = {
132 | x: left,
133 | y: top
134 | }
135 |
136 | const transform = feature(s).isCircular() ? middle : margins
137 | const transformString = `translate(${transform.x},${transform.y})`
138 |
139 | return selection => {
140 | selection.select(`g.${WRAPPER_CLASS}`).attr('transform', transformString)
141 | }
142 | }
143 |
144 | export { margin, tickMargin, position }
145 |
--------------------------------------------------------------------------------
/source/predicate.js:
--------------------------------------------------------------------------------
1 | /**
2 | * generate functions which test features of the data points
3 | * @module predicate
4 | * @see {@link module:transform}
5 | * @see {@link https://vega.github.io/vega-lite/docs/predicate.html|vega-lite:predicate}
6 | */
7 |
8 | import { identity } from './helpers.js'
9 | import { memoize } from './memoize.js'
10 | import { expressionStringParse } from './expression.js'
11 |
12 | const value = (config, datum) => datum[config.field]
13 |
14 | /**
15 | * equal to predicate
16 | * @param {object} config predicate definition
17 | * @return {function(object)} filter predicate
18 | */
19 | const equal = config => datum => value(config, datum) === config.equal
20 |
21 | /**
22 | * less than predicate
23 | * @param {object} config predicate definition
24 | * @return {function(object)} filter predicate
25 | */
26 | const lt = config => datum => value(config, datum) < config.lt
27 |
28 | /**
29 | * less than or equal to predicate
30 | * @param {object} config predicate definition
31 | * @return {function(object)} filter predicate
32 | */
33 | const lte = config => datum => value(config, datum) <= config.lte
34 |
35 | /**
36 | * greater than predicate
37 | * @param {object} config predicate definition
38 | * @return {function(object)} filter predicate
39 | */
40 | const gt = config => datum => value(config, datum) > config.gt
41 |
42 | /**
43 | * greater than or equal to predicate
44 | * @param {object} config predicate definition
45 | * @return {function(object)} filter predicate
46 | */
47 | const gte = config => datum => value(config, datum) >= config.gte
48 |
49 | /**
50 | * range predicate
51 | * @param {object} config predicate definition
52 | * @return {function(object)} filter predicate
53 | */
54 | const range = config => datum => value(config, datum) >= config.range[0] && value(config, datum) <= config.range[1]
55 |
56 | /**
57 | * oneOf predicate
58 | * @param {object} config predicate definition
59 | * @return {function(object)} filter predicate
60 | */
61 | const oneOf = config => datum => config.oneOf.includes(value(config, datum))
62 |
63 | /**
64 | * valid predicate
65 | * @param {object} config predicate definition
66 | * @return {function(object)} filter predicate
67 | */
68 | const valid = config => datum => config.valid ? !Number.isNaN(value(config, datum)) && value(config, datum) !== null : true
69 |
70 | const predicates = {
71 | equal,
72 | lt,
73 | lte,
74 | gt,
75 | gte,
76 | range,
77 | oneOf,
78 | valid
79 | }
80 |
81 | /**
82 | * generate a predicate test function
83 | * @param {object|string} _config predicate definition
84 | * @return {function(object)} predicate test function
85 | */
86 | const single = _config => {
87 | const converter = typeof _config === 'string' ? expressionStringParse : identity
88 | const config = converter(_config)
89 | const [key, create] = Object.entries(predicates).find(([key]) => config[key])
90 | if (typeof create === 'function') {
91 | return create(config)
92 | } else {
93 | throw new Error(`could not create ${key} predicate function for data field ${config.field}`)
94 | }
95 | }
96 |
97 | /**
98 | * compose a single predicate function based on multiple predicate definitions
99 | * @param {object} config predicate definition
100 | * @return {function(object)} predicate test function
101 | */
102 | const compose = config => {
103 | const key = ['and', 'or', 'not'].find(key => config[key])
104 | const functions = config[key].map(single)
105 | const predicates = {
106 | and: datum => functions.every(fn => fn(datum) === true),
107 | not: datum => functions.every(fn => fn(datum) === false),
108 | or: datum => functions.some(fn => fn(datum) === true)
109 | }
110 | return predicates[key]
111 | }
112 |
113 | /**
114 | * generate a predicate test function
115 | * @param {object} config predicate definition
116 | * @return {function(object)} predicate test function
117 | */
118 | const _predicate = config => {
119 | const multiple = config.and || config.or || config.not
120 | try {
121 | if (multiple) {
122 | return compose(config)
123 | } else {
124 | return single(config)
125 | }
126 | } catch (error) {
127 | error.message = `could not create predicate function - ${error.message}`
128 | throw error
129 | }
130 | }
131 | const predicate = memoize(_predicate)
132 |
133 | export { predicate }
134 |
--------------------------------------------------------------------------------
/source/state.js:
--------------------------------------------------------------------------------
1 | /**
2 | * store the current state of a chart
3 | * @module state
4 | * @see {@link module:interactions}
5 | */
6 |
7 | /**
8 | * store current keyboard navigation position in a closure
9 | * @return {object} methods for modifying keyboard position state
10 | */
11 | const createState = () => {
12 | let index
13 |
14 | return {
15 | init() {
16 | if (index === undefined) {
17 | index = 0
18 | }
19 | },
20 | index(n) {
21 | return n === undefined ? index : ((index = n), true)
22 | }
23 | }
24 | }
25 |
26 | export { createState }
27 |
--------------------------------------------------------------------------------
/source/styles.js:
--------------------------------------------------------------------------------
1 | /**
2 | * render CSS in the DOM
3 | * @module styles
4 | */
5 |
6 | import { noop } from './helpers.js'
7 |
8 | /**
9 | * read styles from a JavaScript object and apply them as CSS properties
10 | * @param {object} styles key/value pairs of JavaScript and CSS properties
11 | * @param {object} source desired styles with JavaScript style keys
12 | */
13 | const renderStyles = (styles, source) => {
14 | if (source === undefined || source === null) {
15 | return noop
16 | }
17 | return selection => {
18 | Object.entries(styles)
19 | .forEach(([js, css]) => {
20 | const value = source[js]
21 | if (value !== undefined) {
22 | selection.style(css, value)
23 | }
24 | })
25 | }
26 | }
27 |
28 | export { renderStyles }
29 |
--------------------------------------------------------------------------------
/source/table.js:
--------------------------------------------------------------------------------
1 | /**
2 | * render a table of data values to accompany the graphic
3 | * @module table
4 | */
5 |
6 | import { extension } from './extensions.js'
7 | import { deduplicateByField, noop, show, hide } from './helpers.js'
8 | import { encodingField } from './encodings.js'
9 | import { layerPrimary } from './views.js'
10 | import { markData } from './marks.js'
11 | import { parseScales } from './scales.js'
12 | import { values } from './values.js'
13 | import { feature } from './feature.js'
14 |
15 | import * as d3 from 'd3'
16 |
17 | const tableSelector = '.chart > .table'
18 | const legendSelector = '.chart .legend'
19 | const graphicSelector = '.chart .graphic'
20 | const toggleSelector = '.menu [data-menu="table"] button'
21 |
22 | /**
23 | * create the outer DOM for the table
24 | * @param {object} s Vega Lite specification
25 | * @return {function(object)} table setup function
26 | */
27 | const setup = s => {
28 | return selection => {
29 | selection
30 | .append('table')
31 | .append('caption')
32 | .text(s.title.text)
33 | }
34 | }
35 |
36 | /**
37 | * column headers
38 | * @param {object} s Vega Lite specification
39 | * @return {string[]} array of column names
40 | */
41 | const channels = s => deduplicateByField(s)(Object.keys(s.encoding))
42 |
43 | /**
44 | * column encoding fields
45 | * @param {object} s Vega Lite specification
46 | * @return {string[]} array of column encoding fields
47 | */
48 | const fields = s => {
49 | const encodings = channels(s).map(channel => encodingField(s, channel))
50 | const first = values(s)[0]
51 | const other = Object.keys(first).filter(item => !encodings.includes(item))
52 | return [...encodings, ...other]
53 | }
54 |
55 | /**
56 | * create an ordered datum for data binding
57 | * @param {object} s datum
58 | * @return {function(object)} function to convert a datum into key/value pairs
59 | */
60 | const columnEntries = s => {
61 | return d => Object.entries(d)
62 | .sort((a, b) => fields(s).indexOf(a[0]) - fields(s).indexOf(b[0]))
63 | }
64 |
65 | /**
66 | * render table header
67 | * @param {object} s Vega Lite specification
68 | * @return {function(object)} header renderer
69 | */
70 | const header = s => {
71 | return selection => {
72 | const header = selection
73 | .select('table')
74 | .append('thead')
75 | .append('tr')
76 | header
77 | .selectAll('td')
78 | .data(fields(s))
79 | .enter()
80 | .append('td')
81 | .attr('scope', 'col')
82 | .text(d => d)
83 | }
84 | }
85 |
86 | /**
87 | * render table rows
88 | * @param {object} s Vega Lite specification
89 | * @return {function(object)} rows renderer
90 | */
91 | const rows = s => {
92 | return selection => {
93 | const cell = selection
94 | .select('table')
95 | .append('tbody')
96 | .selectAll('tr')
97 | .data(values(s))
98 | .enter()
99 | .append('tr')
100 | .attr('scope', 'row')
101 | .selectAll('td')
102 | .data(d => columnEntries(s)(d))
103 | .enter()
104 | .append('td')
105 | cell
106 | .attr('class', ([_, value]) => typeof value === 'number' ? 'quantitative' : null)
107 | .each(function([field, value]) {
108 | const href = encodingField(s, 'href') === field
109 | const target = href ? d3.select(this).append('a').attr('href', value) : d3.select(this)
110 | target.text(([_, value]) => value)
111 | })
112 | if (s.encoding.color?.field) {
113 | cell.filter(([key]) => key === encodingField(s, 'color'))
114 | .append('div')
115 | .style('background-color', ([_, value]) => parseScales(s).color(value))
116 | }
117 | }
118 | }
119 |
120 | /**
121 | * compile options to pass to an external table renderer
122 | * @param {object} s Vega Lite specification
123 | * @return {object} options for table rendering
124 | */
125 | const tableOptions = s => {
126 | return { data: { marks: markData(layerPrimary(s)) } }
127 | }
128 |
129 | /**
130 | * render table
131 | * @param {object} _s Vega Lite specification
132 | * @param {object} options table options
133 | * @return {function(object)} table renderer
134 | */
135 | const table = (_s, options) => { // eslint-disable-line no-unused-vars
136 | const s = layerPrimary(_s)
137 | if (extension(s, 'table') === null) {
138 | return noop
139 | }
140 | return selection => {
141 | selection
142 | .call(setup(_s))
143 | .call(header(s))
144 | .call(rows(s))
145 | }
146 | }
147 |
148 | /**
149 | * toggle the content from a graphic to a table
150 | * @param {object} s Vega Lite specification
151 | * @param {function} renderer custom table rendering function
152 | * @return {function(object)} table toggle interaction function
153 | */
154 | const tableToggle = (s, renderer) => {
155 | // input selection must be the outer chart wrapper
156 | // which includes both menu and content
157 | return selection => {
158 | if (!feature(s).hasTable()) {
159 | return
160 | }
161 | const toggle = selection.select(toggleSelector)
162 | const table = selection.select(tableSelector)
163 | const graphic = selection.select(graphicSelector)
164 | const legend = selection.select(legendSelector)
165 | toggle.on('click', function(event) {
166 | event.preventDefault()
167 | table.html('')
168 | const values = ['table', 'graphic']
169 | const previous = toggle.text()
170 | const next = values.filter(value => value !== previous).pop()
171 | if (previous === 'table') {
172 | table.call(renderer(s, tableOptions(s)))
173 | graphic.call(hide)
174 | legend.call(hide)
175 | } else if (previous === 'graphic') {
176 | graphic.call(show)
177 | legend.call(show)
178 | }
179 | toggle.text(next)
180 | })
181 | }
182 | }
183 |
184 | export { table, tableToggle }
185 |
--------------------------------------------------------------------------------
/source/time.js:
--------------------------------------------------------------------------------
1 | /**
2 | * timestamp computations
3 | * @module time
4 | */
5 |
6 | import './types.d.js'
7 |
8 | import * as d3 from 'd3'
9 | import { encodingChannelCovariateCartesian, encodingValue } from './encodings.js'
10 | import { memoize } from './memoize.js'
11 | import { feature } from './feature.js'
12 | import { barWidth } from './marks.js'
13 |
14 | const UTC = 'utc'
15 | const TIME = 'time'
16 |
17 | /**
18 | * convert date string in YYYY-MM-DD format to date object
19 | * @param {string} dateString string in YYYY-MM-DD format
20 | * @return {Date} date object
21 | */
22 | const timeParseYYYYMMDD = d3.utcParse('%Y-%m-%d')
23 |
24 | /**
25 | * convert date string in ISO8601 format to date object
26 | * @param {string} dateString string in ISO8601 format
27 | * @return {Date} date object
28 | */
29 | const timeParseIso = d3.isoParse
30 |
31 | /**
32 | * convert date in milliseconds to date object
33 | * @param {number} date number of milliseconds
34 | * @return {Date} date object
35 | */
36 | const timeParseMilliseconds = date => new Date(date)
37 |
38 | const _getTimeParser = date => {
39 | const parsers = [
40 | timeParseYYYYMMDD,
41 | timeParseIso,
42 | timeParseMilliseconds
43 | ]
44 | const isDate = parser => {
45 | const parsed = parser(date)
46 | const year = !!parsed && parsed.getFullYear()
47 |
48 | return typeof year === 'number' && !Number.isNaN(year)
49 | }
50 |
51 | // use the first date parsing function that works
52 | const parser = parsers.find(isDate)
53 |
54 | const isFunction = typeof parser === 'function'
55 |
56 | return isFunction ? parser : null
57 | }
58 |
59 | /**
60 | * select a function that can parse a date format
61 | * @param {(string|number)} date date representation
62 | * @return {function(Date)} date parsing function
63 | */
64 | const getTimeParser = memoize(_getTimeParser)
65 |
66 | /**
67 | * select a function that can parse a date format
68 | * @param {(string|number)} date date representation
69 | * @return {Date} date object
70 | */
71 | const _parseTime = date => {
72 | const parser = getTimeParser(date)
73 |
74 | if (typeof parser === 'function') {
75 | return parser(date)
76 | }
77 | }
78 | const parseTime = memoize(_parseTime)
79 |
80 | const findTimePeriod = new RegExp(`(?:${TIME}|${UTC})(\\w+)`, 'gi')
81 | /**
82 | * capitalize the time period in a time specifier string
83 | * @param {string} timeSpecifier lowercase time specifier string
84 | * @return {string} camelcased time specifier string
85 | */
86 | const camelCaseTimePeriod = timeSpecifier => {
87 | let matches = [...timeSpecifier.matchAll(findTimePeriod)]
88 |
89 | if (!matches.length) {
90 | return timeSpecifier
91 | }
92 |
93 | let [, period] = matches[0]
94 |
95 | return timeSpecifier.replace(period, period[0].toUpperCase() + period.slice(1))
96 | }
97 |
98 | /**
99 | * convert time specifier into d3 method name
100 | * @param {string} specifier time specifier string
101 | * @return {string} d3 time interval method name
102 | */
103 | const timeMethod = specifier => {
104 | const prefix = specifier.startsWith(UTC) ? '' : TIME
105 |
106 | return camelCaseTimePeriod(`${prefix}${specifier}`)
107 | }
108 |
109 | /**
110 | * string key for controlling date functionality
111 | * @param {object} s Vega Lite specification
112 | * @param {string} channel temporal encoding channel
113 | */
114 | const timePeriod = (s, channel) => {
115 | const unit = s.encoding[channel].timeUnit || 'utcday'
116 | const utc = unit.startsWith(UTC)
117 | const weekly = unit.endsWith('week')
118 | const prefix = utc ? UTC : TIME
119 | let period
120 |
121 | if (!utc) {
122 | period = unit
123 | } else {
124 | if (weekly) {
125 | const firstDate = parseTime(d3.min(s.data.values, encodingValue(s, channel)))
126 |
127 | period = d3.utcFormat('%A')(firstDate)
128 | } else {
129 | period = unit.slice(UTC.length)
130 | }
131 | }
132 |
133 | return camelCaseTimePeriod(`${prefix}${period}`)
134 | }
135 |
136 | /**
137 | * alter dimensions object to subtract the bar width
138 | * for a temporal bar chart
139 | * @param {object} s Vega Lite specification
140 | * @param {dimensions} dimensions chart dimensions
141 | * @return {dimensions} chart dimensions with bar width offset
142 | */
143 | const temporalBarDimensions = (s, dimensions) => {
144 | const offset = feature(s).isTemporalBar() ? barWidth(s, dimensions) : 0
145 | const channel = encodingChannelCovariateCartesian(s)
146 | return {
147 | ...dimensions,
148 | [channel]: dimensions[channel] - offset
149 | }
150 | }
151 |
152 | export { getTimeParser, parseTime, timePeriod, timeMethod, temporalBarDimensions }
153 |
--------------------------------------------------------------------------------
/source/transform.js:
--------------------------------------------------------------------------------
1 | /**
2 | * modify data before rendering
3 | * @module transform
4 | * @see {@link module:data}
5 | * @see {@link module:predicate}
6 | * @see {@link module:expression}
7 | * @see {@link https://vega.github.io/vega-lite/docs/transform.html|vega-lite:transform}
8 | */
9 |
10 | import { identity } from './helpers.js'
11 | import { memoize } from './memoize.js'
12 | import { predicate } from './predicate.js'
13 | import { expression } from './expression.js'
14 | import * as d3 from 'd3'
15 |
16 | /**
17 | * create a function to perform a single calculate expression
18 | * @param {string} str calculate expression
19 | * @return {function} expression evaluation function
20 | */
21 | const calculate = str => expression(str)
22 |
23 | /**
24 | * compose all calculate transforms
25 | * into a single function
26 | * @param {object[]} transforms transform configuration objects
27 | * @return {function(object)}
28 | */
29 | const _composeCalculateTransforms = transforms => {
30 | if (!transforms) {
31 | return () => identity
32 | }
33 | return d => {
34 | return transforms
35 | .filter(transform => transform.calculate)
36 | .reduce((previous, current) => {
37 | return {
38 | ...previous,
39 | [current.as]: calculate(current.calculate)({ ...d })
40 | }
41 | }, { ...d })
42 | }
43 | }
44 |
45 | /**
46 | * create a function to augment a datum with multiple calculate expressions
47 | * @param {object[]} transforms an array of calculate expressions
48 | * @return {function(object[])} transform function
49 | */
50 | const composeCalculateTransforms = memoize(_composeCalculateTransforms)
51 |
52 | /**
53 | * create a function to run transforms on a single datum
54 | * @param {object} s Vega Lite specification
55 | * @return {function(object)} transform function for a single datum
56 | */
57 | const transformDatum = s => {
58 | return composeCalculateTransforms(s.transform)
59 | }
60 |
61 | /**
62 | * run a random sampling transform
63 | * @param {number} n count
64 | * @return {function(object[])} random sampling function
65 | */
66 | const sample = n => {
67 | return data => {
68 | if (!n) {
69 | return data
70 | }
71 | return d3.shuffle(data.slice()).slice(0, n)
72 | }
73 | }
74 |
75 | /**
76 | * run a filter transform
77 | * @param {object} s Vega Lite specification
78 | * @param {object} config transform configuration
79 | * @return {function(object[])} filter transform function
80 | */
81 | const filter = (s, config) => {
82 | return data => {
83 | return data.filter(datum => {
84 | return predicate(config)(datum) || predicate(config)(transformDatum(s)(datum))
85 | })
86 | }
87 | }
88 |
89 | /**
90 | * fold fields
91 | * @param {object} config fold configuration
92 | * @return {function(object[])} fold transform function
93 | */
94 | const fold = config => {
95 | const fields = config.fold
96 | const [key, value] = config.as || ['key', 'value']
97 | return data => {
98 | return fields.map(field => {
99 | return data.map(item => {
100 | return { ...item, [key]: field, [value]: item[field] }
101 | })
102 | }).flat()
103 | }
104 | }
105 |
106 | /**
107 | * flatten fields
108 | * @param {object} config flatten configuration
109 | * @return {function(object[])} flatten transform function
110 | */
111 | const flatten = config => {
112 | const fields = config.flatten
113 | return data => {
114 | return data.map(item => {
115 | const longest = +d3.max(fields, d => item[d]?.length)
116 | return Array.from({ length: longest }).map((_, i) => {
117 | let result = { ...item }
118 | fields.forEach((field, index) => {
119 | const key = config.as ? config.as[index] : field
120 | result[key] = item[field][i] || null
121 | })
122 | return result
123 | }).flat()
124 | }).flat()
125 | }
126 | }
127 |
128 | /**
129 | * apply a single transform
130 | * @param {object} s Vega Lite specification
131 | * @param {object} config transform configuration
132 | * @param {object[]} data data set
133 | * @return {object[]} transformed data set
134 | */
135 | const applyTransform = (s, config, data) => {
136 | if (config.sample) {
137 | return sample(config.sample)(data)
138 | } else if (config.filter) {
139 | return filter(s, config.filter)(data)
140 | } else if (config.fold) {
141 | return fold(config)(data)
142 | } else if (config.flatten) {
143 | return flatten(config)(data)
144 | }
145 | }
146 |
147 | /**
148 | * create a function to run transforms on a data set
149 | * @param {object} s Vega Lite specification
150 | * @return {function(object[])} transform function for a data set
151 | */
152 | const _transformValues = s => {
153 | if (!s.transform) {
154 | return identity
155 | }
156 | return data => {
157 | return s.transform
158 | .filter(transform => !transform.calculate)
159 | .reduce((accumulator, current) => {
160 | return applyTransform(s, current, accumulator)
161 | }, data)
162 | }
163 | }
164 | const transformValues = memoize(_transformValues)
165 |
166 | export { calculate, transformDatum, transformValues }
167 |
--------------------------------------------------------------------------------
/source/types.d.js:
--------------------------------------------------------------------------------
1 | /**
2 | * chart dimensions
3 | * @typedef dimensions
4 | * @property {number} x horizontal dimension
5 | * @property {number} y vertical dimension
6 | */
7 |
8 | /**
9 | * encoding channel in cartesian space
10 | * @typedef {'x'|'y'} cartesian
11 | */
12 |
13 | /**
14 | * margin convention object
15 | * @typedef margin
16 | * @property {number} top top margin
17 | * @property {number} right right margin
18 | * @property {number} bottom bottom margin
19 | * @property {number} left left margin
20 | * @see {@link https://observablehq.com/@d3/margin-convention|margin convention}
21 | */
22 |
23 | /**
24 | * @typedef {('path'|'circle'|'rect'|'line'|'image'|'text')} mark
25 | */
26 |
27 | /**
28 | * @typedef {('audio'|'color'|'description'|'download'|'id'|'menu'|'table')} extension
29 | */
30 |
--------------------------------------------------------------------------------
/source/values.js:
--------------------------------------------------------------------------------
1 | /**
2 | * fetch, cache, and transform raw data
3 | * @module values
4 | * @see {@link module:data}
5 | * @see {@link module:fetch}
6 | * @see {@link module:transform}
7 | */
8 |
9 | import * as d3 from 'd3'
10 | import { cached } from './fetch.js'
11 | import { identity, nested } from './helpers.js'
12 | import { transformValues } from './transform.js'
13 | import { memoize } from './memoize.js'
14 |
15 | /**
16 | * get values from values property
17 | * @param {object} s Vega Lite specification
18 | * @return {object[]|object}
19 | */
20 | const valuesInline = s => s.data.values || s.data
21 |
22 | /**
23 | * get values from datasets property based on name
24 | * @param {object} s Vega Lite specification
25 | * @return {object[]}
26 | */
27 | const valuesTopLevel = s => s.datasets[s.data.name]
28 |
29 | /**
30 | * generate a data set
31 | * @param {object} s Vega Lite specification
32 | * @return {object[]} data
33 | */
34 | const valuesSequence = s => {
35 | const { start, stop, step } = s.data.sequence
36 | const values = d3.range(start, stop, (step || 1))
37 | const key = s.data.sequence.as || 'data'
38 | return values.map(item => {
39 | return { [key]: item }
40 | })
41 | }
42 |
43 | /**
44 | * look up data values attached to specification
45 | * @param {object} s Vega Lite specification
46 | * @return {object[]|object}
47 | */
48 | const valuesStatic = s => {
49 | if (s.data?.name) {
50 | return valuesTopLevel(s)
51 | } else if (s.data?.sequence) {
52 | return valuesSequence(s)
53 | } else {
54 | return valuesInline(s)
55 | }
56 | }
57 |
58 | /**
59 | * convert numbers to objects
60 | * @param {number[]} arr array of primitives
61 | * @return {object[]} array of objects
62 | */
63 | const wrap = arr => {
64 | if (!arr || typeof arr[0] === 'object') {
65 | return arr
66 | } else {
67 | try {
68 | return arr.map(item => {
69 | return { data: item }
70 | })
71 | } catch (error) {
72 | error.message = `could not convert primitives to objects - ${error.message}`
73 | throw error
74 | }
75 | }
76 | }
77 |
78 | /**
79 | * look up data from a nested object based on
80 | * a string of properties
81 | * @param {object} s Vega Lite specification
82 | * @return {function(object)}
83 | */
84 | const lookup = s => {
85 | if (s.data.format?.type !== 'json' || !s.data.format?.property) {
86 | return identity
87 | }
88 | return data => {
89 | return nested(data, s.data.format?.property)
90 | }
91 | }
92 |
93 | /**
94 | * get remote data from the cache
95 | * @param {object} s Vega Lite specification
96 | * @return {object[]} data set
97 | */
98 | const valuesCached = s => cached(s.data)
99 |
100 | const parsers = {
101 | number: d => +d,
102 | boolean: d => !!d,
103 | date: d => new Date(d),
104 | null: identity
105 | }
106 |
107 | /**
108 | * convert field types in an input datum object
109 | * @param {object} s Vega Lite specification
110 | * @return {function(object)} datum field parsing function
111 | */
112 | const parseFields = s => {
113 | if (!s.data?.format?.parse) {
114 | return identity
115 | }
116 | const parser = d => {
117 | let result = { ...d }
118 | for (const [key, type] of Object.entries(s.data.format.parse)) {
119 | result[key] = parsers[`${type}`](d[key])
120 | }
121 | return result
122 | }
123 | return data => data.map(parser)
124 | }
125 |
126 | /**
127 | * run all data transformation and utility functions
128 | * on an input data set
129 | * @param {object} s Vega Lite specification
130 | * @return {function(object[])} data processing function
131 | */
132 | const dataUtilities = s => {
133 | return data => {
134 | return transformValues(s)(wrap(parseFields(s)(lookup(s)(data)))).slice()
135 | }
136 | }
137 |
138 | /**
139 | * look up data values
140 | * @param {object} s Vega Lite specification
141 | * @return {object[]} data set
142 | */
143 | const _values = s => {
144 | if (!s.data) {
145 | return
146 | }
147 | const url = !!s.data.url
148 | return dataUtilities(s)(url ? valuesCached(s) : valuesStatic(s))
149 | }
150 | const values = memoize(_values)
151 |
152 | export { values }
153 |
--------------------------------------------------------------------------------
/stylelint.config.cjs:
--------------------------------------------------------------------------------
1 | const config = {
2 | "extends": "stylelint-config-standard"
3 | };
4 |
5 | module.exports = config;
--------------------------------------------------------------------------------
/tests/browser-shim.cjs:
--------------------------------------------------------------------------------
1 | class AudioContext {
2 | createOscillator = () => null;
3 | createGain = () => null;
4 | }
5 |
6 | const getBBox = () => {
7 | if (!window.SVGElement.prototype.getBBox) {
8 | window.SVGElement.prototype.getBBox = () => {
9 | return { height: 0, width: 0, x: 0, y: 0 };
10 | };
11 | }
12 | };
13 |
14 | const svg = () => {
15 | getBBox();
16 | };
17 |
18 | const url = () => {
19 | let counter = 0
20 | if (!window.URL?.createObjectURL) {
21 | window.URL.createObjectURL = () => {
22 | counter++
23 | return `https://test/${counter}`
24 | }
25 | }
26 | }
27 |
28 | const context = () => {
29 | const pixelsPerCharacter = 5;
30 | window.HTMLCanvasElement.prototype.getContext = () => {
31 | return {
32 | measureText: (text) => {
33 | return { width: text.length * pixelsPerCharacter };
34 | }
35 | };
36 | };
37 | };
38 |
39 | const canvas = () => {
40 | context();
41 | };
42 |
43 | const audio = () => {
44 | if (!window.AudioContext) {
45 | window.AudioContext = AudioContext;
46 | }
47 | };
48 |
49 | svg();
50 | canvas();
51 | audio();
52 | url();
53 |
--------------------------------------------------------------------------------
/tests/index.mustache:
--------------------------------------------------------------------------------
1 |
2 |
3 | Test Suite
4 |
5 |
13 |
14 |
15 |
16 |
17 |
18 |
19 | {{#serve_files}}
20 |
21 | {{/serve_files}}
22 |
23 |
24 |
--------------------------------------------------------------------------------
/tests/integration/aria-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, specificationFixture, testSelector } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > aria', function() {
7 | test('aria-label includes tooltip by default', assert => {
8 | const spec = specificationFixture('circular')
9 |
10 | spec.usermeta = { tooltipHandler: false }
11 |
12 | const element = render(spec)
13 |
14 | const marks = [...element.querySelectorAll(testSelector('mark'))]
15 | const labels = marks.map(mark => mark.getAttribute('aria-label'))
16 | const titles = marks.map(mark => mark.querySelector(testSelector('mark-title')))
17 | const match = labels.every((label, index) => label.includes(titles[index].textContent))
18 |
19 | assert.ok(match)
20 | })
21 |
22 | test('aria-label includes tooltip when tooltip channel is specified', assert => {
23 | const spec = specificationFixture('circular')
24 |
25 | spec.usermeta = { tooltipHandler: false }
26 |
27 | spec.mark.tooltip = true
28 | spec.encoding.tooltip = 'value'
29 |
30 | const element = render(spec)
31 |
32 | const marks = [...element.querySelectorAll(testSelector('mark'))]
33 | const labels = marks.map(mark => mark.getAttribute('aria-label'))
34 | const titles = marks.map(mark => mark.querySelector(testSelector('mark-title')).textContent)
35 | const match = labels.every((label, index) => label.includes(titles[index]))
36 |
37 | assert.ok(match)
38 | })
39 |
40 | test('aria-label can be set with description field', assert => {
41 | const spec = specificationFixture('circular')
42 | const element = render(spec);
43 |
44 | [...element.querySelectorAll(testSelector('mark'))].forEach(mark => {
45 | assert.ok(mark.getAttribute('aria-label'))
46 | })
47 | })
48 |
49 | test('aria-label can be set to a calculate transform field', assert => {
50 | const spec = specificationFixture('circular')
51 |
52 | spec.transform = [{ calculate: "'START:' + datum.value + ':END'", as: 'test' }]
53 | spec.encoding.description = { field: 'test' }
54 |
55 | const element = render(spec)
56 |
57 | assert.ok(element.querySelector(testSelector('mark')).getAttribute('aria-label').match(/^START.*END$/))
58 | })
59 |
60 | test('aria-label can diverge from tooltip', assert => {
61 | const spec = specificationFixture('circular')
62 |
63 | spec.usermeta = { tooltipHandler: false }
64 |
65 | spec.transform = [
66 | { calculate: "'a'", as: 'a' },
67 | { calculate: "'b'", as: 'b' }
68 | ]
69 | spec.encoding.description = { field: 'a' }
70 | spec.encoding.tooltip = { field: 'b' }
71 |
72 | const element = render(spec)
73 |
74 | const marks = [...element.querySelectorAll(testSelector('mark'))]
75 | const labels = marks.map(mark => mark.getAttribute('aria-label'))
76 | const titles = marks.map(mark => mark.querySelector(testSelector('mark-title')).textContent)
77 | const match = labels.every((label, index) => label === titles[index])
78 |
79 | assert.notOk(match)
80 | })
81 |
82 | test('every bar chart mark has an aria-label attribute by default', assert => {
83 | const spec = specificationFixture('categoricalBar')
84 | const element = render(spec)
85 | element.querySelectorAll(testSelector('mark')).forEach(mark => {
86 | assert.ok(mark.getAttribute('aria-label'))
87 | })
88 | })
89 |
90 | test('every circular chart mark has an aria-label attribute by default', assert => {
91 | const spec = specificationFixture('circular')
92 | const element = render(spec)
93 | element.querySelectorAll(testSelector('mark')).forEach(mark => {
94 | assert.ok(mark.getAttribute('aria-label'))
95 | })
96 | })
97 |
98 | test('every line chart point mark has an aria-label attribute by default', assert => {
99 | const spec = specificationFixture('line')
100 | const element = render(spec)
101 | element.querySelectorAll(testSelector('mark')).forEach(mark => {
102 | assert.ok(mark.getAttribute('aria-label'))
103 | })
104 | })
105 | })
106 |
--------------------------------------------------------------------------------
/tests/integration/axes-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
3 | import * as d3 from 'd3'
4 |
5 | const { module, test } = qunit
6 |
7 | module('integration > axes', function() {
8 | test('renders a chart with axes', assert => {
9 | const spec = specificationFixture('categoricalBar')
10 | const element = render(spec)
11 |
12 | const single = [testSelector('axes'), testSelector('axes-x'), testSelector('axes-y')]
13 |
14 | single.forEach(selector => assert.equal(element.querySelectorAll(selector).length, 1))
15 |
16 | assert.ok(element.querySelector(testSelector('tick')))
17 | })
18 |
19 | test('renders a chart with custom axis titles', assert => {
20 | const spec = specificationFixture('categoricalBar')
21 |
22 | spec.encoding.x.axis = { title: 'a' }
23 | spec.encoding.y.axis = { title: 'b' }
24 | const element = render(spec)
25 | assert.equal(element.querySelector(testSelector('axes-x-title')).textContent, spec.encoding.x.axis.title)
26 | assert.equal(element.querySelector(testSelector('axes-y-title')).textContent, spec.encoding.y.axis.title)
27 | })
28 |
29 | test('renders a chart without y-axis tick labels', assert => {
30 | const spec = specificationFixture('categoricalBar')
31 |
32 | spec.encoding.y.axis = { labels: false }
33 |
34 | const element = render(spec)
35 |
36 | const tickLabelText = element.querySelector(`${testSelector('axes-y')} .axis`).textContent
37 |
38 | assert.equal(tickLabelText, '')
39 | })
40 |
41 | test('renders a chart with custom axis tick intervals', assert => {
42 | const monthly = specificationFixture('temporalBar')
43 | const biannual = specificationFixture('temporalBar')
44 |
45 | const endpoints = d3.extent(monthly.data.values, d => +d.date)
46 | const years = endpoints[1] - endpoints[0]
47 |
48 | monthly.encoding.x.axis = { tickCount: { interval: 'utcmonth' } }
49 |
50 | let element
51 |
52 | element = render(monthly)
53 |
54 | const monthlyTicks = element.querySelectorAll(`${testSelector('axes-x')} .tick`)
55 |
56 | assert.ok(monthlyTicks.length > years)
57 |
58 | biannual.encoding.x.axis = { tickCount: { interval: 'utcyear', step: 2 } }
59 | element = render(biannual)
60 |
61 | const biannualTicks = element.querySelectorAll(`${testSelector('axes-x')} .tick`)
62 |
63 | assert.ok(biannualTicks.length < years)
64 | })
65 |
66 | test('renders a chart with custom axis tick steps', assert => {
67 | const spec = specificationFixture('line')
68 |
69 | const dates = spec.data.values.map(item => new Date(item.label))
70 | const differenceMilliseconds = Math.abs(Math.min(...dates) - Math.max(...dates))
71 | const differenceDays = Math.floor(differenceMilliseconds / (24 * 60 * 60 * 1000))
72 |
73 | spec.encoding.x.axis = { tickCount: { interval: 'utcday', step: 2 } }
74 |
75 | const element = render(spec)
76 |
77 | const ticks = element.querySelectorAll(`${testSelector('axes-x')} .tick`)
78 |
79 | assert.ok(ticks.length < differenceDays)
80 | })
81 |
82 | test('renders a chart without axis titles', assert => {
83 | const spec = specificationFixture('categoricalBar')
84 |
85 | spec.encoding.x.axis = { title: null }
86 | spec.encoding.y.axis = { title: null }
87 | const element = render(spec)
88 |
89 | const selectors = [testSelector('axes-x-title'), testSelector('axes-y-title')]
90 |
91 | selectors.forEach(selector => assert.notOk(element.querySelector(selector)))
92 | })
93 |
94 | test('renders a chart with truncated axis labels', assert => {
95 | const max = 20
96 | const spec = specificationFixture('categoricalBar')
97 |
98 | spec.encoding.x.axis = { labelLimit: max }
99 |
100 | const element = render(spec);
101 |
102 | [...element.querySelectorAll(`${testSelector('axes-x')} .tick text`)].forEach(node => {
103 | assert.ok(node.getBoundingClientRect().width <= max)
104 | })
105 | })
106 |
107 | test('renders a chart with custom axis title styling', assert => {
108 | const spec = specificationFixture('line')
109 | spec.encoding.x.axis.titleOpacity = 0.5
110 | const element = render(spec)
111 | const title = element.querySelector(testSelector('axes-x-title'))
112 | assert.equal(title.style.opacity, 0.5)
113 | })
114 |
115 | test('renders a chart with custom axis tick styling', assert => {
116 | const spec = specificationFixture('line')
117 | spec.encoding.x.axis.tickOpacity = 0.5
118 | const element = render(spec)
119 | const tick = element.querySelector(`${testSelector('axes-x')} .tick`)
120 | assert.equal(tick.style.opacity, 0.5)
121 | })
122 |
123 | test('disables axes', assert => {
124 | const spec = specificationFixture('categoricalBar')
125 | spec.encoding.x.axis = null
126 | spec.encoding.y.axis = null
127 | const element = render(spec)
128 | assert.notOk(element.querySelector('axes-x'))
129 | assert.notOk(element.querySelector('axes-y'))
130 | })
131 | })
132 |
--------------------------------------------------------------------------------
/tests/integration/categorical-bar-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import {
3 | nodesHavePositiveHeights,
4 | specificationFixture,
5 | render,
6 | testSelector
7 | } from '../test-helpers.js'
8 |
9 | const { module, test } = qunit
10 |
11 | const approximate = value => Math.round(value * 100) / 100
12 |
13 | module('integration > categorical-bar', function() {
14 | test('renders a categorical bar chart', assert => {
15 | const spec = specificationFixture('categoricalBar')
16 |
17 | const element = render(spec)
18 | const mark = testSelector('mark')
19 |
20 | assert.ok(element.querySelector(mark))
21 | assert.equal(element.querySelector(mark).tagName, 'rect')
22 |
23 | const nodes = [...element.querySelectorAll(mark)]
24 |
25 | assert.ok(
26 | nodesHavePositiveHeights(nodes),
27 | 'all mark rects have positive numbers as height attributes'
28 | )
29 |
30 | let baseline = nodes[0].getBoundingClientRect().bottom
31 |
32 | nodes.forEach((node, i) => {
33 | let { bottom } = node.getBoundingClientRect()
34 |
35 | assert.equal(
36 | approximate(bottom),
37 | approximate(baseline),
38 | `Rect #${i} starts at the correct position: about: ${approximate(baseline)}`
39 | )
40 | })
41 | })
42 |
43 | test('handles input data with all zero values', assert => {
44 | const spec = specificationFixture('categoricalBar')
45 |
46 | spec.data.values.forEach(item => {
47 | item.value = 0
48 | })
49 |
50 | const element = render(spec)
51 | element.querySelectorAll('rect.mark').forEach(mark => {
52 | assert.equal(mark.getAttribute('height'), 0)
53 | })
54 | })
55 | })
56 |
--------------------------------------------------------------------------------
/tests/integration/circular-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > circular', function() {
7 | test('renders a circular chart', assert => {
8 | const s = specificationFixture('circular')
9 | const element = render(s)
10 | const marks = [...element.querySelectorAll(testSelector('mark'))]
11 | assert.equal(marks.length, s.data.values.length)
12 | assert.ok(marks.every(item => item.tagName === 'path'))
13 | assert.equal(new Set(marks.map(item => item.style.fill)).size, s.data.values.length)
14 | })
15 |
16 | test('renders a circular chart with arbitrary field names', assert => {
17 | const s = specificationFixture('circular')
18 | const keys = {
19 | group: '•',
20 | label: '-',
21 | value: '+'
22 | }
23 | s.data.values = s.data.values.map(item => {
24 | Object.entries(keys).forEach(([original, altered]) => {
25 | item[altered] = item[original]
26 | delete item[original]
27 | })
28 | return item
29 | })
30 | Object.entries(s.encoding).forEach(([channel, definition]) => {
31 | const original = definition.field
32 | const altered = keys[original]
33 | s.encoding[channel].field = altered
34 | })
35 |
36 | const element = render(s)
37 |
38 | const marks = [...element.querySelectorAll(testSelector('mark'))]
39 | assert.equal(marks.length, s.data.values.length)
40 | assert.ok(marks.every(item => item.tagName === 'path'))
41 | assert.equal(new Set(marks.map(item => item.style.fill)).size, s.data.values.length)
42 | })
43 | })
44 |
--------------------------------------------------------------------------------
/tests/integration/description-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { specificationFixture, testSelector, render } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > description', () => {
7 | module('marks', () => {
8 | const charts = ['line', 'temporalBar', 'categoricalBar', 'circular', 'stackedBar']
9 | charts.forEach(chart => {
10 | test(`detects extent for ${chart} chart`, assert => {
11 | const s = specificationFixture(chart)
12 | const element = render(s)
13 | const labels = [
14 | ...element.querySelectorAll(testSelector('mark')),
15 | ...element.querySelectorAll(testSelector('marks-mark-point'))
16 | ].map(node => node.getAttribute('aria-label'))
17 | assert.ok(labels.some(item => item.includes('minimum value')), 'detects minimum value')
18 | assert.ok(labels.some(item => item.includes('maximum value')), 'detects maximum value')
19 | assert.ok(labels.some(item => !item.includes('minimum') && !item.includes('maximum')), 'some marks do not match extent')
20 | })
21 | })
22 | test('detects single minimum and maximum value', assert => {
23 | let s = specificationFixture('circular')
24 | s.data.values = [
25 | { group: 'a', value: 1 },
26 | { group: 'b', value: 2 },
27 | { group: 'c', value: 3 },
28 | { group: 'd', value: 4 },
29 | { group: 'e', value: 5 }
30 | ]
31 | const element = render(s)
32 | const labels = [
33 | ...element.querySelectorAll(testSelector('mark'))
34 | ].map(node => node.getAttribute('aria-label'))
35 | assert.equal(labels.filter(item => item.includes('minimum value')).length, 1, 'detects single minimum value')
36 | assert.equal(labels.filter(item => item.includes('maximum value')).length, 1, 'detects single maximum value')
37 | })
38 | test('detects multiple minimum and maximum values', assert => {
39 | let s = specificationFixture('circular')
40 | s.data.values = [
41 | { group: 'a', value: 1 },
42 | { group: 'b', value: 1 },
43 | { group: 'c', value: 3 },
44 | { group: 'd', value: 5 },
45 | { group: 'e', value: 5 }
46 | ]
47 | const element = render(s)
48 | const labels = [
49 | ...element.querySelectorAll(testSelector('mark'))
50 | ].map(node => node.getAttribute('aria-label'))
51 | assert.equal(labels.filter(item => item.includes('minimum value')).length, 2, 'detects multiple minimum values')
52 | assert.equal(labels.filter(item => item.includes('maximum value')).length, 2, 'detects multiple maximum values')
53 | })
54 | test('detects minimum and maximum values in multiple channels', assert => {
55 | const s = {
56 | title: {
57 | text: 'multiple channel extent demos'
58 | },
59 | mark: {
60 | type: 'point'
61 | },
62 | data: {
63 | values: [
64 | { a: 1, b: 1 },
65 | { a: 2, b: 1 },
66 | { a: 3, b: 3 },
67 | { a: 4, b: 5 },
68 | { a: 5, b: 5 }
69 | ]
70 | },
71 | encoding: {
72 | x: {
73 | field: 'a',
74 | type: 'quantitative'
75 | },
76 | y: {
77 | field: 'b',
78 | type: 'quantitative'
79 | }
80 | }
81 | }
82 |
83 | const element = render(s)
84 |
85 | const labels = [
86 | ...element.querySelectorAll(testSelector('marks-mark-point'))
87 | ].map(node => node.getAttribute('aria-label'))
88 | assert.equal(labels.filter(item => item.includes('minimum value of a')).length, 1, 'detects single minimum value in field a')
89 | assert.equal(labels.filter(item => item.includes('maximum value of a')).length, 1, 'detects single maximum value in field a')
90 | assert.equal(labels.filter(item => item.includes('minimum value of b')).length, 2, 'detects multiple minimum values in field b')
91 | assert.equal(labels.filter(item => item.includes('maximum value of b')).length, 2, 'detects multiple maximum values in field b')
92 | })
93 | })
94 | })
95 |
--------------------------------------------------------------------------------
/tests/integration/dimensions-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { chart } from '../../source/chart.js'
3 | import { testSelector, specificationFixture } from '../test-helpers.js'
4 | import * as d3 from 'd3'
5 |
6 | const { module, test } = qunit
7 |
8 | module('unit > dimensions', () => {
9 | const dimensions = { x: 500, y: 500 }
10 | test('renders a chart with separate dimensions object', assert => {
11 | const s = specificationFixture('circular')
12 | const selection = d3.create('svg:svg').append('svg:g')
13 | const renderer = chart(s, dimensions)
14 | selection.call(renderer)
15 | const marks = selection.selectAll(testSelector('mark'))
16 | assert.equal(marks.size(), s.data.values.length)
17 | })
18 | test('renders a chart with dimensions in specification', assert => {
19 | const s = specificationFixture('circular')
20 | s.width = dimensions.x
21 | s.height = dimensions.y
22 | const selection = d3.create('svg:svg').append('svg:g')
23 | const renderer = chart(s)
24 | selection.call(renderer)
25 | const marks = selection.selectAll(testSelector('mark'))
26 | assert.equal(marks.size(), s.data.values.length)
27 | })
28 | test('renders a chart with step dimension in specification', assert => {
29 | const s = specificationFixture('categoricalBar')
30 | s.width = { step: 50 }
31 | s.height = dimensions.y
32 | const selection = d3.create('svg:svg').append('svg:g')
33 | const renderer = chart(s)
34 | selection.call(renderer)
35 | const marks = selection.selectAll(testSelector('mark'))
36 | assert.equal(marks.size(), s.data.values.length)
37 | })
38 | })
39 |
--------------------------------------------------------------------------------
/tests/integration/download-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { testSelector, specificationFixture, render } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('unit > download', () => {
7 | test('renders download links', assert => {
8 | const element = render(specificationFixture('circular'))
9 | const items = [...element.querySelectorAll(testSelector('menu-item'))]
10 | const text = items.map(node => node.textContent)
11 | assert.ok(text.includes('csv'))
12 | assert.ok(text.includes('json'))
13 | })
14 | test('disables download links individually', assert => {
15 | const s = specificationFixture('circular')
16 | s.usermeta = {}
17 | s.usermeta.download = { csv: false }
18 | const element = render(s)
19 | const items = [...element.querySelectorAll(testSelector('menu-item'))]
20 | const text = items.map(node => node.textContent)
21 | assert.notOk(text.includes('csv'))
22 | assert.ok(text.includes('json'))
23 | })
24 | test('disables all download links', assert => {
25 | const s = specificationFixture('circular')
26 | s.usermeta = {}
27 | s.usermeta.download = null
28 | const element = render(s)
29 | const items = [...element.querySelectorAll(testSelector('menu-item'))]
30 | const text = items.map(node => node.textContent)
31 | assert.notOk(text.includes('csv'))
32 | assert.notOk(text.includes('json'))
33 | })
34 | })
35 |
--------------------------------------------------------------------------------
/tests/integration/legend-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, specificationFixture, testSelector } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > legend', function() {
7 | test('renders a chart with legend', assert => {
8 | const spec = specificationFixture('circular')
9 | const element = render(spec)
10 |
11 | assert.ok(element.querySelector(testSelector('legend')))
12 | })
13 |
14 | test('renders a chart with legend automatically omitted', assert => {
15 | const spec = specificationFixture('categoricalBar')
16 | const element = render(spec)
17 | assert.notOk(element.querySelector(testSelector('legend')))
18 | })
19 |
20 | test('renders a chart with legend explicitly omitted', assert => {
21 | const spec = specificationFixture('circular')
22 |
23 | spec.encoding.color.legend = null
24 | const element = render(spec)
25 |
26 | assert.equal(element.querySelector(testSelector('legend')), null)
27 | })
28 |
29 | test('renders a legend with all categories', assert => {
30 | const spec = specificationFixture('circular')
31 | const categories = [...new Set(spec.data.values.map(item => item.group))]
32 |
33 | const element = render(spec)
34 |
35 | assert.equal(element.querySelectorAll(testSelector('legend-pair')).length, categories.length)
36 | })
37 |
38 | test.skip('partitions legend into popup when content overflows', assert => {
39 | const spec = specificationFixture('circular')
40 | const element = render(spec)
41 | assert.ok(element.querySelector(testSelector('legend-items-more')))
42 | })
43 |
44 | test('renders legend in full when content does not overflow', assert => {
45 | const spec = specificationFixture('circular')
46 |
47 | const ids = new Map()
48 |
49 | // it's not possible to change the window size during these tests
50 | // so instead mutate the data set such that the strings are short
51 | spec.data.values = spec.data.values.map(item => {
52 | if (!ids.has(item.group)) {
53 | ids.set(item.group, `${ids.size + 1}`)
54 | }
55 |
56 | return { ...item, group: ids.get(item.group) }
57 | })
58 | const element = render(spec)
59 |
60 | assert.notOk(element.querySelector(testSelector('legend-items-more')))
61 | })
62 |
63 | test('uses aria-labelledby to associate legend title and content', assert => {
64 | const spec = specificationFixture('circular')
65 | spec.usermeta = { id: 'abdefg' }
66 | const element = render(spec)
67 | const legend = element.querySelector(testSelector('legend'))
68 | const title = legend.querySelector(testSelector('legend-title'))
69 | const id = title.getAttribute('id')
70 | const content = legend.querySelector('ul')
71 | const aria = content.getAttribute('aria-labelledby')
72 | assert.equal(id, aria)
73 | })
74 | })
75 |
--------------------------------------------------------------------------------
/tests/integration/line-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, specificationFixture, testSelector } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | const pointSelector = testSelector('marks-mark-point')
7 |
8 | module('integration > line', function() {
9 | test('renders a line chart', assert => {
10 | const spec = specificationFixture('line')
11 | const element = render(spec)
12 |
13 | const selector = testSelector('mark')
14 |
15 | assert.ok(element.querySelector(selector))
16 | assert.ok(element.querySelector(selector).getAttribute('d'))
17 |
18 | const pathStrings = [...element.querySelectorAll(selector)].map(node =>
19 | node.getAttribute('d')
20 | )
21 |
22 | pathStrings.forEach(pathString => {
23 | assert.ok(pathString.length > 0, 'path string is not empty')
24 | assert.equal((pathString.match(/NaN/g) || []).length, 0, 'path string does not contain NaN')
25 | })
26 | })
27 |
28 | test('renders a line chart with points', assert => {
29 | const spec = specificationFixture('line')
30 | const element = render(spec)
31 | assert.ok(element.querySelector(pointSelector))
32 | })
33 |
34 | test('renders a line chart without points', assert => {
35 | const spec = specificationFixture('line')
36 |
37 | delete spec.mark.point
38 | const element = render(spec)
39 | assert.notOk(element.querySelector(pointSelector))
40 | })
41 |
42 | test('renders a line chart with arbitrary field names', assert => {
43 | const spec = specificationFixture('line')
44 | const propertyMap = {
45 | label: '_',
46 | group: '*',
47 | value: '•'
48 | }
49 |
50 | spec.data.values = spec.data.values.map(item => {
51 | Object.entries(propertyMap).forEach(([a, b]) => {
52 | item[b] = item[a]
53 | delete item[a]
54 | })
55 |
56 | return { ...item }
57 | })
58 | Object.entries(spec.encoding).forEach(([, definition]) => {
59 | const old = definition.field
60 |
61 | definition.field = propertyMap[old]
62 | })
63 | const element = render(spec)
64 | assert.ok(element.querySelector(pointSelector))
65 | })
66 | })
67 |
--------------------------------------------------------------------------------
/tests/integration/menu-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > menu', function() {
7 | test('renders a menu', assert => {
8 | const element = render(specificationFixture('circular'))
9 | assert.ok(element.querySelector(testSelector('menu-item')))
10 | })
11 | test('adds custom menu items', assert => {
12 | const items = [
13 | { text: 'WCAG', href: 'https://www.w3.org/WAI/standards-guidelines/wcag/' },
14 | { text: 'Vega Lite', href: 'https://vega.github.io/vega-lite/' }
15 | ]
16 | const s = specificationFixture('circular')
17 | s.usermeta = {}
18 | s.usermeta.menu = { items }
19 | const element = render(s)
20 | const labels = [...element.querySelectorAll(testSelector('menu-item'))].map(node => node.textContent)
21 | items
22 | .map(item => item.text)
23 | .forEach(text => {
24 | assert.ok(labels.includes(text))
25 | })
26 | })
27 | })
28 |
--------------------------------------------------------------------------------
/tests/integration/pivot-test.js:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import { encodingField } from '../../source/encodings.js'
3 | import {
4 | render,
5 | marksWithUrls,
6 | specificationFixture,
7 | testSelector
8 | } from '../test-helpers.js'
9 | import qunit from 'qunit'
10 |
11 | const { module, test } = qunit
12 |
13 | module('integration > pivot urls', function() {
14 | const getUrl = item => d3.select(item).datum().url
15 |
16 | test('stacked bar chart pivot links', assert => {
17 | const spec = specificationFixture('stackedBar')
18 |
19 | spec.encoding.href = { field: 'url' }
20 | spec.data.values[0].url = 'https://www.example.com/a'
21 | spec.data.values[1].url = 'https://www.example.com/b'
22 |
23 | const element = render(spec)
24 | assert.equal(marksWithUrls(element).length, 2)
25 |
26 | const urls = marksWithUrls(element).map(getUrl)
27 |
28 | assert.notEqual(urls[0], urls[1])
29 | })
30 |
31 | test('categorical bar chart pivot links', assert => {
32 | const spec = specificationFixture('categoricalBar')
33 |
34 | spec.encoding.href = { field: 'url' }
35 | spec.data.values[0].url = 'https://www.example.com/a'
36 | spec.data.values[1].url = 'https://www.example.com/b'
37 |
38 | const element = render(spec)
39 | assert.equal(marksWithUrls(element).length, 2)
40 |
41 | const urls = marksWithUrls(element).map(getUrl)
42 |
43 | assert.notEqual(urls[0], urls[1])
44 | })
45 |
46 | test('line chart pivot links', assert => {
47 | const spec = specificationFixture('line')
48 |
49 | spec.encoding.href = { field: 'url' }
50 | spec.data.values[0].url = 'https://www.example.com/a'
51 | spec.data.values[1].url = 'https://www.example.com/b'
52 |
53 | const element = render(spec)
54 |
55 | // in this case because of the nested series we need to
56 | // bypass the marksWithUrls() helper in order to flatten the array
57 | // and likewise bypass the getUrl() helper since the mark datum has
58 | // already been retrieved during that flat map
59 | const data = [...element
60 | .querySelectorAll(testSelector('mark'))]
61 | .map(mark => d3.select(mark).datum().values)
62 | .flat()
63 |
64 | const marksWithUrls = data.filter(item => item.url)
65 |
66 | assert.equal(marksWithUrls.length, 2)
67 |
68 | const urls = marksWithUrls.map(item => item.url)
69 |
70 | assert.notEqual(urls[0], urls[1])
71 | })
72 |
73 | test('circular chart pivot links', assert => {
74 | const spec = specificationFixture('circular')
75 |
76 | spec.data.values = [
77 | { group: 'a', value: 1, url: 'https://www.example.com/a' },
78 | { group: 'a', value: 2, url: 'https://www.example.com/a' },
79 | { group: 'b', value: 3, url: 'https://www.example.com/b' },
80 | { group: 'b', value: 4, url: 'https://www.example.com/b' },
81 | { group: 'c', value: 5, url: 'https://www.example.com/c' },
82 | { group: 'c', value: 6, url: 'https://www.example.com/c' }
83 | ]
84 |
85 | spec.encoding.href = { field: 'url' }
86 |
87 | const element = render(spec)
88 |
89 | const getUrl = mark => d3.select(mark).datum().data[encodingField(spec, 'href')]
90 | const marks = [...element.querySelectorAll(testSelector('mark'))]
91 | const links = marks.filter(getUrl).map(getUrl)
92 |
93 | assert.equal(links.length, 3)
94 | assert.notEqual(links[0], links[1])
95 | assert.notEqual(links[1], links[2])
96 | assert.notEqual(links[0], links[2])
97 | })
98 | })
99 |
--------------------------------------------------------------------------------
/tests/integration/rules-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > rules', function() {
7 | test('renders rules', assert => {
8 | const spec = specificationFixture('rules')
9 | const element = render(spec)
10 |
11 | const markSelector = testSelector('mark')
12 | const axisSelectors = {
13 | y: testSelector('axes-y'),
14 | x: testSelector('axes-x')
15 | }
16 | const mark = [...element.querySelectorAll(markSelector)]
17 |
18 | assert.ok(element.querySelector(axisSelectors.y))
19 | assert.notOk(element.querySelector(axisSelectors.x))
20 | assert.ok(element.querySelector(markSelector))
21 | assert.equal(element.querySelector(markSelector).tagName, 'line')
22 |
23 | mark.forEach(item => {
24 | assert.equal(
25 | item.getAttribute('y1'),
26 | item.getAttribute('y2'),
27 | 'rule y attributes are the same'
28 | )
29 | assert.notEqual(
30 | item.getAttribute('x1'),
31 | item.getAttribute('x2'),
32 | 'rule x attributes are not the same'
33 | )
34 | })
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/tests/integration/single-bar-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > single bar', function() {
7 | test('renders a stacked single bar', assert => {
8 | const spec = specificationFixture('singleBar')
9 | const element = render(spec)
10 |
11 | const mark = testSelector('mark')
12 |
13 | assert.ok(element.querySelector(mark))
14 | assert.ok(element.querySelector(mark).tagName, 'rect')
15 |
16 | const nodes = [...element.querySelectorAll(mark)]
17 | const nodeHasPositiveHeight = node => Number(node.getAttribute('height')) >= 0
18 | const nodeHasZeroHeight = node => Number(node.getAttribute('height')) === 0
19 | const nodesHavePositiveHeights = nodes.every(nodeHasPositiveHeight)
20 | const nodesHaveZeroHeights = nodes.every(nodeHasZeroHeight)
21 |
22 | assert.ok(
23 | nodesHavePositiveHeights,
24 | 'all mark rects have positive numbers as height attributes'
25 | )
26 | assert.ok(!nodesHaveZeroHeights, 'some mark rects have nonzero height attributes')
27 | })
28 | })
29 |
--------------------------------------------------------------------------------
/tests/integration/size-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | const markSizes = element => {
7 | const marks = [...element.querySelectorAll(testSelector('marks-mark-point'))]
8 | const radii = marks.map(mark => mark.getAttribute('r'))
9 | return new Set(radii).size
10 | }
11 |
12 | module('integration > size', function() {
13 | test('renders a scatter plot with consistent mark size', assert => {
14 | const s = specificationFixture('scatterPlot')
15 | const element = render(s)
16 | assert.equal(markSizes(element), 1, 'marks have consistent size')
17 | })
18 | test('renders a bubble plot with variable mark size', assert => {
19 | const s = specificationFixture('scatterPlot')
20 | s.data.values = s.data.values.map(item => {
21 | return {
22 | ...item,
23 | _: Math.random()
24 | }
25 | })
26 | s.encoding.size = { field: '_', type: 'quantitative' }
27 | const element = render(s)
28 | assert.ok(markSizes(element) > 1, 'marks have variable size')
29 | })
30 | })
31 |
--------------------------------------------------------------------------------
/tests/integration/sort-test.js:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import qunit from 'qunit'
3 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
4 |
5 | const { module, test } = qunit
6 |
7 | module('integration > sort', function() {
8 | test('renders marks in ascending order', assert => {
9 | const spec = specificationFixture('categoricalBar')
10 |
11 | spec.encoding.x.sort = 'y'
12 | const element = render(spec)
13 |
14 | const markSelector = testSelector('mark')
15 |
16 | const marks = [...element.querySelectorAll(markSelector)]
17 | const data = marks.map(mark => d3.select(mark).datum())
18 | const values = data.map(item => item.data._.value)
19 | const sorted = values.slice().sort(d3.ascending)
20 |
21 | assert.deepEqual(values, sorted)
22 | })
23 | test('renders marks in descending order', assert => {
24 | const spec = specificationFixture('categoricalBar')
25 |
26 | spec.encoding.x.sort = '-y'
27 | const element = render(spec)
28 |
29 | const markSelector = testSelector('mark')
30 |
31 | const marks = [...element.querySelectorAll(markSelector)]
32 | const data = marks.map(mark => d3.select(mark).datum())
33 | const values = data.map(item => item.data._.value)
34 | const sorted = values.slice().sort(d3.descending)
35 |
36 | assert.deepEqual(values, sorted)
37 | })
38 | })
39 |
--------------------------------------------------------------------------------
/tests/integration/stacked-area-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > stacked area', function() {
7 | test('renders a stacked area chart', assert => {
8 | const spec = specificationFixture('stackedArea')
9 | const element = render(spec)
10 |
11 | const mark = testSelector('mark')
12 | const marks = [...element.querySelectorAll(mark)]
13 | const { field } = spec.encoding.color
14 | const categories = [...new Set(spec.data.values.map(item => item[field]))]
15 |
16 | assert.ok(marks.every(mark => mark.tagName === 'path'), 'every mark is a path node')
17 | assert.equal(marks.length, categories.length, 'one mark node per data category')
18 | })
19 | })
20 |
--------------------------------------------------------------------------------
/tests/integration/stacked-bar-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > stacked-bar', function() {
7 | test('renders a vertical stacked bar chart', assert => {
8 | const spec = specificationFixture('stackedBar')
9 | const element = render(spec)
10 |
11 | const mark = testSelector('mark')
12 |
13 | assert.ok(element.querySelector(mark))
14 | assert.ok(element.querySelector(mark).tagName, 'rect')
15 |
16 | const nodes = [...element.querySelectorAll(mark)]
17 | const nodeHasPositiveHeight = node => Number(node.getAttribute('height')) >= 0
18 | const nodeHasZeroHeight = node => Number(node.getAttribute('height')) === 0
19 | const nodesHavePositiveHeights = nodes.every(nodeHasPositiveHeight)
20 | const nodesHaveZeroHeights = nodes.every(nodeHasZeroHeight)
21 |
22 | assert.ok(
23 | nodesHavePositiveHeights,
24 | 'all mark rects have positive numbers as height attributes'
25 | )
26 | assert.ok(!nodesHaveZeroHeights, 'some mark rects have nonzero height attributes')
27 | })
28 |
29 | test('renders a horizontal stacked bar chart', assert => {
30 | const spec = specificationFixture('stackedBar')
31 |
32 | const { x, y } = spec.encoding
33 |
34 | // invert the encodings
35 | spec.encoding.x = y
36 | spec.encoding.y = x
37 |
38 | const element = render(spec)
39 |
40 | const mark = testSelector('mark')
41 |
42 | assert.ok(element.querySelector(mark))
43 | assert.equal(element.querySelector(mark).tagName, 'rect')
44 |
45 | const nodes = [...element.querySelectorAll(mark)]
46 | const nodeHasPositiveWidth = node => Number(node.getAttribute('width')) >= 0
47 | const nodeHasZeroWidth = node => Number(node.getAttribute('width')) === 0
48 | const nodesHavePositiveWidths = nodes.every(nodeHasPositiveWidth)
49 | const nodesHaveZeroWidths = nodes.every(nodeHasZeroWidth)
50 |
51 | assert.ok(nodesHavePositiveWidths, 'all mark rects have positive numbers as width attributes')
52 | assert.ok(!nodesHaveZeroWidths, 'some mark rects have nonzero width attributes')
53 | })
54 | })
55 |
--------------------------------------------------------------------------------
/tests/integration/table-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, specificationFixture, testSelector } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > table', function() {
7 | const selector = `${testSelector('menu-item')}[data-menu="table"] button`
8 | const event = () => new MouseEvent('click')
9 | test('renders a chart with a table item in the menu', assert => {
10 | const element = render(specificationFixture('circular'))
11 | assert.ok(element.querySelector(selector))
12 | })
13 | test('disables table item in the menu', assert => {
14 | const spec = specificationFixture('circular')
15 | spec.usermeta = { table: null }
16 | const element = render(spec)
17 | assert.notOk(element.querySelector(selector))
18 | })
19 | test('menu item toggles between table and graphic when clicked', assert => {
20 | const element = render(specificationFixture('circular'))
21 | const toggle = element.querySelector(selector)
22 | assert.equal(toggle.textContent, 'table')
23 | toggle.dispatchEvent(event())
24 | assert.equal(toggle.textContent, 'graphic')
25 | toggle.dispatchEvent(event())
26 | assert.equal(toggle.textContent, 'table')
27 | })
28 | test('content toggles between table and graphic when clicked', assert => {
29 | const element = render(specificationFixture('circular'))
30 | const table = element.querySelector('.table')
31 | const toggle = element.querySelector(selector)
32 | assert.notOk(table.querySelector('tr'), 'table view is inactive')
33 | toggle.dispatchEvent(event())
34 | assert.equal(table.querySelectorAll('tr').length, 9, 'table view is active')
35 | toggle.dispatchEvent(event())
36 | assert.notOk(table.querySelector('tr'), 'table view is inactive')
37 | })
38 | test('renders urls as links', assert => {
39 | const s = specificationFixture('circular')
40 | const count = s.data.values.length
41 | s.data.values = s.data.values.map((item, index) => {
42 | return { ...item, url: `https://example.com/${index}` }
43 | })
44 | s.encoding.href = { field: 'url' }
45 | const element = render(s)
46 | const toggle = element.querySelector(selector)
47 | const table = element.querySelector('.table')
48 | toggle.dispatchEvent(event())
49 | assert.equal(table.querySelectorAll('td a').length, count, `${count} links in table`)
50 | })
51 | })
52 |
--------------------------------------------------------------------------------
/tests/integration/text-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > text', function() {
7 | test('renders text marks', assert => {
8 | const spec = {
9 | $schema: 'https://vega.github.io/schema/vega-lite/v4.json',
10 | title: {
11 | text: 'text mark test'
12 | },
13 | data: {
14 | values: [{}]
15 | },
16 | mark: {
17 | type: 'text'
18 | },
19 | encoding: {
20 | text: {
21 | datum: 'A'
22 | }
23 | }
24 | }
25 |
26 | const element = render(spec)
27 |
28 | const markSelector = testSelector('mark')
29 |
30 | assert.equal(element.querySelectorAll(markSelector).length, 1)
31 | assert.equal(element.querySelector(markSelector).tagName, 'text')
32 | })
33 |
34 | test('renders text marks with dynamic attributes', assert => {
35 | const spec = specificationFixture('scatterPlot')
36 |
37 | spec.mark = { type: 'text' }
38 | spec.encoding.color = { field: 'section', type: 'nominal' }
39 | spec.encoding.text = { field: 'section', type: 'nominal' }
40 |
41 | const element = render(spec)
42 |
43 | const marks = [...element.querySelectorAll(testSelector('mark'))]
44 |
45 | const sections = [...new Set(spec.data.values.map(item => item.section)).values()]
46 |
47 | sections.forEach(section => {
48 | const matchingValues = spec.data.values.filter(item => item.section === section)
49 | const matchingContent = marks.filter(mark => mark.textContent === section)
50 |
51 | assert.equal(
52 | matchingValues.length,
53 | matchingContent.length,
54 | `data category ${section} has ${matchingValues.length} values and ${matchingContent.length} mark nodes`
55 | )
56 | })
57 |
58 | assert.ok(
59 | marks.every(mark => mark.hasAttribute('x')),
60 | 'every mark has x position'
61 | )
62 | assert.ok(
63 | marks.every(mark => mark.hasAttribute('y')),
64 | 'every mark has y position'
65 | )
66 | assert.ok(
67 | marks.every(mark => mark.hasAttribute('fill')),
68 | 'every mark has color'
69 | )
70 | })
71 | })
72 |
--------------------------------------------------------------------------------
/tests/integration/time-series-bar-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import {
3 | nodesHavePositiveHeights,
4 | render,
5 | specificationFixture,
6 | testSelector
7 | } from '../test-helpers.js'
8 |
9 | const { module, test } = qunit
10 |
11 | const approximate = value => Math.round(value * 100) / 100
12 |
13 | module('integration > temporal-bar', function() {
14 | test('renders a time series bar chart', assert => {
15 | const spec = specificationFixture('temporalBar')
16 |
17 | const element = render(spec)
18 | const mark = testSelector('mark')
19 |
20 | assert.ok(element.querySelector(mark))
21 | assert.equal(element.querySelector(mark).tagName, 'rect')
22 |
23 | const nodes = [...element.querySelectorAll(mark)]
24 |
25 | assert.ok(
26 | nodesHavePositiveHeights(nodes),
27 | 'all mark rects have positive numbers as height attributes'
28 | )
29 |
30 | let baseline = nodes[0].getBoundingClientRect().bottom
31 |
32 | nodes.forEach((node, i) => {
33 | let { bottom } = node.getBoundingClientRect()
34 |
35 | assert.equal(
36 | approximate(bottom),
37 | approximate(baseline),
38 | `Rect #${i} starts at the correct position: about: ${approximate(baseline)}`
39 | )
40 | })
41 | })
42 |
43 | test('handles input data with all zero values', assert => {
44 | const spec = specificationFixture('temporalBar')
45 |
46 | spec.data.values.forEach(item => {
47 | item.value = 0
48 | })
49 |
50 | const element = render(spec)
51 | element.querySelectorAll('rect.mark').forEach(mark => {
52 | assert.equal(mark.getAttribute('height'), 0)
53 | })
54 | })
55 | })
56 |
--------------------------------------------------------------------------------
/tests/integration/views-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { render, specificationFixture, testSelector } from '../test-helpers.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('integration > views', function() {
7 | test('renders a chart without layers', assert => {
8 | const spec = specificationFixture('line')
9 | const element = render(spec)
10 |
11 | assert.notOk(element.querySelector(testSelector('layer')))
12 | })
13 |
14 | test('renders a chart with one layer', assert => {
15 | const spec = { ...specificationFixture('line') }
16 | const lineLayer = {
17 | mark: spec.mark,
18 | encoding: spec.encoding
19 | }
20 |
21 | delete spec.mark
22 | delete spec.encoding
23 | spec.layer = [lineLayer]
24 | const element = render(spec)
25 |
26 | assert.ok(element.querySelector(testSelector('layer')))
27 | })
28 |
29 | test('renders a chart with two layers', assert => {
30 | const spec = { ...specificationFixture('line') }
31 |
32 | const lineLayer = {
33 | mark: spec.mark,
34 | encoding: spec.encoding
35 | }
36 |
37 | delete spec.mark
38 | delete spec.encoding
39 |
40 | const ruleLayer = {
41 | data: { values: [{}] },
42 | encoding: { y: { datum: 15 } },
43 | mark: { type: 'rule', size: 2 }
44 | }
45 |
46 | spec.layer = [lineLayer, ruleLayer]
47 | const element = render(spec)
48 |
49 | assert.equal(element.querySelectorAll(testSelector('layer')).length, 2)
50 | })
51 |
52 | test('renders a chart with nested layer data', assert => {
53 | const lineChartSpec = specificationFixture('line')
54 | const layerSpec = JSON.parse(JSON.stringify(lineChartSpec))
55 | const lineLayer = {
56 | data: { values: [...lineChartSpec.data.values] },
57 | mark: { ...lineChartSpec.mark },
58 | encoding: { ...lineChartSpec.encoding }
59 | }
60 |
61 | delete layerSpec.mark
62 | delete layerSpec.encoding
63 | layerSpec.layer = [lineLayer]
64 |
65 | const element = render(layerSpec)
66 |
67 | assert.ok(element.querySelector(testSelector('layer')))
68 | assert.ok(element.querySelector(testSelector('mark')))
69 | })
70 | })
71 |
--------------------------------------------------------------------------------
/tests/test-helpers.js:
--------------------------------------------------------------------------------
1 | import { stackedAreaChartSpec } from '../fixtures/stacked-area.js'
2 | import { categoricalBarChartSpec } from '../fixtures/categorical-bar.js'
3 | import { circularChartSpec } from '../fixtures/circular.js'
4 | import { dotPlotSpec } from '../fixtures/dot-plot.js'
5 | import { groupedBarChartSpec } from '../fixtures/grouped-bar.js'
6 | import { lineChartSpec } from '../fixtures/line.js'
7 | import { multilineChartSpec } from '../fixtures/multiline.js'
8 | import { rulesSpec } from '../fixtures/rules.js'
9 | import { scatterPlotSpec } from '../fixtures/scatter-plot.js'
10 | import { stackedBarChartSpec } from '../fixtures/stacked-bar.js'
11 | import { temporalBarChartSpec } from '../fixtures/temporal-bar.js'
12 | import { singleBarChartSpec } from '../fixtures/single-bar.js'
13 | import { select } from 'd3'
14 | import { chart } from '../source/chart.js'
15 |
16 | const testSelector = string => `[data-test-selector="${string}"]`
17 |
18 | const render = (specification, dimensions = { x: 500, y: 500 }) => {
19 | const node = document.createElement('div')
20 | select(node).call(chart(specification, dimensions))
21 | return node
22 | }
23 |
24 | const TEST_SELECTORS = {
25 | tooltipContent: 'chart-tooltip-content',
26 | marks: 'marks',
27 | mark: 'mark'
28 | }
29 |
30 | const marksWithUrls = element => {
31 | return [
32 | ...element.querySelectorAll(testSelector(TEST_SELECTORS.mark))
33 | ].filter(mark => select(mark).datum().url)
34 | }
35 |
36 | const nodesHavePositiveHeights = nodes =>
37 | nodes.every(node => {
38 | return Number(node.getAttribute('height')) >= 0
39 | })
40 |
41 | /**
42 | * retrieve a static prefabricated
43 | * Vega Lite specification to use
44 | * for tests
45 | * @param {string} type chart type
46 | * @return {object} Vega Lite specification
47 | */
48 | function specificationFixture(type) {
49 | let spec
50 |
51 | if (type === 'stackedBar') {
52 | spec = stackedBarChartSpec
53 | } else if (type === 'stackedArea') {
54 | spec = stackedAreaChartSpec
55 | } else if (type === 'singleBar') {
56 | spec = singleBarChartSpec
57 | } else if (type === 'circular') {
58 | spec = circularChartSpec
59 | } else if (type === 'line') {
60 | spec = lineChartSpec
61 | } else if (type === 'multiline') {
62 | spec = multilineChartSpec
63 | } else if (type === 'categoricalBar') {
64 | spec = categoricalBarChartSpec
65 | } else if (type === 'groupedBar') {
66 | spec = groupedBarChartSpec
67 | } else if (type === 'temporalBar') {
68 | spec = temporalBarChartSpec
69 | } else if (type === 'dotPlot') {
70 | spec = dotPlotSpec
71 | } else if (type === 'rules') {
72 | spec = rulesSpec
73 | } else if (type === 'scatterPlot') {
74 | spec = scatterPlotSpec
75 | } else {
76 | console.error(`unknown specification fixture type ${type}`)
77 | }
78 |
79 | const result = JSON.parse(JSON.stringify(spec))
80 |
81 | return result
82 | }
83 |
84 | export { render, testSelector, marksWithUrls, nodesHavePositiveHeights, specificationFixture }
85 |
--------------------------------------------------------------------------------
/tests/testem.cjs:
--------------------------------------------------------------------------------
1 | 'use strict';
2 |
3 | module.exports = {
4 | framework: 'qunit',
5 | test_page: './tests/index.mustache',
6 | launch_in_ci: ['Chrome'],
7 | launch_in_dev: ['Chrome'],
8 | tap_quiet_logs: true,
9 | src_files: [
10 | "source/**/*.js",
11 | "tests/**/*.js"
12 | ],
13 | serve_files: [
14 | "tests/**/*.js"
15 | ]
16 | };
--------------------------------------------------------------------------------
/tests/unit/axes-test.js:
--------------------------------------------------------------------------------
1 | import {
2 | abbreviate,
3 | format,
4 | rotation,
5 | truncate
6 | } from '../../source/text.js'
7 | import qunit from 'qunit'
8 | import { specificationFixture } from '../test-helpers.js'
9 |
10 | const { module, test } = qunit
11 |
12 | module('unit > axes', () => {
13 | test('retrieves x axis text rotation', assert => {
14 | const s = specificationFixture('stackedBar')
15 |
16 | s.encoding.x.axis = { labelAngle: 90 }
17 | assert.equal(rotation(s, 'x'), Math.PI / 2)
18 | })
19 | test('abbreviates axis tick label text', assert => {
20 | const s = {
21 | data: { values: [{ value: 0 }, { value: 10000 }, { value: 100000 }] },
22 | encoding: {
23 | x: {
24 | field: 'value',
25 | type: 'quantitative'
26 | }
27 | }
28 | }
29 | const text = abbreviate(s, { x: 100, y: 100 }, 'x')(100000)
30 |
31 | assert.ok(text.endsWith('K'))
32 | })
33 | test('formats axis tick label text', assert => {
34 | const s = {
35 | encoding: {
36 | x: {
37 | type: 'temporal',
38 | axis: { format: '%Y-%m-%d' }
39 | },
40 | y: {
41 | type: 'nominal'
42 | }
43 | }
44 | }
45 |
46 | const date = new Date(2020, 1, 15)
47 |
48 | assert.equal(format(s, 'x')(date), '2020-02-15', 'parses timestamps')
49 |
50 | const value = 1
51 |
52 | assert.equal(format(s, 'y')(value), '1', 'casts numbers to strings')
53 | })
54 | test('truncates axis tick label text', assert => {
55 | const text = 'aaaaaaaaaaaaaaaaaaaaaaaaaa'
56 | const truncated = truncate(text, 15, [])
57 |
58 | assert.ok(truncated.length < text.length, 'truncated text is shorter than original text')
59 | assert.ok(truncated.endsWith('…'), 'truncated text ends with ellipsis')
60 | })
61 | })
62 |
--------------------------------------------------------------------------------
/tests/unit/color-test.js:
--------------------------------------------------------------------------------
1 | import { parseScales } from '../../source/scales.js'
2 | import qunit from 'qunit'
3 | import { specificationFixture } from '../test-helpers.js'
4 |
5 | const { module, test } = qunit
6 |
7 | module('unit > color', () => {
8 | const defaultColor = 'steelblue'
9 | const newColor = 'green'
10 | test('color scales use default single color', assert => {
11 | const s = specificationFixture('categoricalBar')
12 | const { color } = parseScales(s)
13 | assert.equal(color(), defaultColor)
14 | })
15 | test('color scales use custom single color based on value encoding', assert => {
16 | const s = specificationFixture('categoricalBar')
17 | s.encoding.color = { value: newColor }
18 | const { color } = parseScales(s)
19 | assert.equal(color(), newColor)
20 | })
21 | test('generates accessible color palettes', assert => {
22 | const range = s => parseScales(s).color.range().join(' ')
23 | const standard = range(specificationFixture('circular'));
24 | ['tritanopia', 'deuteranopia', 'protanopia'].forEach(variant => {
25 | const s = specificationFixture('circular')
26 | s.usermeta = { color: { variant } }
27 | assert.notEqual(range(s), standard, variant)
28 | })
29 | })
30 | test('supports named color schemes', assert => {
31 | const normal = specificationFixture('circular')
32 | const normalRange = parseScales(normal).color.range()
33 | const accent = specificationFixture('circular')
34 | accent.encoding.color.scale = { scheme: 'accent' }
35 | const accentRange = parseScales(accent).color.range()
36 | assert.notEqual(normalRange.join(' '), accentRange.join(' '))
37 | })
38 | })
39 |
--------------------------------------------------------------------------------
/tests/unit/data-test.js:
--------------------------------------------------------------------------------
1 | import { data } from '../../source/data.js'
2 | import { encodingField } from '../../source/encodings.js'
3 | import { getTimeParser } from '../../source/time.js'
4 | import qunit from 'qunit'
5 | import { specificationFixture } from '../test-helpers.js'
6 |
7 | const { module, test } = qunit
8 |
9 | module('unit > data', () => {
10 | module('sources', () => {
11 | test('retrieves values from top level datasets property', assert => {
12 | const s = specificationFixture('circular')
13 | const name = '_'
14 | s.datasets = { [name]: s.data.values }
15 | delete s.data.values
16 | s.data.name = name
17 | const valid = item => typeof item === 'object' && typeof item.key === 'string' && typeof item.value === 'number'
18 | assert.ok(data(s).every(valid))
19 | })
20 | test('wraps primitives in objects', assert => {
21 | const s = {
22 | data: { values: [1, 2, 3] },
23 | mark: { type: 'point' },
24 | encoding: { x: { field: 'data', type: 'quantitative' } }
25 | }
26 | assert.ok(data(s).every(item => typeof item.data === 'number'))
27 | })
28 | test('generates data sequences', assert => {
29 | const s = {
30 | data: { sequence: {
31 | start: 0,
32 | stop: 100,
33 | step: 10,
34 | as: '_'
35 | } },
36 | mark: { type: 'point' },
37 | encoding: { x: { field: 'data', type: 'quantitative' } }
38 | }
39 | assert.ok(data(s).length, 10)
40 | assert.ok(data(s).every(item => typeof item._ === 'number'))
41 | })
42 | })
43 | module('chart forms', () => {
44 | test('compiles stacked bar data', assert => {
45 | const stacked = data(specificationFixture('stackedBar'))
46 |
47 | assert.ok(
48 | stacked.every(item => Array.isArray(item)),
49 | 'stacked bar data returns arrays'
50 | )
51 | assert.ok(
52 | stacked.every(item => {
53 | return item.every(point => point.length === 2)
54 | }),
55 | 'every item in the stack is an array of two points'
56 | )
57 | assert.ok(
58 | stacked.every(item => typeof item.key === 'string'),
59 | 'each series has a string key'
60 | )
61 | assert.ok(
62 | stacked.every(item => typeof item.index === 'number'),
63 | 'each series has a numerical index'
64 | )
65 | })
66 |
67 | test('compiles single-series data using a placeholder encoding key when a series encoding is not present', assert => {
68 | const barData = data(specificationFixture('categoricalBar'))
69 |
70 | assert.ok(Array.isArray(barData), 'data function returns an array')
71 | assert.equal(barData.length, 1, 'single series')
72 | assert.equal(barData[0].key, '_', 'series key is an underscore')
73 | })
74 |
75 | test('compiles single-series data using the original encoding key when a series encoding is present', assert => {
76 | const specification = specificationFixture('categoricalBar')
77 | const field = 'a'
78 | const value = 'b'
79 | specification.data.values = specification.data.values.map(item => {
80 | item[field] = value
81 | return item
82 | })
83 | specification.encoding.color = {
84 | field,
85 | type: 'nominal'
86 | }
87 | const barData = data(specification)
88 | assert.ok(Array.isArray(barData), 'data function returns an array')
89 | assert.equal(barData.length, 1, 'single series')
90 | assert.equal(barData[0].key, value, 'series key is the encoding field')
91 | })
92 |
93 | test('computes circular chart data', assert => {
94 | const segments = data(specificationFixture('circular'))
95 | const keys = segments.every(item => typeof item.key === 'string')
96 |
97 | assert.ok(keys, 'every segment has a key')
98 |
99 | const values = segments.every(item => typeof item.value === 'number')
100 |
101 | assert.ok(values, 'every segment has a value')
102 | })
103 |
104 | test('computes radial chart data', assert => {
105 | const s = specificationFixture('circular')
106 | s.data.values = s.data.values.map(item => {
107 | return {
108 | ...item,
109 | _: item.value
110 | }
111 | })
112 | s.encoding.radius = { field: '_', type: 'quantitative' }
113 | const segments = data(s)
114 | const keys = segments.every(item => typeof item.key === 'string')
115 |
116 | assert.ok(keys, 'every segment has a key')
117 |
118 | assert.ok(segments.every(item => typeof item._ === 'number'), 'every segment has a radius value')
119 |
120 | assert.ok(segments.every(item => typeof item.value === 'number'), 'every segment has a theta value')
121 | })
122 |
123 | test('compiles line chart data', assert => {
124 | const spec = specificationFixture('multiline')
125 | const dailyTotals = data(spec, encodingField(spec, 'x'))
126 | const groupNames = dailyTotals.every(
127 | item => typeof item[encodingField(spec, 'color')] === 'string'
128 | )
129 |
130 | assert.ok(groupNames, 'every series specifies a group')
131 |
132 | const valueArrays = dailyTotals.every(item => Array.isArray(item.values))
133 |
134 | assert.ok(valueArrays, 'every series includes an array of values')
135 |
136 | const parser = getTimeParser(dailyTotals[0].values[0].period)
137 | const periods = dailyTotals.every(series => {
138 | return series.values.every(item => typeof parser(item.period).getFullYear === 'function')
139 | })
140 |
141 | assert.ok(periods, 'every item specifies a valid time period')
142 |
143 | const values = dailyTotals.every(series => {
144 | return series.values.every(item => typeof item[encodingField(spec, 'y')] === 'number')
145 | })
146 |
147 | assert.ok(values, 'every item includes a value')
148 | })
149 | })
150 | })
151 |
--------------------------------------------------------------------------------
/tests/unit/description-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { specificationFixture } from '../test-helpers.js'
3 | import { axisDescription, chartDescription } from '../../source/descriptions.js'
4 |
5 | const { module, test } = qunit
6 |
7 | module('unit > description', () => {
8 | module('axis', () => {
9 | const charts = ['line', 'multiline', 'categoricalBar', 'scatterPlot', 'stackedArea', 'stackedBar', 'temporalBar']
10 | const prohibited = ['undefined', 'NaN', 'null']
11 | const channels = ['x', 'y']
12 | charts.forEach(chart => {
13 | test(chart, assert => {
14 | const s = specificationFixture(chart)
15 | channels.forEach(channel => {
16 | const text = axisDescription(s, channel)
17 | const valid = text.length && prohibited.every(substring => text.includes(substring) === false)
18 | assert.ok(valid, `generates ${channel} axis description`)
19 | })
20 | })
21 | })
22 | })
23 |
24 | module('chart', () => {
25 | const specification = () => {
26 | return {
27 | mark: {
28 | type: 'arc'
29 | },
30 | encoding: {
31 | theta: {
32 | field: 'size',
33 | type: 'quantitative'
34 | },
35 | color: {
36 | field: 'type',
37 | type: 'nominal'
38 | }
39 | }
40 | }
41 | }
42 | test('generates chart description based on encodings', assert => {
43 | const s = specification()
44 | const description = chartDescription(s)
45 | assert.equal(description, 'Pie chart of size split by type. Use the arrow keys to navigate.')
46 | })
47 | test('describes how to open links with keyboard navigation', assert => {
48 | const s = specification()
49 | s.encoding.href = { field: 'url' }
50 | const description = chartDescription(s)
51 | assert.ok(description.includes('Enter key'))
52 | assert.ok(description.includes('open links'))
53 | })
54 | test('prepends custom chart description', assert => {
55 | const s = specification()
56 | s.description = 'this is a chart with a custom description!'
57 | const description = chartDescription(s)
58 | assert.equal(description, 'this is a chart with a custom description! Pie chart of size split by type. Use the arrow keys to navigate.')
59 | })
60 | })
61 | })
62 |
--------------------------------------------------------------------------------
/tests/unit/error-test.js:
--------------------------------------------------------------------------------
1 | import { chart } from '../../source/chart.js'
2 | import * as d3 from 'd3'
3 | import qunit from 'qunit'
4 |
5 | const { module, test } = qunit
6 |
7 | const specification = {
8 | title: {
9 | text: 'this specification will cause an error'
10 | },
11 | encoding: {},
12 | mark: {}
13 | }
14 |
15 | module('unit > error handling', () => {
16 | test('catches errors by default', assert => {
17 | const renderer = chart(specification)
18 | assert.equal(renderer.error(), console.error)
19 | })
20 | test('uses custom error handlers', assert => {
21 | const dimensions = { x: 500, y: 500 }
22 | let x = null
23 | const handler = error => x = error
24 | const renderer = chart(specification, dimensions).error(handler)
25 | d3.create('div').call(renderer)
26 | assert.ok(x instanceof Error)
27 | })
28 | test('disables error handling', assert => {
29 | const renderer = chart(specification).error(null)
30 | assert.throws(() => renderer())
31 | })
32 | })
33 |
--------------------------------------------------------------------------------
/tests/unit/expression-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { expression, expressionStringParse } from '../../source/expression.js'
3 |
4 | const { module, test } = qunit
5 |
6 | module('expression', () => {
7 | module('concatenation', () => {
8 | test('interpolates properties', assert => {
9 | const exp = "'>-' + datum.a"
10 | const datum = { a: '•' }
11 | assert.equal(expression(exp)(datum), '>-•')
12 | })
13 | test('interpolates multiple properties', assert => {
14 | const exp = "'>-' + datum.a + '-' + datum.b"
15 | const datum = { a: '•', b: '*' }
16 | assert.equal(expression(exp)(datum), '>-•-*')
17 | })
18 | test('omits malformed string interpolations', assert => {
19 | const exp = "'>' + '-' + datum.a + '-*"
20 | const datum = { a: '•' }
21 | assert.equal(expression(exp)(datum), '>-•')
22 | })
23 | })
24 | module('functions', () => {
25 | test('random()', assert => {
26 | const exp = 'random()'
27 | assert.equal(typeof expression(exp)(), 'number')
28 | })
29 | test('now()', assert => {
30 | const exp = 'now()'
31 | assert.equal(typeof expression(exp)(), 'number')
32 | assert.equal(expression(exp)(), Date.now())
33 | })
34 | })
35 | module('string expressions', () => {
36 | test('converts string expressions to object form', assert => {
37 | const string = 'datum.x === 1'
38 | assert.equal(expressionStringParse(string).field, 'x')
39 | assert.equal(expressionStringParse(string).equal, 1)
40 | })
41 | })
42 | })
43 |
--------------------------------------------------------------------------------
/tests/unit/feature-test.js:
--------------------------------------------------------------------------------
1 | import { feature } from '../../source/feature.js'
2 | import qunit from 'qunit'
3 |
4 | const { module, test } = qunit
5 |
6 | module('unit > feature', () => {
7 | test('identifies chart features', assert => {
8 | assert.ok(feature({ mark: { type: 'bar' } }).isBar(), 'identifies bar charts')
9 | assert.ok(feature({ mark: { type: 'arc' } }).isCircular(), 'identifies circular charts')
10 | })
11 |
12 | test('always creates feature test methods', assert => {
13 | const s = {}
14 | const tests = feature(s)
15 | assert.equal(typeof tests.hasData, 'function', 'successfully creates feature tests for an empty input object')
16 | Object.keys(tests).forEach(key => {
17 | assert.equal(typeof tests[key](), 'boolean', `feature test ${key} returns a boolean`)
18 | })
19 | })
20 | })
21 |
--------------------------------------------------------------------------------
/tests/unit/format-test.js:
--------------------------------------------------------------------------------
1 | import { format } from '../../source/format.js'
2 | import qunit from 'qunit'
3 |
4 | const { module, test } = qunit
5 |
6 | const date = new Date(2000, 1, 1)
7 | const number = 1000
8 |
9 | module('format', () => {
10 | module('types', () => {
11 | test('number', assert => {
12 | const config = {
13 | format: '~s',
14 | formatType: 'number'
15 | }
16 | assert.equal(format(config)(number), '1k')
17 | })
18 | test('time', assert => {
19 | const config = {
20 | format: '%Y',
21 | formatType: 'time'
22 | }
23 | assert.equal(format(config)(date), '2000')
24 | })
25 | })
26 | module('contexts', () => {
27 | test('channel definition', assert => {
28 | const channel = { field: 'x', type: 'temporal', format: '%Y' }
29 | assert.equal(format(channel)(date), '2000')
30 | })
31 | test('axis definition', assert => {
32 | const channel = { field: 'x', type: 'temporal', axis: { format: '%Y' } }
33 | assert.equal(format(channel)(date), '2000')
34 | })
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/tests/unit/helpers-test.js:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import * as helpers from '../../source/helpers.js'
3 | import qunit from 'qunit'
4 |
5 | const { module, test } = qunit
6 |
7 | module('unit > helpers', () => {
8 | test('converts polar coordinates to cartesian coordinates', assert => {
9 | const radius = 10
10 | const right = helpers.polarToCartesian(radius, 0)
11 | const up = helpers.polarToCartesian(radius, Math.PI * 0.5)
12 | const left = helpers.polarToCartesian(radius, Math.PI)
13 | const down = helpers.polarToCartesian(radius, Math.PI * 1.5)
14 | const loop = helpers.polarToCartesian(radius, Math.PI * 2)
15 |
16 | assert.equal(right.x, radius)
17 | assert.equal(up.y, radius)
18 | assert.equal(left.x, radius * -1)
19 | assert.equal(down.y, radius * -1)
20 | assert.equal(loop.x, right.x)
21 | })
22 | test('renders to a detached node', assert => {
23 | const renderer = selection => {
24 | selection.append('g').classed('test', true).append('circle').attr('r', 10)
25 | }
26 | const selection = d3.create('svg')
27 | selection.call(helpers.detach(renderer))
28 | assert.ok(selection.select('g.test').size(), 1)
29 | assert.ok(selection.select('circle').size(), 1)
30 | })
31 | test('deduplicates channels by field', assert => {
32 | const s = {
33 | encoding: {
34 | 'x': { field: 'a' },
35 | 'y': { field: 'a' }
36 | }
37 | }
38 | assert.equal(helpers.deduplicateByField(s)(Object.keys(s.encoding)).length, 1)
39 | })
40 | })
41 |
--------------------------------------------------------------------------------
/tests/unit/interactions-test.js:
--------------------------------------------------------------------------------
1 | import { dispatchers } from '../../source/interactions.js'
2 | import {
3 | render,
4 | specificationFixture,
5 | testSelector
6 | } from '../test-helpers.js'
7 | import qunit from 'qunit'
8 |
9 | const { module, test } = qunit
10 |
11 | const validateDispatcher = dispatcher => {
12 | return ['link', 'tooltip'].every(key => typeof dispatcher._[key] === 'object')
13 | }
14 |
15 | module('unit > interactions', function() {
16 | test('registers an event dispatcher for charts with single layer', assert => {
17 | const element = render(specificationFixture('circular'))
18 |
19 | const mark = element.querySelector(testSelector('mark'))
20 |
21 | const dispatcher = dispatchers.get(mark)
22 | assert.ok(validateDispatcher(dispatcher), 'layered node has associated event dispatcher')
23 | })
24 |
25 | test('registers an event dispatcher for charts with multiple layers', assert => {
26 | const s = {
27 | title: {
28 | text: 'donut chart'
29 | },
30 | data: {
31 | values: [
32 | { value: 10, group: '*' },
33 | { value: 20, group: '_' },
34 | { value: 30, group: '•' }
35 | ]
36 | },
37 | usermeta: {
38 | customTooltipHandler: event => {
39 | alert(event)
40 | }
41 | },
42 | layer: [
43 | {
44 | mark: {
45 | type: 'arc',
46 | innerRadius: 50
47 | },
48 | encoding: {
49 | color: {
50 | field: 'group',
51 | type: 'nominal'
52 | },
53 | theta: {
54 | field: 'value',
55 | type: 'quantitative'
56 | }
57 | }
58 | },
59 | {
60 | mark: {
61 | type: 'text',
62 | text: 60
63 | },
64 | encoding: {
65 | href: {
66 | value: 'https://www.example.com'
67 | }
68 | }
69 | }
70 | ]
71 | }
72 |
73 | const element = render(s)
74 |
75 | const layers = [...element.querySelectorAll(testSelector('layer'))]
76 | layers.forEach((layer, index) => {
77 | const dispatcher = dispatchers.get(layer)
78 | assert.ok(validateDispatcher(dispatcher), `layer node at index ${index} has an associated event dispatcher`)
79 | })
80 | })
81 | })
82 |
--------------------------------------------------------------------------------
/tests/unit/internals-test.js:
--------------------------------------------------------------------------------
1 | import { dimensions } from './support.js'
2 | import qunit from 'qunit'
3 | import { createAccessors } from '../../source/accessors.js'
4 | import { parseScales } from '../../source/scales.js'
5 | import { createEncoders } from '../../source/encodings.js'
6 | import { data } from '../../source/data.js'
7 | import { feature } from '../../source/feature.js'
8 | import { marks } from '../../source/marks.js'
9 | import { specificationFixture } from '../test-helpers.js'
10 |
11 | const { module, test } = qunit
12 |
13 | const charts = [
14 | 'categoricalBar',
15 | 'circular',
16 | 'dotPlot',
17 | 'line',
18 | 'multiline',
19 | 'rules',
20 | 'scatterPlot',
21 | 'singleBar',
22 | 'stackedArea',
23 | 'temporalBar'
24 | ]
25 |
26 | module('unit > internals', () => {
27 | test('feature()', assert => {
28 | charts.forEach(chart => assert.equal(typeof feature(specificationFixture(chart)), 'object', chart))
29 | })
30 | test('data()', assert => {
31 | charts.forEach(chart => assert.ok(Array.isArray(data(specificationFixture(chart))), chart))
32 | })
33 | test('createAccessors()', assert => {
34 | charts.forEach(chart => assert.equal(typeof createAccessors(specificationFixture(chart)), 'object', chart))
35 | })
36 | test('parseScales()', assert => {
37 | charts.forEach(chart => assert.equal(typeof parseScales(specificationFixture(chart)), 'object', chart))
38 | })
39 | test('createEncoders()', assert => {
40 | charts.forEach(chart => {
41 | assert.equal(typeof createEncoders(specificationFixture(chart), dimensions, createAccessors(specificationFixture(chart))), 'object', chart)
42 | })
43 | })
44 | test('marks()', assert => {
45 | charts.forEach(chart => assert.equal(typeof marks(specificationFixture(chart), dimensions), 'function', chart))
46 | })
47 | })
48 |
--------------------------------------------------------------------------------
/tests/unit/marks-test.js:
--------------------------------------------------------------------------------
1 | import { barWidth, markSelector } from '../../source/marks.js'
2 | import qunit from 'qunit'
3 | import { specificationFixture } from '../test-helpers.js'
4 |
5 | const { module, test } = qunit
6 |
7 | module('unit > marks', () => {
8 | module('bar width', () => {
9 | const dimensions = { x: 1000, y: 1000 }
10 |
11 | test('return value', assert => {
12 | const specification = specificationFixture('categoricalBar')
13 |
14 | assert.equal(
15 | typeof barWidth(specification, dimensions),
16 | 'number',
17 | 'bar width is a number'
18 | )
19 | })
20 |
21 | test('temporal encoding', assert => {
22 | const specification = specificationFixture('temporalBar')
23 | const dates = new Set(specification.data.values.map(item => item.label)).size
24 |
25 | assert.ok(
26 | barWidth(specification, dimensions) <= dimensions.x / dates,
27 | 'time series bar width sized according to number of timestamps'
28 | )
29 | })
30 |
31 | test('nominal encoding', assert => {
32 | const specification = specificationFixture('categoricalBar')
33 | const categories = specification.data.values.map(item => item.label).length
34 |
35 | assert.ok(
36 | barWidth(specification, dimensions) <= dimensions.x / categories,
37 | 'time series bar width sized according to number of categories'
38 | )
39 | })
40 |
41 | test('temporal gap', assert => {
42 | const specification = specificationFixture('stackedBar')
43 | specification.data.values = [
44 | { label: '2020-01-01', value: 10, group: 'a' },
45 | { label: '2020-01-01', value: 20, group: 'b' },
46 | { label: '2020-01-02', value: 30, group: 'a' },
47 | { label: '2020-01-02', value: 40, group: 'b' }
48 | ]
49 | assert.ok(
50 | barWidth(specification, dimensions) <= dimensions.x / 3,
51 | 'gap left between two time series bars'
52 | )
53 | })
54 |
55 | test('nominal gap', assert => {
56 | const specification = specificationFixture('categoricalBar')
57 | specification.data.values = [
58 | { group: 'a', value: 10 },
59 | { group: 'b', value: 20 }
60 | ]
61 | assert.ok(
62 | barWidth(specification, dimensions) <= dimensions.x / 3,
63 | 'gap left between two categorical bars'
64 | )
65 | })
66 |
67 | test('iterates through temporal periods for custom domains', assert => {
68 | const standardSpec = specificationFixture('temporalBar')
69 | const customSpec = specificationFixture('temporalBar')
70 | const standardWidth = barWidth(standardSpec, dimensions)
71 | customSpec.encoding.x.scale = { domain: ['2010', '2020'] }
72 | const customWidth = barWidth(customSpec, dimensions)
73 | assert.ok(standardWidth > customWidth)
74 | })
75 | })
76 | module('point marks', () => {
77 | test('point', assert => {
78 | assert.equal(markSelector(specificationFixture('scatterPlot')), 'circle')
79 | })
80 | test('circle', assert => {
81 | assert.equal(markSelector(specificationFixture('scatterPlot')), 'circle')
82 | })
83 | test('square', assert => {
84 | const s = specificationFixture('scatterPlot')
85 | s.mark.type = 'square'
86 | assert.equal(markSelector(s), 'rect')
87 | })
88 | })
89 | })
90 |
--------------------------------------------------------------------------------
/tests/unit/memoize-test.js:
--------------------------------------------------------------------------------
1 | import { memoize } from '../../source/memoize.js'
2 | import qunit from 'qunit'
3 |
4 | const { module, test } = qunit
5 |
6 | module('unit > memoize', () => {
7 | test('memoizes functions', assert => {
8 | const fn = input => {
9 | return Math.random() + input
10 | }
11 | const memoized = memoize(fn)
12 |
13 | assert.equal(memoized(1), memoized(1))
14 | assert.notEqual(memoized(1), memoized(2))
15 | assert.equal(memoized(2), memoized(2))
16 | })
17 |
18 | test('memoizes functions with multiple arguments', assert => {
19 | const fn = (...inputs) => {
20 | return inputs.reduce((accumulator, current) => {
21 | return accumulator + current + Math.random()
22 | }, 0)
23 | }
24 |
25 | const memoized = memoize(fn)
26 |
27 | assert.equal(memoized(1, 2), memoized(1, 2))
28 | assert.notEqual(memoized(1, 2), memoized(2, 3))
29 | assert.equal(memoized(2, 3), memoized(2, 3))
30 | })
31 |
32 | test('memoizes functions with enclosing scopes', assert => {
33 | let a, b, c;
34 |
35 | // create scopes with anonymous functions
36 |
37 | (() => {
38 | const value = 'apple'
39 | a = () => value
40 | })();
41 |
42 | (() => {
43 | const value = 'banana'
44 | b = () => value
45 | })();
46 |
47 | (() => {
48 | const value = 'cherry'
49 | c = () => value
50 | })()
51 |
52 | const _eat = fn => {
53 | return `delicious ${fn()}`
54 | }
55 |
56 | const eat = memoize(_eat)
57 |
58 | assert.equal(a(), 'apple')
59 | assert.equal(b(), 'banana')
60 | assert.equal(c(), 'cherry')
61 |
62 | assert.equal(eat(a), 'delicious apple')
63 | assert.equal(eat(b), 'delicious banana')
64 | assert.equal(eat(c), 'delicious cherry')
65 | })
66 | })
67 |
--------------------------------------------------------------------------------
/tests/unit/metadata-test.js:
--------------------------------------------------------------------------------
1 | import { data } from '../../source/data.js'
2 | import { encodingField } from '../../source/encodings.js'
3 | import { specificationFixture } from '../test-helpers.js'
4 | import qunit from 'qunit'
5 |
6 | const { module, test } = qunit
7 |
8 | module('unit > metadata', () => {
9 | const urlData = () => {
10 | return [
11 | { value: 1, group: 'a', label: '2020-01-01', url: 'https://example.com/a' },
12 | { value: 1, group: 'a', label: '2020-01-02', url: 'https://example.com/a' },
13 | { value: 2, group: 'b', label: '2020-01-03', url: 'https://example.com/b' },
14 | { value: 2, group: 'b', label: '2020-01-04', url: 'https://example.com/b' },
15 | { value: 2, group: 'b', label: '2020-01-05', url: 'https://example.com/b' },
16 | { value: 3, group: 'c', label: '2020-01-06', url: 'https://example.com/c' },
17 | { value: 3, group: 'c', label: '2020-01-07', url: 'https://example.com/c' },
18 | { value: 3, group: 'c', label: '2020-01-07', url: 'https://example.com/c' }
19 | ]
20 | }
21 |
22 | const mismatch = data => {
23 | data[data.length - 1].url = 'https://example.com/d'
24 | return data
25 | }
26 |
27 | test('transfers urls to aggregated circular chart segments', assert => {
28 | const s = {
29 | data: {
30 | values: urlData()
31 | },
32 | mark: {
33 | type: 'arc'
34 | },
35 | encoding: {
36 | color: { field: 'group' },
37 | href: { field: 'url' },
38 | theta: { field: 'value' }
39 | }
40 | }
41 |
42 | assert.ok(data(s).every(item => item.url.startsWith('https://example.com/')))
43 | })
44 |
45 | test('avoids transplanting mismatched data in aggregated circular chart segments', assert => {
46 | const s = {
47 | data: {
48 | values: mismatch(urlData())
49 | },
50 | mark: {
51 | type: 'arc'
52 | },
53 | encoding: {
54 | color: { field: 'group' },
55 | href: { field: 'url' },
56 | theta: { field: 'value' }
57 | }
58 | }
59 |
60 | data(s).forEach(item => {
61 | const url = item[encodingField(s, 'href')]
62 | const check = url?.startsWith('https://example.com/')
63 | if (item.key === 'c') {
64 | assert.notOk(check)
65 | } else {
66 | assert.ok(check)
67 | }
68 | })
69 | })
70 |
71 | test('transfers urls to aggregated stacked bar chart segments', assert => {
72 | const s = {
73 | data: {
74 | values: urlData()
75 | },
76 | mark: { type: 'bar' },
77 | encoding: {
78 | color: { field: 'group', type: 'nominal' },
79 | href: { field: 'url' },
80 | x: { field: 'label', type: 'temporal' },
81 | y: { field: 'value', type: 'quantitative' }
82 | }
83 | }
84 |
85 | data(s).forEach(series => {
86 | series.forEach(item => {
87 | const difference = Math.abs(item[1] - item[0]) !== 0
88 |
89 | if (difference) {
90 | const url = item[encodingField(s, 'href')]
91 |
92 | assert.ok(url.startsWith('https://example.com/'))
93 | }
94 | })
95 | })
96 | })
97 |
98 | test('avoids transplanting mismatched data in aggregated stacked bar chart segments', assert => {
99 | const s = {
100 | data: {
101 | values: mismatch(urlData())
102 | },
103 | mark: { type: 'bar' },
104 | encoding: {
105 | color: { field: 'group', type: 'nominal' },
106 | href: { field: 'url' },
107 | x: { field: 'label', type: 'temporal' },
108 | y: { field: 'value', type: 'quantitative' }
109 | }
110 | }
111 |
112 | data(s).forEach(series => {
113 | series.forEach(item => {
114 | const difference = Math.abs(item[1] - item[0]) !== 0
115 |
116 | if (difference) {
117 | const url = item[encodingField(s, 'href')]
118 | const check = url?.startsWith('https://example.com/')
119 | if (item.data.key === '2020-01-07') {
120 | assert.notOk(check)
121 | } else {
122 | assert.ok(check)
123 | }
124 | }
125 | })
126 | })
127 | })
128 |
129 | test('copies multiple metadata fields', assert => {
130 | const s = specificationFixture('circular')
131 | s.data.values = [
132 | { a: '•', b: '-', c: 'https://www.example.com/a', group: 'a', value: 95 },
133 | { a: '+', b: '_', c: 'https://www.example.com/b', group: 'b', value: 3 },
134 | { a: '@', b: '|', c: 'https://www.example.com/c', group: 'c', value: 2 }
135 | ]
136 | s.encoding.tooltip = { field: 'a' }
137 | s.encoding.description = { field: 'b' }
138 | s.encoding.href = { field: 'c' }
139 | data(s).forEach(item => {
140 | assert.equal(typeof item.a, 'string')
141 | assert.equal(typeof item.b, 'string')
142 | assert.equal(typeof item.c, 'string')
143 | })
144 | })
145 | })
146 |
--------------------------------------------------------------------------------
/tests/unit/mutation-test.js:
--------------------------------------------------------------------------------
1 | import { chart } from '../../source/chart.js'
2 | import qunit from 'qunit'
3 | import { select } from 'd3'
4 | import { specificationFixture } from '../test-helpers.js'
5 |
6 | const { module, test } = qunit
7 |
8 | /**
9 | * recursively freeze a nested object
10 | * @param {object} object object to be frozen
11 | * @return {object} frozen object
12 | */
13 | const freeze = object => {
14 | const propNames = Object.getOwnPropertyNames(object)
15 |
16 | for (const name of propNames) {
17 | const value = object[name]
18 |
19 | if (value && typeof value === 'object') {
20 | freeze(value)
21 | }
22 | }
23 |
24 | return Object.freeze(object)
25 | }
26 |
27 | module('unit > mutation', () => {
28 | test('does not mutate specifications', assert => {
29 | const s = freeze(JSON.parse(JSON.stringify(specificationFixture('line'))))
30 | const dimensions = { x: 500, y: 500 }
31 |
32 | assert.equal(
33 | typeof chart(s, dimensions),
34 | 'function',
35 | 'factory successfully generates a chart function for a frozen specification'
36 | )
37 |
38 | const render = () => {
39 | const node = document.createElement('div')
40 |
41 | select(node).call(chart(s, dimensions))
42 | }
43 |
44 | render()
45 |
46 | assert.ok(true, 'chart function generated with a frozen specification does not throw error')
47 | })
48 | })
49 |
--------------------------------------------------------------------------------
/tests/unit/position-test.js:
--------------------------------------------------------------------------------
1 | import { margin } from '../../source/position.js'
2 | import qunit from 'qunit'
3 |
4 | const { module, test } = qunit
5 |
6 | const round = (i, precision = 1000) => Math.round(i * precision) / precision
7 |
8 | const specification = modifier => {
9 | return modifier({
10 | data: {
11 | values: [
12 | { value: 10, label: 'a' },
13 | { value: 10, label: 'b' },
14 | { value: 30, label: 'c' }
15 | ]
16 | },
17 | mark: { type: 'bar' },
18 | encoding: {
19 | x: { field: 'value', type: 'quantitative' },
20 | y: { field: 'label', type: 'nominal' }
21 | }
22 | })
23 | }
24 |
25 | const axisTitle = (channel, text) => {
26 | return s => {
27 | if (!s.encoding[channel].axis) {
28 | s.encoding[channel].axis = {}
29 | }
30 |
31 | s.encoding[channel].axis.title = text
32 |
33 | return s
34 | }
35 | }
36 |
37 | const labelAngle = angle => {
38 | return s => {
39 | if (!s.encoding.x.axis) {
40 | s.encoding.x.axis = {}
41 | }
42 |
43 | s.encoding.x.axis.labelAngle = angle
44 |
45 | return s
46 | }
47 | }
48 |
49 | const s = angle => specification(labelAngle(angle))
50 |
51 | const dimensions = { x: 500, y: 500 }
52 |
53 | const bottom = angle => round(margin(s(angle), dimensions).bottom)
54 |
55 | module('unit > margin', () => {
56 | test('increases bottom margins to reserve space for labelAngle rotation', assert => {
57 | assert.ok(margin(s(0), dimensions).bottom < margin(s(45), dimensions).bottom)
58 | assert.ok(margin(s(45), dimensions).bottom < margin(s(90), dimensions).bottom)
59 | })
60 |
61 | test('computes identical bottom margins for equivalent positive and negative axis tick text labelAngle rotation', assert => {
62 | assert.equal(margin(s(45), dimensions).bottom, margin(s(-45), dimensions).bottom)
63 | })
64 |
65 | module(
66 | 'computes identical bottom margins for equivalent axis tick text labelAngle rotation starting at',
67 | () => {
68 | const offsets = [5, 10, 15, 20, 25, 30]
69 | const startAngles = [90, 270]
70 |
71 | startAngles.forEach(angle => {
72 | test(`${angle} degrees`, assert => {
73 | offsets.forEach(offset => {
74 | const a = angle - offset
75 | const b = angle + offset
76 |
77 | assert.equal(
78 | bottom(a),
79 | bottom(b),
80 | `rotating x axis tick text ${a} degrees and ${b} degrees results in identical bottom margins`
81 | )
82 | })
83 | })
84 | })
85 | }
86 | )
87 | module('increases margin for axis titles', () => {
88 | const axes = { x: 'bottom', y: 'left' }
89 |
90 | Object.entries(axes).forEach(([channel, position]) => {
91 | test(channel, assert => {
92 | const hasAxisTitle = margin(specification(axisTitle(channel, 'testing')), dimensions)
93 | const noAxisTitle = margin(specification(axisTitle(channel, null)), dimensions)
94 |
95 | assert.true(hasAxisTitle[position] > noAxisTitle[position], channel)
96 | })
97 | })
98 | })
99 | })
100 |
--------------------------------------------------------------------------------
/tests/unit/predicate-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { predicate } from '../../source/predicate.js'
3 |
4 | const { module, test } = qunit
5 |
6 | const data = () => {
7 | return [
8 | { x: 1, _: 'apple pie' },
9 | { x: 2, _: 'banana split' },
10 | { x: 3, _: 'cherry cobbler' },
11 | { x: 4, _: 'donut' },
12 | { x: 5, _: 'eclair' },
13 | { x: 6, _: 'flan' }
14 | ]
15 | }
16 |
17 | const run = config => {
18 | return data().filter(predicate(config)).map(item => item.x).join(',')
19 | }
20 |
21 | module('unit > predicate', () => {
22 | module('simple predicates', () => {
23 | test('lt', assert => {
24 | const config = { lt: 5, field: 'x' }
25 | assert.equal(typeof predicate(config), 'function', 'generates a simple predicate function')
26 | assert.equal(data().filter(predicate(config)).length, 4, 'predicate function works for filtering')
27 | })
28 | })
29 | module('predicate composition', () => {
30 | const run = config => {
31 | return data()
32 | .filter(predicate(config))
33 | .map(item => item.x)
34 | .join(',')
35 | }
36 | test('and', assert => {
37 | const config = { and: [
38 | { oneOf: [1, 2], field: 'x' },
39 | { equal: 1, field: 'x' }
40 | ] }
41 | assert.equal(run(config), '1')
42 | })
43 | test('or', assert => {
44 | const config = { or: [
45 | { oneOf: [1, 2], field: 'x' },
46 | { equal: 3, field: 'x' }
47 | ] }
48 | assert.equal(run(config), '1,2,3')
49 | })
50 | test('not', assert => {
51 | const config = { not: [
52 | { oneOf: [1, 2], field: 'x' },
53 | { oneOf: [3, 4], field: 'x' }
54 | ] }
55 | assert.equal(run(config), '5,6')
56 | })
57 | })
58 | module('string expression predicates', () => {
59 | const comparisons = {
60 | '==': 'equal',
61 | '===': 'equal',
62 | '>': 'gt',
63 | '>=': 'gte',
64 | '<': 'lt',
65 | '<=': 'lte'
66 | }
67 | Object.entries(comparisons).forEach(([symbol, key]) => {
68 | test(`${key} (${symbol})`, assert => {
69 | const string = `datum.x ${symbol} 1`
70 | const object = { field: 'x', [key]: 1 }
71 | assert.equal(run(string), run(object))
72 | })
73 | })
74 | })
75 | test('equal (===) with string comparison', assert => {
76 | const string = 'datum._ === "apple pie"'
77 | assert.equal(run(string), 1)
78 | })
79 | })
80 |
--------------------------------------------------------------------------------
/tests/unit/sort-test.js:
--------------------------------------------------------------------------------
1 | import { ascending } from 'd3'
2 | import qunit from 'qunit'
3 | import { parseScales } from '../../source/scales.js'
4 |
5 | const { module, test } = qunit
6 |
7 | const extra = { group: 'z', label: 'a', value: 3 }
8 | const specification = () => {
9 | const s = {
10 | data: {
11 | values: [
12 | { group: 'z', label: 'a', value: 10 },
13 | { group: 'y', label: 'b', value: 20 },
14 | { group: 'w', label: 'd', value: 15 },
15 | { group: 'x', label: 'c', value: 12 },
16 | { group: 'u', label: 'f', value: 16 },
17 | { group: 'v', label: 'e', value: 25 },
18 | { group: 's', label: 'h', value: 22 },
19 | { group: 'r', label: 'i', value: 30 },
20 | { group: 't', label: 'g', value: 5 }
21 | ]
22 | },
23 | mark: { type: 'bar' },
24 | encoding: {
25 | x: {
26 | field: 'label',
27 | type: 'nominal'
28 | },
29 | y: {
30 | field: 'value',
31 | type: 'quantitative'
32 | }
33 | }
34 | }
35 |
36 | return s
37 | }
38 |
39 | const dates = s => {
40 | s.data.values = s.data.values.map((item, index) => {
41 | return { ...item, label: `2021-01-0${index}` }
42 | })
43 | s.encoding.x.type = 'temporal'
44 | s.encoding.x.timeUnit = 'utcday'
45 |
46 | return s
47 | }
48 |
49 | const dimensions = { x: 500, y: 500 }
50 |
51 | module('unit > sort', () => {
52 | test('natural sort for quantitative scales', function(assert) {
53 | const asc = specification()
54 |
55 | asc.mark.type = 'point'
56 |
57 | const desc = specification()
58 |
59 | desc.mark.type = 'point'
60 |
61 | asc.encoding.y.sort = 'ascending'
62 | desc.encoding.y.sort = 'descending'
63 |
64 | const ascy = parseScales(asc, dimensions).y
65 | const descy = parseScales(desc, dimensions).y
66 |
67 | assert.deepEqual(ascy.domain(), [...descy.domain()].reverse())
68 | })
69 | test('natural sort for time scales', function(assert) {
70 | const asc = dates(specification())
71 |
72 | asc.mark.type = 'point'
73 |
74 | const desc = dates(specification())
75 |
76 | desc.mark.type = 'point'
77 |
78 | asc.encoding.x.sort = 'ascending'
79 | desc.encoding.x.sort = 'descending'
80 |
81 | const ascx = parseScales(asc, dimensions).x
82 | const descx = parseScales(desc, dimensions).x
83 |
84 | assert.deepEqual(ascx.domain(), [...descx.domain()].reverse())
85 | })
86 |
87 | test('sort by encoding', function(assert) {
88 | const none = specification()
89 | const asc = specification()
90 | const desc = specification()
91 |
92 | const encoding = 'y'
93 | const { field } = specification().encoding[encoding]
94 |
95 | none.encoding.x.sort = { encoding }
96 | asc.encoding.x.sort = { encoding, order: 'ascending' }
97 | desc.encoding.x.sort = { encoding, order: 'descending' }
98 |
99 | const nonex = parseScales(none, dimensions).x
100 | const ascx = parseScales(asc, dimensions).x
101 | const descx = parseScales(desc, dimensions).x
102 |
103 | const labels = specification()
104 | .data.values.slice()
105 | .sort((a, b) => a[field] - b[field])
106 | .map(item => item.label)
107 |
108 | assert.deepEqual(nonex.domain(), labels)
109 | assert.deepEqual(ascx.domain(), labels)
110 | assert.deepEqual(descx.domain(), [...labels].reverse())
111 | })
112 |
113 | test('sort by encoding with string shorthand', function(assert) {
114 | const none = specification()
115 | const asc = specification()
116 | const desc = specification()
117 |
118 | const encoding = 'y'
119 | const { field } = specification().encoding[encoding]
120 |
121 | none.encoding.x.sort = encoding
122 | asc.encoding.x.sort = encoding
123 | desc.encoding.x.sort = `-${encoding}`
124 |
125 | const nonex = parseScales(none, dimensions).x
126 | const ascx = parseScales(asc, dimensions).x
127 | const descx = parseScales(desc, dimensions).x
128 |
129 | const labels = specification()
130 | .data.values.slice()
131 | .sort((a, b) => a[field] - b[field])
132 | .map(item => item.label)
133 |
134 | assert.deepEqual(nonex.domain(), labels)
135 | assert.deepEqual(ascx.domain(), labels)
136 | assert.deepEqual(descx.domain(), [...labels].reverse())
137 | })
138 |
139 | test('sort by field', function(assert) {
140 | const none = specification()
141 | const asc = specification()
142 | const desc = specification()
143 |
144 | const field = 'value'
145 |
146 | none.encoding.x.sort = { field }
147 | asc.encoding.x.sort = { field, order: 'ascending' }
148 | desc.encoding.x.sort = { field, order: 'descending' }
149 |
150 | const nonex = parseScales(asc, dimensions).x
151 | const ascx = parseScales(asc, dimensions).x
152 | const descx = parseScales(desc, dimensions).x
153 |
154 | const values = specification()
155 | .data.values.slice()
156 | .sort((a, b) => ascending(a[field], b[field]))
157 | const labels = values.map(item => item.label)
158 |
159 | assert.deepEqual(nonex.domain(), labels)
160 | assert.deepEqual(ascx.domain(), labels)
161 | assert.deepEqual(descx.domain(), [...labels].reverse())
162 | })
163 | test('array specifies sorting', function(assert) {
164 | const s = specification()
165 |
166 | const sort = ['a', 'c', 'b', 'd', 'f', 'i', 'h']
167 |
168 | s.encoding.x.sort = sort
169 |
170 | const { x } = parseScales(s, dimensions)
171 |
172 | assert.deepEqual(x.domain().slice(0, sort.length), sort)
173 | })
174 | test('remaining items are appended to sort after specified values', function(assert) {
175 | const s = specification()
176 |
177 | const partial = ['a', 'c']
178 |
179 | s.encoding.x.sort = partial
180 |
181 | const { x } = parseScales(s, dimensions)
182 |
183 | const remaining = specification()
184 | .data.values.map(item => item.label)
185 | .filter(item => !partial.includes(item))
186 |
187 | const sort = [...partial, ...remaining]
188 |
189 | assert.deepEqual(x.domain(), sort)
190 | })
191 | test('null disables sorting', function(assert) {
192 | const s = specification()
193 |
194 | s.encoding.x.sort = null
195 |
196 | const unsorted = s.data.values.map(item => item.label)
197 |
198 | const { x } = parseScales(s, dimensions)
199 |
200 | assert.deepEqual(x.domain(), unsorted)
201 | })
202 | test('resolves duplicates with min for most charts', function(assert) {
203 | const s = specification()
204 |
205 | s.mark.type = 'line'
206 | s.data.values.push(extra)
207 | s.encoding.x.sort = { field: 'value' }
208 |
209 | const { x } = parseScales(s, dimensions)
210 |
211 | assert.deepEqual(x.domain(), ['a', 'g', 'c', 'd', 'f', 'b', 'h', 'e', 'i'])
212 | })
213 | test('resolves duplicates with sum for bar charts', function(assert) {
214 | const s = specification()
215 |
216 | s.data.values.push(extra)
217 | s.encoding.x.sort = { field: 'value' }
218 |
219 | const { x } = parseScales(s, dimensions)
220 |
221 | assert.deepEqual(x.domain(), ['g', 'c', 'a', 'd', 'f', 'b', 'h', 'e', 'i'])
222 | })
223 | })
224 |
--------------------------------------------------------------------------------
/tests/unit/stack-test.js:
--------------------------------------------------------------------------------
1 | import { markData } from '../../source/marks.js'
2 | import qunit from 'qunit'
3 | import { render, testSelector, specificationFixture } from '../test-helpers.js'
4 |
5 | const { module, test } = qunit
6 |
7 | module('unit > stack', () => {
8 | const precision = 15
9 | const zero = ([start, _]) => start === 0
10 | const one = ([_, end]) => +end.toFixed(precision) === 1
11 | test('stacks', assert => {
12 | const s = specificationFixture('stackedBar')
13 | const layout = markData(s)
14 | assert.ok(layout[0].every(segment => segment[0] === 0), 'first series uses a zero baseline for all segments')
15 | })
16 | test('normalizes stacking', assert => {
17 | const s = specificationFixture('stackedBar')
18 | s.encoding.y.stack = 'normalize'
19 | const layout = markData(s)
20 | const first = layout[0]
21 | const last = layout[layout.length - 1]
22 | assert.ok(first.every(zero), 'first series uses a zero baseline for all segments')
23 | assert.ok(last.every(one), 'final series extends all segments to maximum value')
24 | })
25 | test('disables stacking', assert => {
26 | const s = specificationFixture('stackedBar')
27 | s.encoding.y.stack = null
28 | // testing disabled stacking requires rendered DOM because it's
29 | // handled with control flow and is not visible in the layout
30 | // returned from d3.stack() and markData()
31 | const element = render(s)
32 | const baseline = mark => +mark.getAttribute('y') + +mark.getAttribute('height')
33 | const baselines = new Set([...element.querySelectorAll(testSelector('mark'))].map(baseline))
34 | assert.equal(baselines.size, 1, 'all marks have the same baseline')
35 | })
36 | })
37 |
--------------------------------------------------------------------------------
/tests/unit/state-test.js:
--------------------------------------------------------------------------------
1 | import { createState } from '../../source/state.js'
2 | import qunit from 'qunit'
3 |
4 | const { module, test } = qunit
5 |
6 | module('state', () => {
7 | test('creates a state manager', assert => {
8 | const state = createState()
9 | assert.equal(typeof state, 'object')
10 | assert.equal(typeof state.init, 'function')
11 | })
12 | test('stores index', assert => {
13 | const state = createState()
14 | const value = 5
15 | state.index(value)
16 | assert.equal(state.index(), value)
17 | })
18 | })
19 |
--------------------------------------------------------------------------------
/tests/unit/support.js:
--------------------------------------------------------------------------------
1 | // import { timeFormat } from '../source/time.js';
2 |
3 | const timeFormat = () => null
4 |
5 | const day = () => Math.floor(Math.random() * 10)
6 | const groups = 5
7 | const group = () => String.fromCharCode(Math.floor(Math.random() * groups + 97))
8 |
9 | const datum = () => {
10 | const date = timeFormat(new Date(new Date().getFullYear(), 1, day()))
11 |
12 | return { label: date, value: Math.random(), group: group() }
13 | }
14 |
15 | const data = () => {
16 | return Array.from({ length: 100 }).map(datum)
17 | }
18 |
19 | const dimensions = { x: 100, y: 100 }
20 |
21 | export { data, dimensions, groups }
22 |
--------------------------------------------------------------------------------
/tests/unit/timestamp-test.js:
--------------------------------------------------------------------------------
1 | import * as d3 from 'd3'
2 | import {
3 | getTimeParser,
4 | parseTime,
5 | timeMethod
6 | } from '../../source/time.js'
7 | import qunit from 'qunit'
8 |
9 | const { module, test } = qunit
10 |
11 | module('unit > timestamps', () => {
12 | const timestamps = [
13 | '2020-01-01',
14 | '2020-07-04T20:00:33.245Z',
15 | 1596057847949
16 | ]
17 |
18 | test('converts time period specifiers to d3 time interval method names', assert => {
19 | ['utcday', 'utcweek', 'day', 'week'].forEach(timeSpecifier => {
20 | assert.equal(
21 | typeof d3[timeMethod(timeSpecifier)],
22 | 'function',
23 | `time specifier ${timeSpecifier} converted to d3 method name`
24 | )
25 | })
26 | })
27 |
28 | test('retrieves a timestamp parsing function', assert => {
29 | timestamps.forEach(timestamp => {
30 | const parser = getTimeParser(timestamp)
31 |
32 | assert.equal(typeof parser, 'function')
33 | })
34 | })
35 | test('retrieved function accurately parses timestamp strings', assert => {
36 | timestamps.forEach(timestamp => {
37 | const parser = getTimeParser(timestamp)
38 | const parsed = parser(timestamp)
39 |
40 | assert.equal(typeof parsed.getFullYear, 'function')
41 | assert.equal(typeof parsed.getFullYear(), 'number')
42 | })
43 | })
44 | test('determines correct timestamp parser', assert => {
45 | const dates = timestamps.map(parseTime)
46 |
47 | dates.forEach(date => {
48 | assert.equal(typeof date.getFullYear, 'function')
49 | assert.equal(typeof date.getFullYear(), 'number')
50 | })
51 | })
52 | test('refuses to parse invalid dates', assert => {
53 | const invalidDate = new Date('this is not a valid date format')
54 | const parser = getTimeParser(invalidDate)
55 |
56 | assert.equal(parser, null)
57 | })
58 | })
59 |
--------------------------------------------------------------------------------
/tests/unit/tooltip-test.js:
--------------------------------------------------------------------------------
1 | import qunit from 'qunit'
2 | import { tooltipContent } from '../../source/tooltips.js'
3 | import { data } from '../../source/data.js'
4 | import { specificationFixture } from '../test-helpers.js'
5 |
6 | const { module, test } = qunit
7 |
8 | module('unit > tooltips', () => {
9 | test('it is a function factory', assert => {
10 | assert.equal(typeof tooltipContent({}), 'function')
11 | })
12 |
13 | const values = [
14 | { a: 1, b: 2, c: 3, group: 'a' },
15 | { a: 1, b: 2, c: 3, group: 'b' }
16 | ]
17 |
18 | const encoding = { x: { field: 'a' }, y: { field: 'b' }, color: { field: 'group' } }
19 |
20 | test('includes encoding fields by default', assert => {
21 | const s = {
22 | data: { values },
23 | mark: { tooltip: true },
24 | encoding
25 | }
26 | const text = tooltipContent(s)(s.data.values[0])
27 |
28 | assert.ok(text.includes('a: 1'))
29 | assert.ok(text.includes('b: 2'))
30 | assert.ok(text.includes('group: a'))
31 | })
32 | test('does not include unused fields by default', assert => {
33 | const s = {
34 | data: { values },
35 | mark: { tooltip: true },
36 | encoding
37 | }
38 | const text = tooltipContent(s)(s.data.values[0])
39 |
40 | assert.ok(!text.includes('c:'))
41 | })
42 | test('can include all data fields', assert => {
43 | const s = {
44 | data: { values },
45 | mark: { tooltip: { content: 'data' } },
46 | encoding
47 | }
48 | const text = tooltipContent(s)(s.data.values[0])
49 |
50 | assert.ok(text.includes('a: 1'))
51 | assert.ok(text.includes('b: 2'))
52 | assert.ok(text.includes('c: 3'))
53 | assert.ok(text.includes('group: a'))
54 | })
55 | test('can specify a data field', assert => {
56 | const s = {
57 | data: { values },
58 | mark: { type: null, tooltip: true },
59 | encoding: {
60 | ...encoding,
61 | tooltip: { field: 'a' }
62 | }
63 | }
64 | const text = tooltipContent(s)(s.data.values[0])
65 |
66 | assert.equal(text, '1')
67 | })
68 | test('can specify multiple data fields', assert => {
69 | const s = {
70 | data: { values },
71 | mark: { type: null, tooltip: true },
72 | encoding: {
73 | ...encoding,
74 | tooltip: [
75 | { field: 'a', type: 'quantitative' },
76 | { field: 'b', type: 'quantitative' }
77 | ]
78 | }
79 | }
80 | const text = tooltipContent(s)(s.data.values[0])
81 |
82 | assert.equal(text, 'a: 1; b: 2')
83 | })
84 | test('can specify labels', assert => {
85 | const s = {
86 | data: { values },
87 | mark: { type: null, tooltip: true },
88 | encoding: {
89 | ...encoding,
90 | tooltip: [
91 | { field: 'a', type: 'quantitative', label: 'VALUE OF A' },
92 | { field: 'b', type: 'quantitative', label: 'VALUE OF B' }
93 | ]
94 | }
95 | }
96 | const text = tooltipContent(s)(s.data.values[0])
97 |
98 | assert.equal(text, 'VALUE OF A: 1; VALUE OF B: 2')
99 | })
100 | test('can specify a calculate transform field', assert => {
101 | const datum = { a: '1' }
102 | const transformField = {
103 | mark: { type: null, tooltip: true },
104 | transform: [{ calculate: "'tooltip value is: ' + datum.a", as: 'd' }],
105 | encoding: {
106 | ...encoding,
107 | tooltip: { field: 'd' }
108 | }
109 | }
110 | const text = tooltipContent(transformField)(datum)
111 |
112 | assert.equal(text, 'tooltip value is: 1')
113 | })
114 | test('normalizes stacked tooltip values', assert => {
115 | const s = specificationFixture('circular')
116 | s.encoding.theta.stack = 'normalize'
117 | const datum = { data: { value: 9, group: 'a' } }
118 | const text = tooltipContent(s)(datum)
119 | assert.ok(text.includes('%;'), 'appends percent sign')
120 | })
121 | test('handles bidirectional cartesian encodings', assert => {
122 | const length = { field: 'a', type: 'quantitative' }
123 | const category = { field: 'b', type: 'nominal' }
124 | const values = [
125 | { a: 10, b: '_' },
126 | { a: 20, b: '•' },
127 | { a: 30, b: '+' }
128 | ]
129 | const mark = { type: 'bar', tooltip: true }
130 | const horizontal = {
131 | data: { values },
132 | mark,
133 | encoding: {
134 | x: length,
135 | y: category
136 | }
137 | }
138 | const vertical = {
139 | data: { values },
140 | mark,
141 | encoding: {
142 | y: length,
143 | x: category
144 | }
145 | }
146 |
147 | const datum = s => data(s)[0][2]
148 | const text = s => tooltipContent(s)(datum(s))
149 |
150 | assert.equal(text(horizontal), 'a: 30; b: +')
151 | assert.equal(text(vertical), 'a: 30; b: +')
152 | })
153 | })
154 |
--------------------------------------------------------------------------------
/tests/unit/values-test.js:
--------------------------------------------------------------------------------
1 | import { values } from '../../source/values.js'
2 | import qunit from 'qunit'
3 |
4 | const { module, test } = qunit
5 |
6 | module('unit > values', () => {
7 | test('extracts values from specification', assert => {
8 | const value = {}
9 | const s = { data: { values: [value] } }
10 |
11 | assert.equal(values(s).pop(), value)
12 | })
13 | test('looks up nested values', assert => {
14 | const nestedData = { first: { second: [{ a: 1, b: 2 }] } }
15 | const s = {
16 | data: nestedData
17 | }
18 | s.data.format = { property: 'first.second', type: 'json' }
19 | const data = values(s)
20 | assert.ok(Array.isArray(data))
21 | assert.equal(data.length, 1)
22 | assert.equal(data[0].a, 1)
23 | assert.equal(data[0].b, 2)
24 | })
25 | test('parses field types', assert => {
26 | const s = {
27 | data: {
28 | values: [{ a: null, b: 0, c: 2020 }],
29 | format: {
30 | parse: {
31 | a: 'number',
32 | b: 'boolean',
33 | c: 'date'
34 | }
35 | }
36 | }
37 | }
38 | const parsed = values(s)[0]
39 | assert.strictEqual(parsed.a, 0)
40 | assert.strictEqual(parsed.b, false)
41 | assert.equal(typeof parsed.c.getFullYear, 'function')
42 | })
43 | })
44 |
--------------------------------------------------------------------------------