├── .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 | --------------------------------------------------------------------------------