├── .babelrc ├── .github └── dependabot.yml ├── .gitignore ├── .prettierrc.json ├── CODE_OF_CONDUCT.md ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── SECURITY.md ├── package.json ├── src ├── annot.ts ├── bar-chart.ts ├── core.ts ├── index.ts ├── line-chart.ts ├── navigation-controller.ts ├── sonify.ts └── util.ts ├── webpack.config.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": ">0.25%" 7 | } 8 | ], 9 | "@babel/preset-typescript" 10 | ], 11 | "plugins": [ 12 | [ 13 | "@babel/plugin-transform-runtime", 14 | { 15 | "regenerator": true 16 | } 17 | ] 18 | ] 19 | } 20 | -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "npm" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | ignore: 13 | - dependency-name: "*" 14 | update-types: ["version-update:semver-patch"] 15 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | vendor 2 | node_modules 3 | build 4 | -------------------------------------------------------------------------------- /.prettierrc.json: -------------------------------------------------------------------------------- 1 | { 2 | "trailingComma": "es5", 3 | "tabWidth": 4, 4 | "semi": true, 5 | "singleQuote": true 6 | } -------------------------------------------------------------------------------- /CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Microsoft Open Source Code of Conduct 2 | 3 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 4 | 5 | Resources: 6 | 7 | - [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/) 8 | - [Microsoft Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 9 | - Contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with questions or concerns -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project welcomes contributions and suggestions. Most contributions require you to 4 | agree to a Contributor License Agreement (CLA) declaring that you have the right to, 5 | and actually do, grant us the rights to use your contribution. For details, visit 6 | https://cla.microsoft.com. 7 | 8 | When you submit a pull request, a CLA-bot will automatically determine whether you need 9 | to provide a CLA and decorate the PR appropriately (e.g., label, comment). Simply follow the 10 | instructions provided by the bot. You will only need to do this once across all repositories using our CLA. 11 | 12 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 13 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) 14 | or contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | chart-reader 2 | 3 | Copyright (c) Microsoft Corporation. 4 | 5 | MIT License 6 | 7 | Permission is hereby granted, free of charge, to any person obtaining a copy 8 | of this software and associated documentation files (the "Software"), to deal 9 | in the Software without restriction, including without limitation the rights 10 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 11 | copies of the Software, and to permit persons to whom the Software is 12 | furnished to do so, subject to the following conditions: 13 | 14 | The above copyright notice and this permission notice shall be included in all 15 | copies or substantial portions of the Software. 16 | 17 | THE SOFTWARE IS PROVIDED *AS IS*, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 18 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 19 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 20 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 21 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 22 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 23 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Chart Reader 2 | 3 | Web-accessibility engine for rendering accessible, interactive charts optimized for screen reader users. 4 | 5 | ## Source Code 6 | 7 | Resides in the `./src/` directory. This engine is built from source code using `npm` or `yarn`. The build outputs to `./build/` as bundled `*.js` file. 8 | 9 | ## Install and Build 10 | 11 | This repository requires the [yarn](https://classic.yarnpkg.com/en/docs/install) package manager. 12 | 13 | Then install the package dependencies and build the Chart Reader engine: 14 | 15 | 1. `yarn install` 16 | 2. `yarn run build` builds to `./build/` as bundled `*.js` file. 17 | 18 | ## Dependencies 19 | 20 | Chart Reader is created with open source libraries, using [d3](https://github.com/d3/d3) for data binding and a modified version of [susielu/react-annotation](https://github.com/susielu/react-annotation) for annotation layout. 21 | 22 | ## How to use 23 | 24 | Chart Reader is an accessibility engine that renders an SVG chart to a web page using __three inputs:__ 25 | 26 | 1. __a data file__ in CSV format 27 | 2. __an insights list__ in JSON format 28 | 3. __a Chart Reader configuration__ in JSON format 29 | 30 | The __data file__ and __insights list__ describe the content of the accessible chart, while the __Chart Reader configuration__ declares how the chart renders the accessible experience. 31 | 32 | The documentation uses a multi-series line chart about "Potholes Reported in Seattle and Atlanta" as an example. 33 | 34 | ### Data File Input 35 | 36 | For the input CSV data file, Chart Reader supports the following data fields: ``number``, ``string``, ``datetime``, ``date``, ``time``. 37 | The engine expects data to be complete and tidy: it does not support missing values. 38 | 39 | ### Insights List 40 | 41 | The insights JSON structure is inspired by the [d3-annotation spec](https://react-annotation.susielu.com/). 42 | Four fields are necessary for Chart Reader to include insights: 43 | 44 | 1. ``title`` - textual description to be read first. The ``title`` should summarize the insight as a headline. 45 | 46 | 2. ``label`` - textual description read second. Details the insight content. Each insight should follow a similar format and structure to other insights of the same ``type`` 47 | 48 | ```json 49 | { 50 | "note": { 51 | "title": [ 52 | "Most Potholes Reported in New York, 2018" 53 | ], 54 | "label": [ 55 | "In the winter of 2018, New York sees its largest number of ", 56 | "potholes reported, reaching a peak of close to 3500 potholes in March. " 57 | ] 58 | }, 59 | } 60 | ``` 61 | 62 | 3. ``target`` - the data targeted by the insight, specified by the axis and values under selection. 63 | 64 | ```json 65 | { 66 | "target": { 67 | "axis": "x", 68 | "values": ["2020-03-01", "2020-04-10"], 69 | "series": ["Seattle"] 70 | }, 71 | } 72 | ``` 73 | 74 | a. ``axis`` - the axis to make the target selection along. Restricted to ``x`` and ``y`` literals. 75 | b. ``values`` - an array that selects the target values. Should be ``start`` and ``end`` values of a range for linear data (e.g., ``number``, ``datetime``, ``date``, ``time``). Should be ``unique`` list of values in the case ``string`` data. 76 | c. ``series`` - an array that selects which series to include. _Only for multi-series charts._ 77 | 78 | 4. ``type`` - describes how the insight should be grouped (e.g., "Summary", "Trends", "Landmarks", "Statistics"). Insight ``types`` are ``strings`` to be set ad-hoc by including new types in the file: Chart Reader will group any insights together with the same type. 79 | 80 | ```json 81 | { 82 | "type": "landmarks", 83 | } 84 | ``` 85 | 86 | 5. ``dx`` and ``dy``- _optional fields_ that relatively place the visual text of the insight with respect to the ``target``. 87 | 88 | ```json 89 | { 90 | "dx": 30, 91 | "dy": -60, 92 | } 93 | ``` 94 | 95 | ### Chart Reader Configuration 96 | 97 | Chart Reader supports four chart types: single-series line, multi-series line, stacked bar, and grouped bar. The configuration is a JSON object that declares the encodings of the chart. The configuration is flexible in how it encodes data, which data types are suppported, and how values can be read by the Screen Reader. 98 | 99 | The configuration requires ``description``, ``insights``, and ``data`` objects that describe the makeup of the chart. 100 | 101 | 1. ``description`` - describes the ``title`` used to first announce the chart, and the ``caption`` used to describe the syntactic aspects of the chart. Note that the summary insight goes into more detail about describing the chart's content. 102 | 103 | ```json 104 | { 105 | "description": { 106 | "title": "Potholes Reported in Seattle and New York City", 107 | "caption": "Line chart displaying potholes reported each month in Seattle and New York City (NYC) from January 2017 to March 2022." 108 | }, 109 | } 110 | ``` 111 | 112 | 2. ``insights`` - a ``url`` pointing towards the JSON file containing the Insights List described above. 113 | 114 | ```json 115 | { 116 | "annotations": { 117 | "url": "./assets/chart/seattle_and_nyc_pothole_insights.json" 118 | }, 119 | } 120 | ``` 121 | 122 | 3. ``data`` - describes the input data in a digestible format for Chart Reader. This JSON Object includes the ``url`` to the CSV data file and how to parse the data ``fields``. Each ``field`` includes an object describing the ``name`` of a column in the CSV data file, the data ``type``, and the ``format``. The ``format`` is only required for ``datetime``, ``date``, or ``time`` data and follows [d3 time format](https://github.com/d3/d3-time-format). 123 | 124 | ```json 125 | { 126 | "data": { 127 | "url": "./assets/chart/seattle_and_nyc_potholes.csv", 128 | "fields": [ 129 | { 130 | "name": "date", 131 | "type": "date", 132 | "format": "%Y-%m-%d" 133 | }, 134 | { 135 | "name": "seattle", 136 | "type": "number" 137 | }, 138 | { 139 | "name": "nyc", 140 | "type": "number" 141 | } 142 | ] 143 | }, 144 | } 145 | ``` 146 | 147 | The configuration also includes encoding attributes for ``x``, ``y``, and ``z`` axes, respectively. The ``x`` and ``y`` objects are required for all chart types, while the ``z`` object is only required for multi-series charts. 148 | 149 | 4. ``x``, ``y``, ``z`` - describes the encoding of the along that axis. The object includes the ``name`` of the column in the CSV data file, the ``type`` of data, the ``label_axis`` to be read by the Screen Reader, the ``label_group`` to be read by the Screen Reader, the ``aggregate`` function to be applied to the data, and the ``period`` to describe equally spaced temporal data, and ``interval`` to be used to ``aggregate`` along the axis. The ``period`` is only required for ``datetime``, ``date``, or ``time`` data. 150 | 151 | ```json 152 | { 153 | "x": { 154 | "name": "date", 155 | "type": "date", 156 | "label_axis": "Time (in months)", 157 | "label_group": "average potholes", 158 | "aggregate": ["mean"], 159 | "period": "Month", 160 | "interval": "Year", 161 | }, 162 | "z": { 163 | "name": "city", 164 | "type": "string", 165 | "label_axis": "City", 166 | "map": { 167 | "sea": "Seattle", 168 | "nyc": "New York City" 169 | } 170 | } 171 | } 172 | ``` 173 | 174 | - ``name`` - the name of the column in the CSV data file. 175 | - ``type`` - the type of data. Supported types are ``number``, ``string``, ``datetime``, ``date``, and ``time``. 176 | - ``label_axis`` - the label to be read by the Screen Reader for the axis and displayed as text. 177 | - ``label_group`` - the label to be read by the Screen Reader for the aggregated group. 178 | - ``aggregate`` - the function to be applied to the data binned by the ``interval``. Supported functions are ``mean``, ``median``, ``min``, ``max``, ``sum``, ``count``, ``consecutive_time``. 179 | - ``period`` - the period to describe equally spaced temporal data. Supported periods are ``Month``, ``Year``, ``Week``, ``Day``, ``Hour``, ``Minute``, ``Second``. 180 | - ``interval`` - the interval to be used to ``aggregate`` along the axis. Supported intervals are ``Month``, ``Year``, ``Week``, ``Day``, ``Hour``, ``Minute``, ``Second`` for ``time`` data. Should be a larger interval than the ``period``. For ``number`` data, the provided number is used ``aggregate`` by binning. 181 | - ``map`` - a mapping of the ``string`` data to be read by the Screen Reader. The keys are the values in the CSV data file, and the values are the text to be read by the Screen Reader. only required for ``string`` data. 182 | 183 | ## Contributing 184 | 185 | This project welcomes contributions and suggestions. 186 | 187 | ### Pull request review 188 | 189 | Pull requests to this repo will be reviewed, at a minimum, by one member of the Chart Reader research team. 190 | 191 | ### Contribution requirements 192 | 193 | Most contributions require you to agree to a 194 | Contributor License Agreement (CLA) declaring that you have the right to, and actually do, grant us 195 | the rights to use your contribution. For details, visit . 196 | 197 | When you submit a pull request, a CLA bot will automatically determine whether you need to provide 198 | a CLA and decorate the PR appropriately (e.g., status check, comment). Simply follow the instructions 199 | provided by the bot. You will only need to do this once across all repos using our CLA. 200 | 201 | ### Code of Conduct 202 | 203 | This project has adopted the [Microsoft Open Source Code of Conduct](https://opensource.microsoft.com/codeofconduct/). 204 | For more information see the [Code of Conduct FAQ](https://opensource.microsoft.com/codeofconduct/faq/) or 205 | contact [opencode@microsoft.com](mailto:opencode@microsoft.com) with any additional questions or comments. 206 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | ## Security 2 | 3 | Microsoft takes the security of our software products and services seriously, which includes all source code repositories managed through our GitHub organizations, which include [Microsoft](https://github.com/microsoft), [Azure](https://github.com/Azure), [DotNet](https://github.com/dotnet), [AspNet](https://github.com/aspnet), [Xamarin](https://github.com/xamarin), and [our GitHub organizations](https://opensource.microsoft.com/). 4 | 5 | If you believe you have found a security vulnerability in any Microsoft-owned repository that meets [Microsoft's definition of a security vulnerability](https://aka.ms/opensource/security/definition), please report it to us as described below. 6 | 7 | ## Reporting Security Issues 8 | 9 | **Please do not report security vulnerabilities through public GitHub issues.** 10 | 11 | Instead, please report them to the Microsoft Security Response Center (MSRC) at [https://msrc.microsoft.com/create-report](https://aka.ms/opensource/security/create-report). 12 | 13 | If you prefer to submit without logging in, send email to [secure@microsoft.com](mailto:secure@microsoft.com). If possible, encrypt your message with our PGP key; please download it from the [Microsoft Security Response Center PGP Key page](https://aka.ms/opensource/security/pgpkey). 14 | 15 | You should receive a response within 24 hours. If for some reason you do not, please follow up via email to ensure we received your original message. Additional information can be found at [microsoft.com/msrc](https://aka.ms/opensource/security/msrc). 16 | 17 | Please include the requested information listed below (as much as you can provide) to help us better understand the nature and scope of the possible issue: 18 | 19 | * Type of issue (e.g. buffer overflow, SQL injection, cross-site scripting, etc.) 20 | * Full paths of source file(s) related to the manifestation of the issue 21 | * The location of the affected source code (tag/branch/commit or direct URL) 22 | * Any special configuration required to reproduce the issue 23 | * Step-by-step instructions to reproduce the issue 24 | * Proof-of-concept or exploit code (if possible) 25 | * Impact of the issue, including how an attacker might exploit the issue 26 | 27 | This information will help us triage your report more quickly. 28 | 29 | If you are reporting for a bug bounty, more complete reports can contribute to a higher bounty award. Please visit our [Microsoft Bug Bounty Program](https://aka.ms/opensource/security/bounty) page for more details about our active programs. 30 | 31 | ## Preferred Languages 32 | 33 | We prefer all communications to be in English. 34 | 35 | ## Policy 36 | 37 | Microsoft follows the principle of [Coordinated Vulnerability Disclosure](https://aka.ms/opensource/security/cvd). -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "chart-reader", 3 | "version": "1.0.0", 4 | "description": "Chart Reader web-accessibility engine", 5 | "main": "index.ts", 6 | "repository": "https://github.com/microsoft/chart-reader.git", 7 | "author": "John Thompson ", 8 | "license": "MIT", 9 | "scripts": { 10 | "build": "webpack" 11 | }, 12 | "dependencies": { 13 | "d3": "^7.8.4" 14 | }, 15 | "devDependencies": { 16 | "@babel/core": "^7.22.5", 17 | "@babel/plugin-transform-runtime": "^7.22.5", 18 | "@babel/preset-env": "^7.22.9", 19 | "@babel/preset-typescript": "^7.22.5", 20 | "babel-loader": "^9.1.2", 21 | "webpack": "^5.88.2", 22 | "webpack-cli": "^5.1.4" 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/annot.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | // Code updated from https://github.com/susielu/react-annotation/blob/master/src/components/Note/Note.js 5 | 6 | /** 7 | * AnnotationAlignment value for the annotation in relation to target 8 | */ 9 | enum AnnotationAlignment { 10 | dynamic = 'dynamic', 11 | top = 'top', 12 | bottom = 'bottom', 13 | middle = 'middle', 14 | left = 'left', 15 | right = 'right', 16 | } 17 | 18 | enum AnnotationOrientation { 19 | topBottom = 'topBottom', 20 | top = 'top', 21 | bottom = 'bottom', 22 | leftRight = 'leftRight', 23 | left = 'left', 24 | right = 'right', 25 | } 26 | 27 | /** 28 | * Computes the outer bounding box for a list of SVGGraphicsElement 29 | * @param svgNodes SVGGraphicsElement[] 30 | * @returns DOMRect 31 | */ 32 | const getOuterBBox = (...svgNodes: SVGGraphicsElement[]) => { 33 | return [...svgNodes].reduce((p: DOMRect, c: SVGGraphicsElement) => { 34 | if (c) { 35 | const bbox = c.getBBox(); 36 | p.x = Math.min(p.x, bbox.x); 37 | p.y = Math.min(p.y, bbox.y); 38 | p.width = Math.max(p.width, bbox.width); 39 | const yOffset = c && c.attributes && c.attributes['y']; 40 | p.height = Math.max( 41 | p.height, 42 | ((yOffset && parseFloat(yOffset.value)) || 0) + bbox.height 43 | ); 44 | } 45 | return p; 46 | }, new DOMRect(0, 0, 0, 0)); 47 | }; 48 | 49 | /** 50 | * 51 | * @param align AnnotationAlignment 52 | * @param y 53 | * @returns 54 | */ 55 | const leftRightDynamic = (align: AnnotationAlignment, y: number) => { 56 | if ( 57 | !align || 58 | align === AnnotationAlignment.dynamic || 59 | align === AnnotationAlignment.left || 60 | align === AnnotationAlignment.right 61 | ) { 62 | if (y < 0) { 63 | align = AnnotationAlignment.top; 64 | } else { 65 | align = AnnotationAlignment.bottom; 66 | } 67 | } 68 | return align; 69 | }; 70 | 71 | /** 72 | * 73 | * @param align 74 | * @param x 75 | * @returns 76 | */ 77 | const topBottomDynamic = (align: AnnotationAlignment, x: number) => { 78 | if ( 79 | !align || 80 | align === AnnotationAlignment.dynamic || 81 | align === AnnotationAlignment.top || 82 | align === AnnotationAlignment.bottom 83 | ) { 84 | if (x < 0) { 85 | align = AnnotationAlignment.right; 86 | } else { 87 | align = AnnotationAlignment.left; 88 | } 89 | } 90 | return align; 91 | }; 92 | 93 | const alignment = (note: AnnotationNote) => { 94 | let x = -note.bbox.x; 95 | let y = -note.bbox.y; 96 | if (orientationTopBottom.indexOf(note.orientation) !== -1) { 97 | note.align = topBottomDynamic(note.align, note.offset.x); 98 | if ( 99 | (note.offset.y < 0 && 100 | note.orientation === AnnotationOrientation.topBottom) || 101 | note.orientation === AnnotationOrientation.top 102 | ) { 103 | y -= note.bbox.height + note.padding; 104 | } else { 105 | y += note.padding; 106 | } 107 | 108 | if (note.align === AnnotationAlignment.middle) { 109 | x -= note.bbox.width / 2; 110 | } else if (note.align === AnnotationAlignment.right) { 111 | x -= note.bbox.width; 112 | } 113 | } else if (orientationLeftRight.indexOf(note.orientation) !== -1) { 114 | note.align = leftRightDynamic(note.align, note.offset.y); 115 | if ( 116 | (note.offset.x < 0 && 117 | note.orientation === AnnotationOrientation.leftRight) || 118 | note.orientation === AnnotationOrientation.left 119 | ) { 120 | x -= note.bbox.width + note.padding; 121 | } else { 122 | x += note.padding; 123 | } 124 | 125 | if (note.align === AnnotationAlignment.middle) { 126 | y -= note.bbox.height / 2; 127 | } else if (note.align === AnnotationAlignment.top) { 128 | y -= note.bbox.height; 129 | } 130 | } 131 | 132 | return { x, y }; 133 | }; 134 | 135 | /** 136 | * 137 | * @param param0 138 | * @returns 139 | */ 140 | const horizontalLine = (note: AnnotationNote) => { 141 | let x = 0, 142 | y = 0; 143 | note.align = topBottomDynamic(note.align, note.offset.x); 144 | 145 | if (note.align === AnnotationAlignment.right) { 146 | x -= note.bbox.width; 147 | } else if (note.align === AnnotationAlignment.middle) { 148 | x -= note.bbox.width / 2; 149 | } 150 | 151 | const data = [ 152 | [x, y], 153 | [x + note.bbox.width, y], 154 | ]; 155 | return data; 156 | }; 157 | 158 | /** 159 | * 160 | * @param param0 161 | * @returns 162 | */ 163 | const elbowLine = (annot: Annotation): number[][] => { 164 | let x1 = 0, 165 | x2 = annot.dx, 166 | y1 = 0, 167 | y2 = annot.dy; 168 | 169 | if (annot.width && annot.height) { 170 | if ( 171 | (annot.width > 0 && annot.dx > 0) || 172 | (annot.width < 0 && annot.dx < 0) 173 | ) { 174 | if (Math.abs(annot.width) > Math.abs(annot.dx)) { 175 | x1 = annot.width / 2; 176 | } else { 177 | x1 = annot.width; 178 | } 179 | } 180 | if ( 181 | (annot.height > 0 && annot.dy > 0) || 182 | (annot.height < 0 && annot.dy < 0) 183 | ) { 184 | if (Math.abs(annot.height) > Math.abs(annot.dy)) { 185 | y1 = annot.height / 2; 186 | } else { 187 | y1 = annot.height; 188 | } 189 | } 190 | if (x1 === annot.width / 2 && y1 === annot.height / 2) { 191 | x1 = x2; 192 | y1 = y2; 193 | } 194 | } 195 | 196 | let data = [ 197 | [x1, y1], 198 | [x2, y2], 199 | ], 200 | diffY = y2 - y1, 201 | diffX = x2 - x1, 202 | xe = x2, 203 | ye = y2; 204 | 205 | let opposite = (y2 < y1 && x2 > x1) || (x2 < x1 && y2 > y1) ? -1 : 1; 206 | 207 | if (Math.abs(diffX) < Math.abs(diffY)) { 208 | xe = x2; 209 | ye = y1 + diffX * opposite; 210 | } else { 211 | ye = y2; 212 | xe = x1 + diffY * opposite; 213 | } 214 | 215 | if (annot.outerRadius || annot.radius) { 216 | const r = 217 | (annot.outerRadius || annot.radius) + (annot.radiusPadding || 0); 218 | const length = r / Math.sqrt(2); 219 | 220 | if (Math.abs(diffX) > length && Math.abs(diffY) > length) { 221 | x1 = length * (x2 < 0 ? -1 : 1); 222 | y1 = length * (y2 < 0 ? -1 : 1); 223 | data = [ 224 | [x1, y1], 225 | [xe, ye], 226 | [x2, y2], 227 | ]; 228 | } else if (Math.abs(diffX) > Math.abs(diffY)) { 229 | const angle = Math.asin(-y2 / r); 230 | x1 = Math.abs(Math.cos(angle) * r) * (x2 < 0 ? -1 : 1); 231 | data = [ 232 | [x1, y2], 233 | [x2, y2], 234 | ]; 235 | } else { 236 | const angle = Math.acos(x2 / r); 237 | y1 = Math.abs(Math.sin(angle) * r) * (y2 < 0 ? -1 : 1); 238 | data = [ 239 | [x2, y1], 240 | [x2, y2], 241 | ]; 242 | } 243 | } else { 244 | data = [ 245 | [x1, y1], 246 | [xe, ye], 247 | [x2, y2], 248 | ]; 249 | } 250 | return data; 251 | }; 252 | 253 | const orientationTopBottom = ['topBottom', 'top', 'bottom']; 254 | const orientationLeftRight = ['leftRight', 'left', 'right']; 255 | 256 | interface Annotation { 257 | target: AnnotationTarget; 258 | dx: number; 259 | dy: number; 260 | radius?: number; 261 | outerRadius?: number; 262 | radiusPadding?: number; 263 | width?: number; 264 | height?: number; 265 | translate?: number[]; 266 | connectorPath?: string; 267 | subjectPath?: string; 268 | notePath?: string; 269 | note: AnnotationNote; 270 | } 271 | 272 | interface AnnotationTarget { 273 | type: string; 274 | axis: 'x' | 'y'; 275 | values: number[]; 276 | dates?: Date[]; 277 | data: any[]; 278 | height?: number; 279 | width?: number; 280 | x?: number; 281 | y?: number; 282 | } 283 | 284 | interface AnnotationNote { 285 | title: string[]; 286 | label: string[]; 287 | bbox?: DOMRect; 288 | align?: AnnotationAlignment; 289 | orientation?: AnnotationOrientation; 290 | offset?: { x: number; y: number }; 291 | padding?: number; 292 | width?: number; 293 | height?: number; 294 | dx?: number; 295 | dy?: number; 296 | } 297 | 298 | export { 299 | getOuterBBox, 300 | leftRightDynamic, 301 | topBottomDynamic, 302 | alignment, 303 | horizontalLine, 304 | elbowLine, 305 | Annotation, 306 | AnnotationTarget, 307 | AnnotationNote, 308 | }; 309 | -------------------------------------------------------------------------------- /src/bar-chart.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import * as d3 from 'd3'; 5 | 6 | import { getOuterBBox, alignment, elbowLine, horizontalLine } from './annot'; 7 | import { 8 | computeAll, 9 | computeSubstrateData, 10 | joinArrayWithCommasAnd, 11 | loadAnnotations, 12 | loadDataCsv, 13 | updateConfigWithFormats, 14 | } from './util'; 15 | import { Sonifier } from './sonify'; 16 | import { ChartConfig, DimensionConfig, DataConfig, MarginConfig } from './core'; 17 | import { NavigationController } from './navigation-controller'; 18 | 19 | class BarChart { 20 | private _data: any; 21 | private _dimensions: any; 22 | private _config: ChartConfig; 23 | private _scales: { x: any; y: any; fill: any; tooltip: any }; 24 | private _axes: { x: any; y: any }; 25 | private _shapes: { area: any; line: any }; 26 | private _legends: any; 27 | 28 | private _nav: NavigationController; 29 | 30 | private _sonifier: Sonifier; 31 | 32 | private _containerId: string; 33 | private $container: d3.selection; 34 | private $chartWrapper: d3.selection; 35 | private $chart: d3.selection; 36 | private $chartBefore: d3.selection; 37 | private $chartAfter: d3.selection; 38 | private $chartFocus: d3.selection; 39 | private $chartLive: d3.selection; 40 | 41 | private $axesG: d3.selection; 42 | private $markG: d3.selection; 43 | private $annotG: d3.selection; 44 | private $hoverG: d3.selection; 45 | private $ariaG: d3.selection; 46 | 47 | private $tooltips: { x: any; y: any; raw: any }; 48 | 49 | constructor( 50 | containerSelector: string, 51 | config: ChartConfig, 52 | dimensions: DimensionConfig, 53 | dataConfig: DataConfig 54 | ) { 55 | this._data = {}; 56 | this.$container = d3.select(containerSelector); 57 | this._containerId = containerSelector; 58 | this._dimensions = dimensions; 59 | this._config = config; 60 | this._scales = { 61 | x: d3.scaleBand().padding(0.2), 62 | y: d3.scaleLinear(), 63 | tooltip: d3.scaleBand().padding(0.2), 64 | fill: d3.scaleOrdinal(d3.schemeCategory10), 65 | }; 66 | this._axes = { 67 | x: d3.axisBottom(this._scales.x), 68 | y: d3.axisRight(this._scales.y), 69 | }; 70 | this._legends = { fill: undefined }; 71 | 72 | if (this.config.z) { 73 | this._dimensions.margin.r += 140; 74 | } 75 | 76 | this.$tooltips = { x: undefined, y: undefined, raw: undefined }; 77 | 78 | this.config.x = updateConfigWithFormats(this.config.x); 79 | this.config.y = updateConfigWithFormats(this.config.y); 80 | 81 | this.createElements(); 82 | this.createScalesAxes(); 83 | this.createKeyHandler(); 84 | this.createSonification(); 85 | this.createNavigation(); 86 | 87 | this.loadData(dataConfig, () => { 88 | if (!this._data.raw) { 89 | console.error('Data not loaded!'); 90 | } else { 91 | console.log('Data loaded'); 92 | this.initChart(); 93 | this.drawChart(); 94 | this.initSonifier(); 95 | this.createDescriptions(); 96 | } 97 | }); 98 | } 99 | 100 | get width(): number { 101 | return this._dimensions.width; 102 | } 103 | 104 | get height(): number { 105 | return this._dimensions.height; 106 | } 107 | 108 | get margin(): MarginConfig { 109 | return this._dimensions.margin; 110 | } 111 | 112 | get config(): ChartConfig { 113 | return this._config; 114 | } 115 | 116 | createElements() { 117 | console.log('create elements called'); 118 | this.$chartBefore = this.$container.append('div'); 119 | 120 | this.$chartWrapper = this.$container 121 | .append('div') 122 | .attr('class', 'chart-wrapper') 123 | .attr('aria-hidden', 'false') 124 | .attr('dir', 'ltr') 125 | .attr('tabindex', '0') 126 | .attr('role', 'application') 127 | .attr('aria-label', 'Interactive chart. Press enter key to start.') 128 | .style('height', '100%') 129 | .style('width', '100%'); 130 | 131 | this.$chartAfter = this.$container.append('div'); 132 | 133 | // TODO needs to be a modal div 134 | this.$chartFocus = this.$chartWrapper.append('div'); 135 | this.$chartLive = this.$chartWrapper.append('div'); 136 | this.$chart = this.$chartWrapper 137 | .append('svg') 138 | .attr('aria-hidden', 'false') 139 | .attr('height', '100%') 140 | .attr('width', '100%') 141 | .append('g') 142 | .attr( 143 | 'transform', 144 | 'translate(' + [this.margin.l, this.margin.t] + ')' 145 | ); 146 | 147 | this.$axesG = this.$chart.append('g').attr('aria-hidden', 'true'); 148 | this.$markG = this.$chart.append('g').attr('aria-hidden', 'true'); 149 | this.$annotG = this.$chart.append('g').attr('aria-hidden', 'true'); 150 | this.$hoverG = this.$chart.append('g').attr('aria-hidden', 'true'); 151 | this.$ariaG = this.$chart 152 | .append('g') 153 | .attr('aria-hidden', 'false') 154 | .attr('role', 'region'); 155 | 156 | this.$tooltips.raw = {}; 157 | this.$tooltips.raw.container = this.$container 158 | .append('div') 159 | .attr('class', 'tooltip-container tooltip-raw') 160 | .attr('aria-hidden', 'true') 161 | .style('visibility', 'hidden'); 162 | this.$tooltips.raw.base = this.$tooltips.raw.container 163 | .append('div') 164 | .attr('class', 'tooltip tooltip-raw'); 165 | this.$tooltips.raw.inner = this.$tooltips.raw.base 166 | .append('div') 167 | .attr('class', 'tooltip-inner'); 168 | this.$tooltips.raw.label = this.$tooltips.raw.inner 169 | .append('span') 170 | .attr('class', 'tooltip-label'); 171 | this.$tooltips.raw.value = this.$tooltips.raw.inner 172 | .append('span') 173 | .attr('class', 'tooltip-value'); 174 | if (this.config.z) { 175 | this.$tooltips.raw.series = this.$tooltips.raw.inner 176 | .append('div') 177 | .attr('class', 'tooltip-series'); 178 | this.$tooltips.raw.series 179 | .append('span') 180 | .attr('class', 'tooltip-series-label') 181 | .text('Series:'); 182 | this.$tooltips.raw.series 183 | .append('span') 184 | .attr('class', 'tooltip-series-legend'); 185 | this.$tooltips.raw.series 186 | .append('span') 187 | .attr('class', 'tooltip-series-series'); 188 | } 189 | 190 | this.$tooltips.x = {}; 191 | this.$tooltips.x.container = this.$container 192 | .append('div') 193 | .attr('class', 'tooltip-container tooltip-x') 194 | .attr('aria-hidden', 'true') 195 | .style('visibility', 'hidden'); 196 | this.$tooltips.x.base = this.$tooltips.x.container 197 | .append('div') 198 | .attr('class', 'tooltip tooltip-x'); 199 | this.$tooltips.x.inner = this.$tooltips.x.base 200 | .append('div') 201 | .attr('class', 'tooltip-inner'); 202 | this.$tooltips.x.label = this.$tooltips.x.inner 203 | .append('span') 204 | .attr('class', 'tooltip-label'); 205 | this.$tooltips.x.value = this.$tooltips.x.inner 206 | .append('span') 207 | .attr('class', 'tooltip-value'); 208 | 209 | this.$tooltips.y = {}; 210 | this.$tooltips.y.container = this.$container 211 | .append('div') 212 | .attr('class', 'tooltip-container tooltip-y tooltip-left') 213 | .attr('aria-hidden', 'true') 214 | .style('visibility', 'hidden'); 215 | this.$tooltips.y.base = this.$tooltips.y.container 216 | .append('div') 217 | .attr('class', 'tooltip tooltip-y'); 218 | this.$tooltips.y.inner = this.$tooltips.y.base 219 | .append('div') 220 | .attr('class', 'tooltip-inner'); 221 | this.$tooltips.y.label = this.$tooltips.y.inner 222 | .append('span') 223 | .attr('class', 'tooltip-label'); 224 | this.$tooltips.y.series = this.$tooltips.y.inner 225 | .append('div') 226 | .attr('class', 'tooltip-series'); 227 | } 228 | 229 | createScalesAxes() { 230 | this._scales.x.range([0, this.width - this.margin.l - this.margin.r]); 231 | this._scales.y.range([this.height - this.margin.t - this.margin.b, 0]); 232 | this._scales.tooltip.range([-10, -90]); 233 | 234 | // TODO format x axis 235 | this._axes.x.tickSizeOuter(0); 236 | this._axes.y 237 | .tickSize(this._scales.x.range()[1] + 60) 238 | .tickFormat(this.config.y.format_short); 239 | } 240 | 241 | createDescriptions() { 242 | this.$chartBefore 243 | .attr('class', 'chart-sr-desc-container chart-sr-before') 244 | .attr('tabindex', 0) 245 | .attr('aria-hidden', 'false'); 246 | let $beforeWrapper = this.$chartBefore 247 | .append('div') 248 | .attr('class', 'chart-sr-desc-wrapper') 249 | .attr('aria-hidden', 'false'); 250 | let $beforeContent = $beforeWrapper 251 | .append('div') 252 | .attr('class', 'chart-sr-desc-content chart-sr-desc-start'); 253 | 254 | $beforeContent 255 | .append('div') 256 | .attr('aria-hidden', false) 257 | .attr('class', 'chart-sr-desc-title') 258 | .text('Chart Description'); 259 | $beforeContent 260 | .append('div') 261 | .attr('class', 'chart-sr-desc-title') 262 | .text(this.config.description.title); 263 | $beforeContent 264 | .append('div') 265 | .attr('class', 'chart-sr-desc-type') 266 | .text( 267 | 'Bar chart with ' + 268 | this._data.series.length + 269 | ' data series.' + 270 | (this._data.series.length > 1 271 | ? ' The series are: ' + 272 | this._data.series 273 | .map((s) => this.config.z.map[s.key]) 274 | .join(', ') + 275 | '.' 276 | : '') 277 | ); 278 | $beforeContent 279 | .append('div') 280 | .attr('class', 'chart-sr-desc-caption') 281 | .text(this.config.description.caption); 282 | $beforeContent 283 | .append('div') 284 | .attr('class', 'chart-sr-desc-x-axis') 285 | .text( 286 | 'This chart has 1 X axis displaying ' + 287 | this.config.x.label_axis + 288 | '. Data ranges from ' + 289 | this.config.x.format_long(this._scales.x.domain()[0]) + 290 | ' to ' + 291 | this.config.x.format_long(this._scales.x.domain()[1]) + 292 | '.' 293 | ); 294 | $beforeContent 295 | .append('div') 296 | .attr('class', 'chart-sr-desc-y-axis') 297 | .text( 298 | 'This chart has 1 Y axis displaying ' + 299 | this.config.y.label_axis + 300 | '. Data ranges from ' + 301 | this.config.y.format_long(this._scales.y.domain()[0]) + 302 | ' to ' + 303 | this.config.y.format_long(this._scales.y.domain()[1]) + 304 | '.' 305 | ); 306 | 307 | this.$chartAfter 308 | .attr('class', 'chart-sr-desc-container chart-sr-after') 309 | .attr('aria-hidden', 'false'); 310 | let $afterWrapper = this.$chartAfter 311 | .append('div') 312 | .attr('class', 'chart-sr-desc-wrapper') 313 | .attr('aria-hidden', 'false'); 314 | let $afterContent = $afterWrapper 315 | .append('div') 316 | .attr('class', 'chart-sr-desc-content chart-sr-desc-exit') 317 | .attr('tabindex', 0) 318 | .attr('aria-hidden', false) 319 | .text('End of interactive chart'); 320 | 321 | this.$chartLive 322 | .attr('class', 'chart-sr-desc-container chart-sr-live') 323 | .attr('aria-hidden', 'false') 324 | .attr('tabindex', '-1') 325 | .attr('aria-live', 'assertive') 326 | .attr('role', 'alert'); 327 | let $liveWrapper = this.$chartLive 328 | .append('div') 329 | .attr('class', 'chart-sr-desc-wrapper') 330 | .attr('aria-hidden', 'false'); 331 | $liveWrapper 332 | .append('p') 333 | .attr('class', 'chart-sr-desc-content chart-sr-live-content') 334 | .text('Aria-label goes here'); 335 | 336 | this.$chartFocus 337 | .attr('class', 'chart-sr-desc-container chart-sr-focus') 338 | .attr('role', 'document') 339 | .attr('aria-hidden', 'false') 340 | .attr('tabindex', '-1'); 341 | let $focusWrapper = this.$chartFocus 342 | .append('div') 343 | .attr('class', 'chart-sr-desc-wrapper') 344 | .attr('aria-hidden', 'false'); 345 | $focusWrapper 346 | .append('p') 347 | .attr('class', 'chart-sr-desc-content chart-sr-focus-landing') 348 | .text('Document mode. Escape key to return.'); 349 | $focusWrapper 350 | .append('p') 351 | .attr('class', 'chart-sr-desc-content chart-sr-focus-content') 352 | .text('Aria-label goes here'); 353 | } 354 | 355 | createKeyHandler() { 356 | this.$chartWrapper.on('keydown', (event) => { 357 | console.log(event); 358 | switch (event.keyCode) { 359 | // Arrow Left 360 | case 37: 361 | // Arrow Right 362 | case 39: 363 | this._nav.action( 364 | 'left_right', 365 | event.keyCode === 37 ? -1 : 1, 366 | this._data, 367 | this.config, 368 | this.$ariaG, 369 | this.$chartWrapper, 370 | event.shiftKey 371 | ); 372 | event.preventDefault(); 373 | break; 374 | // Arrow Up 375 | case 38: 376 | // Arrow Down 377 | case 40: 378 | this._nav.action( 379 | 'series_up_down', 380 | event.keyCode === 38 ? 1 : -1, 381 | this._data, 382 | this.config, 383 | this.$ariaG, 384 | this.$chartWrapper, 385 | event.shiftKey 386 | ); 387 | event.preventDefault(); 388 | break; 389 | // Enter 390 | case 13: 391 | if (event.shiftKey) { 392 | this._nav.toggleSonifier( 393 | this._data, 394 | this._config, 395 | this.$ariaG, 396 | this.$chartWrapper 397 | ); 398 | } else { 399 | this._nav.action( 400 | 'up_down', 401 | 1, 402 | this._data, 403 | this.config, 404 | this.$ariaG, 405 | this.$chartWrapper, 406 | false 407 | ); 408 | } 409 | event.preventDefault(); 410 | break; 411 | // Esc 412 | case 27: 413 | this._nav.action( 414 | 'up_down', 415 | -1, 416 | this._data, 417 | this.config, 418 | this.$ariaG, 419 | this.$chartWrapper, 420 | false 421 | ); 422 | event.preventDefault(); 423 | break; 424 | // Space 425 | case 32: 426 | this._nav.action( 427 | 'focus', 428 | 0, 429 | this._data, 430 | this.config, 431 | this.$ariaG, 432 | this.$chartWrapper, 433 | false 434 | ); 435 | event.preventDefault(); 436 | break; 437 | // Home 438 | case 36: 439 | // End 440 | case 35: 441 | this._nav.action( 442 | 'left_right', 443 | event.keyCode === 36 444 | ? this._nav.getSelectedIndex() * -1 445 | : this._nav.getSelectedLength() - 446 | this._nav.getSelectedIndex() - 447 | 1, 448 | this._data, 449 | this.config, 450 | this.$ariaG, 451 | this.$chartWrapper, 452 | event.shiftKey 453 | ); 454 | event.preventDefault(); 455 | break; 456 | // Page Up 457 | case 33: 458 | // Page Down 459 | case 34: 460 | this._nav.action( 461 | 'left_right', 462 | event.keyCode === 33 ? -5 : 5, 463 | this._data, 464 | this.config, 465 | this.$ariaG, 466 | this.$chartWrapper, 467 | event.shiftKey 468 | ); 469 | event.preventDefault(); 470 | break; 471 | // CTRL 472 | case 17: 473 | if (this._sonifier.isPlaying) { 474 | this._nav.toggleSonifier( 475 | this._data, 476 | this._config, 477 | this.$ariaG, 478 | this.$chartWrapper 479 | ); 480 | } 481 | break; 482 | // INSERT | Num5 | 5 483 | case 45: 484 | case 12: 485 | case 53: 486 | this._nav.action( 487 | 'none', 488 | 1, 489 | this._data, 490 | this.config, 491 | this.$ariaG, 492 | this.$chartWrapper, 493 | event.shiftKey 494 | ); 495 | event.preventDefault(); 496 | break; 497 | // I | X | Y | D | C | F 498 | case 73: 499 | case 88: 500 | case 89: 501 | case 68: 502 | case 67: 503 | case 70: 504 | this._nav.action( 505 | 'control', 506 | event.keyCode, 507 | this._data, 508 | this.config, 509 | this.$ariaG, 510 | this.$chartWrapper, 511 | event.shiftKey 512 | ); 513 | event.preventDefault(); 514 | break; 515 | } 516 | }); 517 | } 518 | 519 | createSonification() { 520 | const onPlayData = (d, i, s) => { 521 | this._nav.updateSonifier( 522 | this._data, 523 | this.config, 524 | this.$ariaG, 525 | this.$chartWrapper, 526 | i, 527 | s 528 | ); 529 | this.highlightPoint(d); 530 | }; 531 | this._sonifier = new Sonifier(onPlayData); 532 | } 533 | 534 | createNavigation() { 535 | let tree = new Map(); 536 | 537 | let root = { 538 | type: 'control', 539 | level: 0, 540 | id: 1, 541 | values: ['-'], 542 | selected: 0, 543 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 544 | '.chart-wrapper', 545 | }; 546 | 547 | let aNode = { 548 | type: 'control', 549 | level: 1, 550 | id: 2, 551 | selected: 0, 552 | values: this.config.z ? [2, 3, 4, 5, 16, 19] : [2, 3, 4, 5, 16], 553 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 554 | '.annotation-ghost-container', 555 | }; 556 | 557 | let xNode = { 558 | type: 'control', 559 | level: 1, 560 | id: 3, 561 | selected: 0, 562 | values: this.config.z ? [2, 3, 4, 5, 16, 19] : [2, 3, 4, 5, 16], 563 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 564 | '.x-ghost-container', 565 | }; 566 | 567 | let yNode = { 568 | type: 'control', 569 | level: 1, 570 | id: 4, 571 | selected: 0, 572 | values: this.config.z ? [2, 3, 4, 5, 16, 19] : [2, 3, 4, 5, 16], 573 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 574 | '.y-ghost-container', 575 | }; 576 | 577 | let rawNode = { 578 | type: 'control', 579 | level: 1, 580 | id: 5, 581 | selected: 0, 582 | values: this.config.z ? [2, 3, 4, 5, 16, 19] : [2, 3, 4, 5, 16], 583 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 584 | '.bar-ghost-container', 585 | }; 586 | 587 | let compareNode = { 588 | type: 'control', 589 | level: 1, 590 | id: 16, 591 | selected: 0, 592 | values: this.config.z ? [2, 3, 4, 5, 16, 19] : [2, 3, 4, 5, 16], 593 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 594 | '.compare-ghost-container', 595 | }; 596 | 597 | if (this._config.z) { 598 | let filterNode = { 599 | type: 'control', 600 | level: 1, 601 | id: 19, 602 | selected: 0, 603 | values: [2, 3, 4, 5, 16, 19], 604 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 605 | '.filter-ghost-container', 606 | }; 607 | 608 | let filterTickNode = { 609 | type: 'filter', 610 | level: 2, 611 | id: 20, 612 | selected: 0, 613 | getData: (data: any, valueAtLevels: any[]) => data.filters, 614 | getClass: ( 615 | d: any, 616 | valueAtLevels: any[], 617 | config: ChartConfig 618 | ) => { 619 | return '.filter-ghost.value-' + formatStringClass(d.value); 620 | }, 621 | }; 622 | tree.set(root.id, { 623 | children: [ 624 | aNode.id, 625 | xNode.id, 626 | yNode.id, 627 | rawNode.id, 628 | compareNode.id, 629 | filterNode.id, 630 | ], 631 | parent: -1, 632 | element: root, 633 | }); 634 | tree.set(filterNode.id, { 635 | children: [filterTickNode.id], 636 | parent: root.id, 637 | element: filterNode, 638 | }); 639 | tree.set(filterTickNode.id, { 640 | children: [], 641 | parent: filterNode.id, 642 | element: filterTickNode, 643 | }); 644 | } 645 | 646 | let groupANode = { 647 | type: 'data-no-sonify', 648 | level: 2, 649 | id: 6, 650 | selected: 0, 651 | getData: (data: any, valueAtLevels: any[]) => data.annotations, 652 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 653 | '.annotation-group-ghost.key-' + formatStringClass(d.key), 654 | }; 655 | 656 | let binXNode = { 657 | type: 'data-sonify-values', 658 | level: 2, 659 | id: 7, 660 | selected: 0, 661 | getData: (data: any, valueAtLevels: any[]) => 662 | data.all.x.map((d) => [d[0].values]), 663 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => { 664 | return ( 665 | '.x-ghost.value-' + 666 | formatStringClass(d[0][0][config.x.name]) 667 | ); 668 | }, 669 | }; 670 | 671 | let binYNode = { 672 | type: 'data-no-sonify', 673 | sonify: 'count', 674 | level: 2, 675 | id: 8, 676 | selected: 0, 677 | getData: (data: any, valueAtLevels: any[]) => data.all.y, 678 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => { 679 | console.log(d); 680 | return '.y-ghost.value-' + d[0].key; 681 | }, 682 | }; 683 | 684 | let aEachNode = { 685 | type: 'data-no-sonify', 686 | level: 3, 687 | id: 9, 688 | selected: 0, 689 | series: 0, 690 | getData: (data: any, valueAtLevels: any[]) => 691 | valueAtLevels[2].values, 692 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 693 | '.annotation-ghost.annotation-' + d.index, 694 | }; 695 | 696 | let binXBarNode = { 697 | type: 'series_normal', 698 | jump: 'enabled', 699 | level: 3, 700 | id: 10, 701 | selected: 0, 702 | series: 0, 703 | getData: (data: any, valueAtLevels: any[]) => valueAtLevels[2], 704 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => { 705 | console.log(d); 706 | console.log(valueAtLevels); 707 | return ( 708 | '.bar.value-' + 709 | formatStringClass(d[config.x.name]) + 710 | '.series-' + 711 | formatStringClass(config.z ? d[config.z.name] : 'Series1') 712 | ); 713 | }, 714 | }; 715 | 716 | let binYCombineNode = { 717 | type: 'data-no-sonify', 718 | jump: 'disabled', 719 | level: 3, 720 | id: 15, 721 | selected: 0, 722 | getData: (data: any, valueAtLevels: any[]) => 723 | valueAtLevels[2][0].layout_sum, 724 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => { 725 | return ( 726 | '.y-ghost.value-' + 727 | valueAtLevels[2][0].key + 728 | ' .y-ghost-combine.x-value-' + 729 | formatStringClass(d.key) + 730 | '.z-value-' + 731 | formatStringClass(config.z ? d.label : 'Series1') 732 | ); 733 | }, 734 | }; 735 | 736 | let aBarNode = { 737 | type: 'series_reverse', 738 | level: 4, 739 | id: 12, 740 | selected: 0, 741 | series: 0, 742 | getData: (data: any, valueAtLevels: any[]) => 743 | valueAtLevels[3].target.data, 744 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => 745 | '.bar.value-' + 746 | formatStringClass(d[config.x.name]) + 747 | '.series-' + 748 | (config.z ? formatStringClass(d[config.z.name]) : 'Series1'), 749 | }; 750 | 751 | let binYBarNode = { 752 | type: 'series_normal', 753 | jump: 'enabled', 754 | level: 3, 755 | id: 13, 756 | selected: 0, 757 | series: 0, 758 | getData: (data: any, valueAtLevels: any[]) => [ 759 | valueAtLevels[3].values, 760 | ], 761 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => { 762 | return ( 763 | '.bar.value-' + 764 | formatStringClass(d[config.x.name]) + 765 | '.series-' + 766 | (config.z ? formatStringClass(d[config.z.name]) : 'Series1') 767 | ); 768 | }, 769 | }; 770 | 771 | let rawBarNode = { 772 | type: 'series_normal', 773 | level: 2, 774 | id: 14, 775 | selected: 0, 776 | series: 0, 777 | getData: (data: any, valueAtLevels: any[]) => data.all.raw, 778 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => { 779 | console.log(d); 780 | return ( 781 | '.bar.value-' + 782 | formatStringClass(d[config.x.name]) + 783 | '.series-' + 784 | (config.z ? formatStringClass(d[config.z.name]) : 'Series1') 785 | ); 786 | }, 787 | }; 788 | 789 | let compareXNode = { 790 | type: 'data-sonify-values', 791 | level: 2, 792 | id: 17, 793 | selected: 0, 794 | getData: (data: any, valueAtLevels: any[]) => 795 | data.all.x.map((d) => [d[0].values]), 796 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => { 797 | return ( 798 | '.x-ghost.value-' + 799 | formatStringClass(d[0][0][config.x.name]) 800 | ); 801 | }, 802 | }; 803 | 804 | let compareXBarNode = { 805 | type: 'series_normal', 806 | jump: 'enabled', 807 | level: 3, 808 | id: 18, 809 | selected: 0, 810 | series: 0, 811 | getData: (data: any, valueAtLevels: any[]) => valueAtLevels[2], 812 | getClass: (d: any, valueAtLevels: any[], config: ChartConfig) => { 813 | return ( 814 | '.bar.value-' + 815 | formatStringClass(d[config.x.name]) + 816 | '.series-' + 817 | formatStringClass(config.z ? d[config.z.name] : 'Series1') 818 | ); 819 | }, 820 | }; 821 | 822 | if (!this.config.z) { 823 | tree.set(root.id, { 824 | children: [ 825 | aNode.id, 826 | xNode.id, 827 | yNode.id, 828 | rawNode.id, 829 | compareNode.id, 830 | ], 831 | parent: -1, 832 | element: root, 833 | }); 834 | } 835 | 836 | tree.set(aNode.id, { 837 | children: [groupANode.id], 838 | parent: root.id, 839 | element: aNode, 840 | }); 841 | 842 | tree.set(xNode.id, { 843 | children: [binXNode.id], 844 | parent: root.id, 845 | element: xNode, 846 | }); 847 | 848 | tree.set(yNode.id, { 849 | children: [binYNode.id], 850 | parent: root.id, 851 | element: yNode, 852 | }); 853 | 854 | tree.set(rawNode.id, { 855 | children: [rawBarNode.id], 856 | parent: root.id, 857 | element: rawNode, 858 | }); 859 | 860 | tree.set(compareNode.id, { 861 | children: [compareXNode.id], 862 | parent: root.id, 863 | element: compareNode, 864 | }); 865 | 866 | tree.set(compareXNode.id, { 867 | children: [compareXBarNode.id], 868 | parent: compareNode.id, 869 | element: compareXNode, 870 | }); 871 | 872 | tree.set(compareXBarNode.id, { 873 | children: [], 874 | parent: compareXNode.id, 875 | element: compareXBarNode, 876 | }); 877 | 878 | tree.set(binXNode.id, { 879 | children: [binXBarNode.id], 880 | parent: xNode.id, 881 | element: binXNode, 882 | }); 883 | 884 | tree.set(groupANode.id, { 885 | children: [aEachNode.id], 886 | parent: aNode.id, 887 | element: groupANode, 888 | }); 889 | 890 | tree.set(aEachNode.id, { 891 | children: [aBarNode.id], 892 | parent: groupANode.id, 893 | element: aEachNode, 894 | }); 895 | 896 | tree.set(aBarNode.id, { 897 | children: [], 898 | parent: aEachNode.id, 899 | element: aBarNode, 900 | }); 901 | 902 | tree.set(binYNode.id, { 903 | children: [binYCombineNode.id], 904 | parent: yNode.id, 905 | element: binYNode, 906 | }); 907 | 908 | tree.set(binYCombineNode.id, { 909 | children: [binYBarNode.id], 910 | parent: binYNode.id, 911 | element: binYCombineNode, 912 | }); 913 | 914 | tree.set(rawBarNode.id, { 915 | children: [], 916 | parent: rawNode.id, 917 | element: rawBarNode, 918 | }); 919 | 920 | tree.set(binXBarNode.id, { 921 | children: [], 922 | parent: binXNode.id, 923 | element: binXBarNode, 924 | }); 925 | 926 | tree.set(binYBarNode.id, { 927 | children: [], 928 | parent: binYCombineNode.id, 929 | element: binYBarNode, 930 | }); 931 | 932 | this._nav = new NavigationController( 933 | this._sonifier, 934 | tree, 935 | root, 936 | (d: any) => this.highlightPoint(d) 937 | ); 938 | } 939 | 940 | highlightPoint(d: any) { 941 | this.fadeBaseMarks(); 942 | this.unfadeBaseMarks([d]); 943 | this.$tooltips.raw.base.style( 944 | 'transform', 945 | 'translate(' + 946 | this._scales.tooltip(d[this._config.x.name]) + 947 | '%, 0px)' 948 | ); 949 | this.$tooltips.raw.container 950 | .style('visibility', 'visible') 951 | .style( 952 | 'left', 953 | this._scales.x(d[this.config.x.name]) + 954 | this._scales.x.bandwidth() / 2 + 955 | this.margin.l + 956 | 'px' 957 | ) 958 | .style( 959 | 'top', 960 | this._scales.y(d['layout'][1]) + this.margin.t + 'px' 961 | ); 962 | this.$tooltips.raw.label.text( 963 | this.config.x.format_long(d[this.config.x.name]) 964 | ); 965 | this.$tooltips.raw.value.text( 966 | this.config.y.label_tooltip + 967 | this.config.y.format_long(d[this.config.y.name]) 968 | ); 969 | if (this.config.z) { 970 | this.$tooltips.raw.series 971 | .select('.tooltip-series-series') 972 | .text(this.config.z.map[d[this.config.z.name]]); 973 | this.$tooltips.raw.series 974 | .select('.tooltip-series-legend') 975 | .style('background', this._scales.fill(d[this.config.z.name])); 976 | } 977 | } 978 | 979 | drawBaseMarks() { 980 | console.log('draw base marks called'); 981 | 982 | let $barSelect = this.$markG 983 | .selectAll('.column.base-mark') 984 | .data(this._data.all.raw.flat()); 985 | 986 | let $barEnter = $barSelect.enter().append('rect'); 987 | 988 | let $barMerge = $barSelect 989 | .merge($barEnter) 990 | .attr('x', (d: any) => this._scales.x(d[this.config.x.name])) 991 | .attr('y', (d: any) => this._scales.y(d.layout[1])) 992 | .attr( 993 | 'height', 994 | (d: any) => 995 | this._scales.y(d.layout[0]) - this._scales.y(d.layout[1]) 996 | ) 997 | .attr('width', this._scales.x.bandwidth()) 998 | .style('fill', (d) => 999 | this._scales.fill( 1000 | this.config.z ? d[this.config.z.name] : 'Series1' 1001 | ) 1002 | ) 1003 | .attr( 1004 | 'class', 1005 | (d) => 1006 | `column base-mark x-value-${ 1007 | d[this.config.x.name] 1008 | } z-value-${ 1009 | this.config.z 1010 | ? formatStringClass(d[this.config.z.name]) 1011 | : 'Series1' 1012 | }` 1013 | ); 1014 | 1015 | $barSelect.exit().remove(); 1016 | } 1017 | 1018 | drawAxisGroupLayer() { 1019 | let $xGroupContainerSelect = this.$markG 1020 | .selectAll('.x-group-container') 1021 | .data(this._data.all.x); 1022 | let $xGroupContainerEnter = $xGroupContainerSelect 1023 | .enter() 1024 | .append('g') 1025 | .attr( 1026 | 'class', 1027 | (d) => 'x-group-container value-' + formatStringClass(d[0].key) 1028 | ); 1029 | let $xGroupContainerMerge = $xGroupContainerSelect 1030 | .merge($xGroupContainerEnter) 1031 | .attr('transform', (d) => { 1032 | let y = this._scales.y.range()[0] + 2, 1033 | x = this._scales.x(d[0].key); 1034 | return 'translate(' + [x, y] + ')'; 1035 | }); 1036 | let $bgXGroupSelect = $xGroupContainerMerge 1037 | .selectAll('.bg') 1038 | .data((d) => [d[0]]); 1039 | let $bgXGroupEnter = $bgXGroupSelect 1040 | .enter() 1041 | .append('rect') 1042 | .attr('class', 'bg'); 1043 | let $bgXGroupMerge = $bgXGroupSelect 1044 | .merge($bgXGroupEnter) 1045 | .attr('height', 36) 1046 | .attr('width', 70) 1047 | .attr('rx', 6) 1048 | .attr('ry', 6); 1049 | let $labelXGroupSelect = $xGroupContainerMerge 1050 | .selectAll('.x-group-label') 1051 | .data((d) => [d[0]]); 1052 | let $labelXGroupEnter = $labelXGroupSelect 1053 | .enter() 1054 | .append('text') 1055 | .attr('class', 'x-group-label'); 1056 | let $labelXGroupMerge = $labelXGroupSelect 1057 | .merge($labelXGroupEnter) 1058 | .attr( 1059 | 'transform', 1060 | (d: any) => 1061 | 'translate(' + [this._scales.x.bandwidth() / 2, 12] + ')' 1062 | ) 1063 | .attr('dy', '0.3em') 1064 | .text((d: any) => this.config.x.format_long(d.key)); 1065 | // Y Groups 1066 | const interval: number = this.config.y.interval 1067 | ? +this.config.y.interval 1068 | : 1; 1069 | let $yGroupContainerSelect = this.$markG 1070 | .selectAll('.y-group') 1071 | .data(this._data.all.y); 1072 | let $yGroupContainerEnter = $yGroupContainerSelect 1073 | .enter() 1074 | .append('g') 1075 | .attr('class', (d) => 'y-group value-' + d[0].key); 1076 | let $yGroupContainerMerge = $yGroupContainerSelect.merge( 1077 | $yGroupContainerEnter 1078 | ); 1079 | 1080 | let $thresholdYGroupSelect = $yGroupContainerMerge 1081 | .selectAll('.threshold') 1082 | .data((d: any) => [d[0].key * interval, (d[0].key + 1) * interval]); 1083 | 1084 | let $thresholdYGroupEnter = $thresholdYGroupSelect 1085 | .enter() 1086 | .append('path') 1087 | .attr('class', (d, i) => 'threshold threshold-' + i); 1088 | 1089 | let $thresholdYGroupMerge = $thresholdYGroupSelect 1090 | .merge($thresholdYGroupEnter) 1091 | .attr('d', (d) => { 1092 | let y = this._scales.y(d); 1093 | return ( 1094 | 'M' + 1095 | (this._scales.x.range()[0] - 50) + 1096 | ',' + 1097 | y + 1098 | 'L' + 1099 | (this._scales.x.range()[1] + 10) + 1100 | ',' + 1101 | y 1102 | ); 1103 | }); 1104 | 1105 | let $bgYGroupSelect = $yGroupContainerMerge 1106 | .selectAll('.bg') 1107 | .data((d: any) => [d[0].key * interval, (d[0].key + 1) * interval]); 1108 | 1109 | let $bgYGroupEnter = $bgYGroupSelect 1110 | .enter() 1111 | .append('rect') 1112 | .attr('height', 14) 1113 | .attr('width', 50) 1114 | .attr('class', (d, i) => 'bg bg-' + i); 1115 | 1116 | let $bgYGroupMerge = $bgYGroupSelect 1117 | .merge($bgYGroupEnter) 1118 | .attr( 1119 | 'transform', 1120 | (d) => 'translate(' + [-50, this._scales.y(d) - 16] + ')' 1121 | ); 1122 | 1123 | let $tickYGroupSelect = $yGroupContainerMerge 1124 | .selectAll('.tick') 1125 | .data((d) => [d[0].key * interval, (d[0].key + 1) * interval]); 1126 | 1127 | let $tickYGroupEnter = $tickYGroupSelect 1128 | .enter() 1129 | .append('text') 1130 | .attr('class', 'tick'); 1131 | 1132 | let $tickYGroupMerge = $tickYGroupSelect 1133 | .merge($tickYGroupEnter) 1134 | .attr( 1135 | 'transform', 1136 | (d) => 'translate(' + [-46, this._scales.y(d) - 4] + ')' 1137 | ) 1138 | .text((d) => this.config.y.format_short(d)); 1139 | } 1140 | 1141 | drawAnnotationLayer() { 1142 | const allAnnotations = [].concat.apply( 1143 | [], 1144 | this._data.annotations.map((at) => at.values) 1145 | ); 1146 | 1147 | let $annotSelect = this.$annotG 1148 | .selectAll('.annotation') 1149 | .data(allAnnotations); 1150 | 1151 | let $annotEnter = $annotSelect 1152 | .enter() 1153 | .append('g') 1154 | .attr( 1155 | 'class', 1156 | (d, i) => 'annotation ' + d.type + ' annotation-' + i 1157 | ); 1158 | 1159 | $annotEnter 1160 | .append('g') 1161 | .attr('class', 'annotation-connector') 1162 | .append('path') 1163 | .attr('class', 'connector'); 1164 | $annotEnter 1165 | .append('g') 1166 | .attr('class', 'annotation-subject') 1167 | .append('path') 1168 | .attr('class', 'subject'); 1169 | 1170 | let $annotNoteEnter = $annotEnter 1171 | .append('g') 1172 | .attr('class', 'annotation-note'); 1173 | $annotNoteEnter.append('path').attr('class', 'note-line'); 1174 | 1175 | let $annotContentEnter = $annotNoteEnter 1176 | .append('g') 1177 | .attr('class', 'annotation-note-content'); 1178 | $annotContentEnter.append('rect').attr('class', 'annotation-note-bg'); 1179 | $annotContentEnter 1180 | .append('text') 1181 | .attr('class', 'annotation-note-title') 1182 | .append('tspan') 1183 | .attr('x', 0) 1184 | .attr('dy', '1.2em'); 1185 | $annotContentEnter 1186 | .append('text') 1187 | .attr('class', 'annotation-note-label') 1188 | .attr('y', 16.5) 1189 | .append('tspan') 1190 | .attr('x', 0) 1191 | .attr('dy', '1.2em'); 1192 | 1193 | let $annotMerge = $annotSelect.merge($annotEnter); 1194 | 1195 | let $annotTitleSelect = $annotMerge 1196 | .select('.annotation-note .annotation-note-title tspan') 1197 | .selectAll('tspan') 1198 | .data((d) => d.note.title); 1199 | let $annotTitleEnter = $annotTitleSelect.enter().append('tspan'); 1200 | $annotTitleSelect 1201 | .merge($annotTitleEnter) 1202 | .attr('x', 0) 1203 | .attr('dy', '1.2em') 1204 | .text((d) => d); 1205 | let $annotLabelSelect = $annotMerge 1206 | .select('.annotation-note .annotation-note-label tspan') 1207 | .selectAll('tspan') 1208 | .data((d) => d.note.label); 1209 | let $annotLabelEnter = $annotLabelSelect.enter().append('tspan'); 1210 | $annotLabelSelect 1211 | .merge($annotLabelEnter) 1212 | .attr('x', 0) 1213 | .attr('dy', '1.2em') 1214 | .text((d) => d); 1215 | 1216 | const raw = this._data.all.raw; 1217 | const scales = this._scales; 1218 | const config = this._config; 1219 | 1220 | // Compute all these parameters here 1221 | $annotMerge.each(function (d, i) { 1222 | let $annot = d3.select(this); 1223 | d.index = i; 1224 | // Get bbox of the note text 1225 | d.note.bbox = getOuterBBox( 1226 | $annot.select('.annotation-note-title').node(), 1227 | $annot.select('.annotation-note-label').node() 1228 | ); 1229 | d.note.align = 'dynamic'; 1230 | d.note.orientation = 'topBottom'; 1231 | d.note.offset = { x: d.dx, y: d.dy }; 1232 | d.note.padding = 5; 1233 | 1234 | let seriesList = config.z ? d.target.series : ['Series'], 1235 | iValues = d.target.values.map((x) => 1236 | scales.x.domain().indexOf(x) 1237 | ), 1238 | rawValues = iValues.map((i) => raw[i]); 1239 | d.target.data = config.z 1240 | ? d.target.series.map((s) => { 1241 | const si = rawValues[0] 1242 | .map((ss) => ss[config.z.name]) 1243 | .indexOf(s); 1244 | return { series: s, values: rawValues.map((d) => d[si]) }; 1245 | }) 1246 | : [{ series: 'Series1', values: rawValues.map((d) => d[0]) }]; 1247 | let yValues = rawValues.flat().map((d) => scales.y(d['layout'][1])); 1248 | let xValues = rawValues 1249 | .flat() 1250 | .map((d) => scales.x(d[config.x.name])); 1251 | let l = Math.min(...xValues), 1252 | r = Math.max(...xValues), 1253 | t = Math.min(...yValues), 1254 | b = Math.max(...yValues); 1255 | d.target.height = Math.max(b - t, 10); 1256 | d.target.width = Math.max(r - l, 10); 1257 | d.target.y = t; 1258 | d.target.x = l; 1259 | 1260 | d.translate = [r + scales.x.bandwidth(), b]; 1261 | 1262 | let { x, y } = alignment(d.note); 1263 | d.note.dx = x; 1264 | d.note.dy = y; 1265 | 1266 | let cd = elbowLine(d), 1267 | nd = horizontalLine(d.note); 1268 | d.connectorPath = 'M' + cd.join('L'); 1269 | 1270 | d.subjectPath = ''; 1271 | d.note.notePath = 'M' + nd.join('L'); 1272 | d.note.width = d.note.bbox.width; 1273 | d.note.height = d.note.bbox.height; 1274 | }); 1275 | 1276 | $annotMerge.attr('transform', (d) => 'translate(' + d.translate + ')'); 1277 | $annotMerge 1278 | .select('.annotation-connector .connector') 1279 | .attr('d', (d) => d.connectorPath); 1280 | $annotMerge 1281 | .select('.annotation-subject .subject') 1282 | .attr('d', (d) => d.subjectPath); 1283 | $annotMerge 1284 | .select('.annotation-note') 1285 | .attr('transform', (d) => 'translate(' + [d.dx, d.dy] + ')'); 1286 | $annotMerge 1287 | .select('.annotation-note .note-line') 1288 | .attr('d', (d) => d.note.notePath); 1289 | $annotMerge 1290 | .select('.annotation-note .annotation-note-content') 1291 | .attr( 1292 | 'transform', 1293 | (d) => 'translate(' + [d.note.dx, d.note.dy] + ')' 1294 | ); 1295 | $annotMerge 1296 | .select('.annotation-note .annotation-note-bg') 1297 | .attr('width', (d) => d.note.width) 1298 | .attr('height', (d) => d.note.height); 1299 | 1300 | // HIGHLIGHTED MARKS 1301 | let $groupAnnotSelect = this.$markG 1302 | .selectAll('.annotation-group') 1303 | .data(allAnnotations); 1304 | 1305 | let $groupAnnotEnter = $groupAnnotSelect 1306 | .enter() 1307 | .append('g') 1308 | .attr( 1309 | 'class', 1310 | (d) => 1311 | 'annotation-group key-' + 1312 | formatStringClass(d.type) + 1313 | ' annotation-' + 1314 | d.index 1315 | ); 1316 | } 1317 | 1318 | drawAxesLegends() { 1319 | let $xLabelSelect = this.$axesG 1320 | .selectAll('.axis-label.axis-label-x') 1321 | .data([this.config.x.label_axis]); 1322 | 1323 | let $xLabelEnter = $xLabelSelect 1324 | .enter() 1325 | .append('text') 1326 | .attr('class', 'axis-label axis-label-x'); 1327 | 1328 | let $xLabelMerge = $xLabelSelect 1329 | .merge($xLabelEnter) 1330 | .attr( 1331 | 'transform', 1332 | 'translate(' + 1333 | [ 1334 | this._scales.x.range()[1], 1335 | this._scales.y.range()[0] + 28, 1336 | ] + 1337 | ')' 1338 | ) 1339 | .attr('dy', '0.7em') 1340 | .style('text-anchor', 'end') 1341 | .text((t) => t); 1342 | 1343 | let $xAxisSelect = this.$axesG 1344 | .selectAll('.axis.axis-x') 1345 | .data(['bottom']); 1346 | 1347 | let $xAxisEnter = $xAxisSelect 1348 | .enter() 1349 | .append('g') 1350 | .attr('class', 'axis axis-x axis-temporal'); 1351 | 1352 | let $xAxisMerge = $xAxisSelect 1353 | .merge($xAxisEnter) 1354 | .attr( 1355 | 'transform', 1356 | 'translate(' + [0, this._scales.y.range()[0]] + ')' 1357 | ) 1358 | .call(this._axes.x) 1359 | .call((g) => g.select('.domain').remove()); 1360 | 1361 | let $yLabelSelect = this.$axesG 1362 | .selectAll('.axis-label.axis-label-y') 1363 | .data([this.config.y.label_axis]); 1364 | 1365 | let $yLabelEnter = $yLabelSelect 1366 | .enter() 1367 | .append('text') 1368 | .attr('class', 'axis-label axis-label-y'); 1369 | 1370 | let $yLabelMerge = $yLabelSelect 1371 | .merge($yLabelEnter) 1372 | .attr('transform', 'translate(' + [-46.5, -46] + ')') 1373 | .attr('dy', '0.7em') 1374 | .style('text-anchor', 'start') 1375 | .text((t) => t); 1376 | 1377 | let $yAxisSelect = this.$axesG.selectAll('.axis.axis-y').data(['left']); 1378 | 1379 | let $yAxisEnter = $yAxisSelect 1380 | .enter() 1381 | .append('g') 1382 | .attr('class', 'axis axis-y axis-quantitative'); 1383 | 1384 | let $yAxisMerge = $yAxisSelect 1385 | .merge($yAxisEnter) 1386 | .attr('transform', 'translate(' + [-50, 0] + ')') 1387 | .call(this._axes.y) 1388 | .call((g) => { 1389 | g.select('.domain').remove(); 1390 | g.selectAll('.tick text').attr('x', 4).attr('dy', -4); 1391 | }); 1392 | 1393 | const allAnnotations = [].concat.apply( 1394 | [], 1395 | this._data.annotations.map((at) => at.values) 1396 | ); 1397 | 1398 | let $aLabelSelect = this.$axesG 1399 | .selectAll('.axis-label.axis-label-a') 1400 | .data(['Data Insights']); 1401 | 1402 | let $aLabelEnter = $aLabelSelect 1403 | .enter() 1404 | .append('text') 1405 | .attr('class', 'axis-label axis-label-a'); 1406 | 1407 | let $aLabelMerge = $aLabelSelect 1408 | .merge($aLabelEnter) 1409 | .attr( 1410 | 'transform', 1411 | 'translate(' + 1412 | [ 1413 | this._scales.x.range()[1] - 1414 | allAnnotations.length * 30 + 1415 | 20, 1416 | -48, 1417 | ] + 1418 | ')' 1419 | ) 1420 | .attr('dy', '0.7em') 1421 | .style('text-anchor', 'start') 1422 | .text((t) => t); 1423 | 1424 | // Draw a legend for series 1425 | if (this.config.z) { 1426 | let $aLabelSelect = this.$axesG 1427 | .selectAll('.axis-label.axis-label-f') 1428 | .data(['Series Filter']); 1429 | 1430 | let $aLabelEnter = $aLabelSelect 1431 | .enter() 1432 | .append('text') 1433 | .attr('class', 'axis-label axis-label-f'); 1434 | 1435 | let $aLabelMerge = $aLabelSelect 1436 | .merge($aLabelEnter) 1437 | .attr( 1438 | 'transform', 1439 | 'translate(' + [this._scales.x.range()[1] + 32, -48] + ')' 1440 | ) 1441 | .attr('dy', '0.7em') 1442 | .style('text-anchor', 'start') 1443 | .text((t) => t); 1444 | 1445 | let $legendSelect = this.$axesG 1446 | .selectAll('.legend.legend-z') 1447 | .data(['right']); 1448 | let $legendEnter = $legendSelect 1449 | .enter() 1450 | .append('g') 1451 | .attr('class', 'legend legend-z') 1452 | .attr( 1453 | 'transform', 1454 | `translate(${[this._scales.x.range()[1] + 32, -28]})` 1455 | ); 1456 | let $legendMerge = $legendSelect.merge($legendEnter); 1457 | 1458 | let $tickSelect = $legendMerge 1459 | .selectAll('.legend-tick') 1460 | .data(this._data.filters); 1461 | 1462 | let $tickEnter = $tickSelect.enter().append('g'); 1463 | 1464 | let $tickMerge = $tickSelect 1465 | .merge($tickEnter) 1466 | .attr( 1467 | 'class', 1468 | (f) => 1469 | `legend-tick value-${formatStringClass(f.value)} ${ 1470 | f.filtered ? 'filtered' : '' 1471 | }` 1472 | ) 1473 | .attr('transform', (t, i) => `translate(${[0, i * 26]})`); 1474 | 1475 | $tickEnter 1476 | .append('rect') 1477 | .attr('class', 'legend-card') 1478 | .attr('width', 10) 1479 | .attr('height', 10) 1480 | .attr('rx', 2) 1481 | .attr('rx', 2); 1482 | $tickMerge 1483 | .selectAll('.legend-card') 1484 | .style('stroke', (t) => this._scales.fill(t.value)) 1485 | .style('fill', (t) => this._scales.fill(t.value)); 1486 | $tickEnter 1487 | .append('text') 1488 | .attr('class', 'legend-value') 1489 | .attr('x', 16) 1490 | .attr('dy', '0.7em'); 1491 | $tickMerge 1492 | .selectAll('.legend-value') 1493 | .text((t) => this.config.z.map[t.value]); 1494 | } 1495 | } 1496 | 1497 | clearHighlightClassed() { 1498 | this.$markG 1499 | .selectAll( 1500 | '.y-group.highlight,.x-group-container.highlight,.annotation-group.highlight' 1501 | ) 1502 | .classed('highlight', false); 1503 | this.$annotG 1504 | .selectAll('.annotation.highlight') 1505 | .classed('highlight', false); 1506 | this.$ariaG 1507 | .selectAll('.annotation-ghost.highlight') 1508 | .classed('highlight', false); 1509 | this.$markG.selectAll('.base-mark').classed('fade', false); 1510 | } 1511 | 1512 | ghostSeriesLayer() { 1513 | // TODO how to describe where you are in the chart based on the current data point 1514 | // Navigate to other insights? Have it describe at a local level, mid level, global level 1515 | // Describe where you are physically in the chart 1516 | const interval: number = this.config.y.interval 1517 | ? +this.config.y.interval 1518 | : 1; 1519 | let $yGhostSelect = this.$ariaG 1520 | .selectAll('.y-ghost') 1521 | .data(this._data.all.y); 1522 | 1523 | let $yGhostEnter = $yGhostSelect 1524 | .enter() 1525 | .append('g') 1526 | .attr('class', (d: any) => 'y-ghost value-' + d[0].key) 1527 | .attr('aria-hidden', 'false') 1528 | .attr('tabindex', '-1'); 1529 | 1530 | $yGhostEnter 1531 | .append('rect') 1532 | .attr( 1533 | 'height', 1534 | this._scales.y(0) - this._scales.y(this.config.y.interval) 1535 | ) 1536 | .attr('width', 60) 1537 | .attr('transform', (d: any) => { 1538 | return ( 1539 | 'translate(-60,' + 1540 | this._scales.y((d[0].key + 1) * interval) + 1541 | ')' 1542 | ); 1543 | }); 1544 | 1545 | let $yGhostMerge = $yGhostSelect 1546 | .merge($yGhostEnter) 1547 | .attr('aria-label', (d: any, i: number) => { 1548 | return ( 1549 | this.config.y.format_long(d[0].key * interval) + 1550 | ' to ' + 1551 | this.config.y.format_long((d[0].key + 1) * interval) + 1552 | ' ' + 1553 | this.config.y.label_group + 1554 | ' bin contains ' + 1555 | (this.config.z 1556 | ? d[0].layout_sum.length + 1557 | ' stacked bars. ' + 1558 | [ 1559 | ...d3 1560 | .rollup( 1561 | d[0].layout_sum, 1562 | (v) => v, 1563 | (ls) => ls.label 1564 | ) 1565 | .values(), 1566 | ] 1567 | .map( 1568 | (v: any) => 1569 | v[0].description + 1570 | ' for ' + 1571 | joinArrayWithCommasAnd( 1572 | v.map((d) => 1573 | this.config.x.format_long(d.key) 1574 | ) 1575 | ) 1576 | ) 1577 | .join('. ') 1578 | : d[0].values.length + 1579 | ' stacked data points: ' + 1580 | d[0].values 1581 | .map((dd) => 1582 | this.config.x.format_long( 1583 | dd[this.config.x.name] 1584 | ) 1585 | ) 1586 | .join(', ')) + 1587 | '. ' + 1588 | (i + 1) + 1589 | ' of ' + 1590 | this._data.all.y.length + 1591 | ' bins.' 1592 | ); 1593 | }) 1594 | .on('focus', (event, d) => { 1595 | this.clearHighlightClassed(); 1596 | this.hideTooltips(); 1597 | this.fadeBaseMarks(); 1598 | 1599 | this.unfadeBaseMarks( 1600 | d[0].layout_sum.flatMap((ls) => ls.values) 1601 | ); 1602 | 1603 | this.$markG 1604 | .selectAll('.y-group.value-' + d[0].key) 1605 | .classed('highlight', true); 1606 | 1607 | let posY = this._scales.y((d[0].key + 0.5) * interval); 1608 | 1609 | this.$tooltips.y.base.style( 1610 | 'transform', 1611 | 'translate(0px, ' + 1612 | ((1 - posY / this._scales.y.range()[0]) * 70 + 10) + 1613 | '%)' 1614 | ); 1615 | this.$tooltips.y.container 1616 | .style('visibility', 'visible') 1617 | .style('left', this.margin.l + 'px') 1618 | .style('top', posY + this.margin.t + 'px'); 1619 | this.$tooltips.y.label.text( 1620 | this.config.y.format_short(d[0].key * interval) + 1621 | ' to ' + 1622 | this.config.y.format_short((d[0].key + 1) * interval) + 1623 | ' ' + 1624 | this.config.y.label_group 1625 | ); 1626 | let rollup = [ 1627 | ...d3 1628 | .rollup( 1629 | d[0].layout_sum, 1630 | (v) => v, 1631 | (ls) => ls.label 1632 | ) 1633 | .values(), 1634 | ].reverse(); 1635 | let $seriesDivSelect = this.$tooltips.y.series 1636 | .selectAll('div') 1637 | .data(rollup); 1638 | let $seriesDivEnter = $seriesDivSelect.enter().append('div'); 1639 | $seriesDivEnter 1640 | .append('span') 1641 | .attr('class', 'tooltip-series-legend-container'); 1642 | $seriesDivEnter 1643 | .append('span') 1644 | .attr('class', 'tooltip-series-label'); 1645 | $seriesDivEnter 1646 | .append('span') 1647 | .attr('class', 'tooltip-series-value'); 1648 | $seriesDivSelect.exit().remove(); 1649 | let $seriesDivMerge = $seriesDivSelect.merge($seriesDivEnter); 1650 | 1651 | let $legendDivSelect = $seriesDivMerge 1652 | .select('.tooltip-series-legend-container') 1653 | .selectAll('.tooltip-series-legend') 1654 | .data((v: any) => 1655 | this.config.z ? v[0].series : ['Series1'] 1656 | ); 1657 | let $legendDivEnter = $legendDivSelect 1658 | .enter() 1659 | .append('span') 1660 | .attr('class', 'tooltip-series-legend'); 1661 | let $legendDivMerge = $legendDivSelect 1662 | .merge($legendDivEnter) 1663 | .style('background', (s: string) => this._scales.fill(s)); 1664 | $legendDivSelect.exit().remove(); 1665 | $seriesDivMerge 1666 | .select('.tooltip-series-label') 1667 | .text((v: any) => 1668 | this.config.z ? v[0].label : 'Series 1' 1669 | ); 1670 | $seriesDivMerge 1671 | .select('.tooltip-series-value') 1672 | .text((v: any) => 1673 | v 1674 | .map((dd: any) => 1675 | this.config.x.format_abbrev(dd.key) 1676 | ) 1677 | .join(', ') 1678 | ); 1679 | }) 1680 | // .on('mouseover', function (event, d) { 1681 | // this.focus(); 1682 | // }); 1683 | 1684 | let $yGhostCombineSelect = $yGhostMerge 1685 | .selectAll('.y-ghost-combine') 1686 | .data((d: any) => d[0].layout_sum); 1687 | 1688 | let $yGhostCombineEnter = $yGhostCombineSelect 1689 | .enter() 1690 | .append('g') 1691 | .attr('aria-hidden', 'false') 1692 | .attr('tabindex', '-1') 1693 | .attr( 1694 | 'class', 1695 | (d: any) => 1696 | 'y-ghost-combine x-value-' + 1697 | formatStringClass(d.key) + 1698 | ' z-value-' + 1699 | formatStringClass(this.config.z ? d.label : 'Series1') 1700 | ); 1701 | 1702 | let $yGhostCombineMerge = $yGhostCombineSelect 1703 | .merge($yGhostCombineEnter) 1704 | .attr('aria-label', (d: any, i: number, array: any[]) => { 1705 | let lastValue = d.values[d.values.length - 1]; 1706 | return ( 1707 | this.config.y.format_long(lastValue['layout'][1]) + 1708 | ' ' + 1709 | this.config.y.label_group + 1710 | '. ' + 1711 | (this.config.z ? d.description + ' for ' : '') + 1712 | this.config.x.format_long(d.key) + 1713 | '. ' + 1714 | (i + 1) + 1715 | ' of ' + 1716 | array.length + 1717 | ' stacks.' 1718 | ); 1719 | }) 1720 | .on('focus', (event, d, i) => { 1721 | this.clearHighlightClassed(); 1722 | this.hideTooltips(); 1723 | this.fadeBaseMarks(); 1724 | this.unfadeBaseMarks(d.values); 1725 | 1726 | this.$markG 1727 | .selectAll('.y-group.value-' + d.key) 1728 | .classed('highlight', true); 1729 | 1730 | let lastValue = d.values[d.values.length - 1]; 1731 | 1732 | let pos = [ 1733 | this._scales.x(d.key) + this._scales.x.bandwidth() / 2, 1734 | this._scales.y(lastValue['layout'][1]), 1735 | ]; 1736 | 1737 | this.$tooltips.x.base.style( 1738 | 'transform', 1739 | 'translate(' + this._scales.tooltip(d.key) + '%, 0px)' 1740 | ); 1741 | this.$tooltips.x.container 1742 | .style('visibility', 'visible') 1743 | .style('left', pos[0] + this.margin.l + 'px') 1744 | .style('top', pos[1] + this.margin.t + 'px'); 1745 | this.$tooltips.x.label.text(this.config.x.format_long(d.key)); 1746 | this.$tooltips.x.value.text( 1747 | this.config.x.label_tooltip + 1748 | this.config.y.format_long(lastValue['layout'][1]) 1749 | ); 1750 | }); 1751 | 1752 | let $xGhostSelect = this.$ariaG 1753 | .selectAll('.x-ghost') 1754 | .data(this._data.all.x); 1755 | 1756 | let $xGhostEnter = $xGhostSelect 1757 | .enter() 1758 | .append('g') 1759 | .attr( 1760 | 'class', 1761 | (d: any) => 'x-ghost value-' + formatStringClass(d[0].key) 1762 | ) 1763 | .attr('aria-hidden', 'false') 1764 | .attr('tabindex', '-1'); 1765 | 1766 | $xGhostEnter 1767 | .append('rect') 1768 | .attr('height', 60) 1769 | .attr('width', this._scales.x.bandwidth()) 1770 | .attr( 1771 | 'transform', 1772 | (d: any) => 1773 | 'translate(' + 1774 | [ 1775 | this._scales.x(d[0].values[0][this.config.x.name]), 1776 | this._scales.y.range()[0], 1777 | ] + 1778 | ')' 1779 | ); 1780 | 1781 | // TODO would get rid of this if just one series 1782 | let $xGhostMerge = $xGhostSelect 1783 | .merge($xGhostEnter) 1784 | .attr('aria-label', (d: any, i: number, array: any[]) => { 1785 | return ( 1786 | this.config.x.format_short( 1787 | d[0].values[0][this.config.x.name] 1788 | ) + 1789 | ' ' + 1790 | this.config.x.label_group + 1791 | (this.config.z 1792 | ? ' are ' + this.config.y.format_long(d[0]['sum']) 1793 | : ' are ' + this.config.y.format_long(d[0]['sum'])) + 1794 | '. ' + 1795 | (i + 1) + 1796 | ' of ' + 1797 | array.length + 1798 | ' ' + 1799 | this.config.x.label_axis + 1800 | 's.' 1801 | ); 1802 | }) 1803 | .on('focus', (event, d) => { 1804 | this.clearHighlightClassed(); 1805 | this.hideTooltips(); 1806 | this.fadeBaseMarks(); 1807 | 1808 | this.unfadeBaseMarks(d[0].values); 1809 | 1810 | this.$markG 1811 | .selectAll( 1812 | '.x-group-container.value-' + 1813 | formatStringClass(d[0].key) 1814 | ) 1815 | .classed('highlight', true); 1816 | 1817 | let pos = [ 1818 | this._scales.x(d[0].key) + this._scales.x.bandwidth() / 2, 1819 | this._scales.y(d[0]['sum']), 1820 | ]; 1821 | 1822 | this.$tooltips.x.base.style( 1823 | 'transform', 1824 | 'translate(' + this._scales.tooltip(d[0].key) + '%, 0px)' 1825 | ); 1826 | this.$tooltips.x.container 1827 | .style('visibility', 'visible') 1828 | .style('left', pos[0] + this.margin.l + 'px') 1829 | .style('top', pos[1] + this.margin.t + 'px'); 1830 | this.$tooltips.x.label.text( 1831 | this.config.x.format_long(d[0].key) 1832 | ); 1833 | this.$tooltips.x.value.text( 1834 | this.config.x.label_tooltip + 1835 | this.config.y.format_long(d[0]['sum']) 1836 | ); 1837 | }) 1838 | // .on('mouseover', function (event, d) { 1839 | // this.focus(); 1840 | // }); 1841 | 1842 | let $barSelect = this.$ariaG 1843 | .selectAll('.bar') 1844 | .data(this._data.all.raw.flat()); 1845 | 1846 | let $barEnter = $barSelect 1847 | .enter() 1848 | .append('rect') 1849 | .attr('aria-hidden', 'false') 1850 | .attr('tabindex', '-1'); 1851 | 1852 | // Change first and last day of month aria-label 1853 | let $barMerge = $barSelect 1854 | .merge($barEnter) 1855 | .attr( 1856 | 'class', 1857 | (d: any) => 1858 | 'bar value-' + 1859 | formatStringClass(d[this.config.x.name]) + 1860 | ' series-' + 1861 | formatStringClass( 1862 | this.config.z ? d[this.config.z.name] : 'Series1' 1863 | ) 1864 | ) 1865 | .attr('width', this._scales.x.bandwidth()) 1866 | .attr('height', (d: any) => this._scales.y.range()[0]) 1867 | .attr('y', 0) 1868 | .attr('x', (d: any) => this._scales.x(d[this.config.x.name])) 1869 | .attr('aria-label', (d) => { 1870 | // TODO change data description based on what the user has heard 1871 | return ( 1872 | this.config.y.format_long(d[this._config.y.name]) + 1873 | ' ' + 1874 | this.config.y.label_group + 1875 | ', ' + 1876 | this.config.x.format_short(d[this._config.x.name]) + 1877 | (this.config.z 1878 | ? ', ' + 1879 | this.config.z.map[d[this.config.z.name]] + 1880 | ' series.' 1881 | : '.') 1882 | ); 1883 | }) 1884 | .on('focus', (event, d) => { 1885 | this.hideTooltips(); 1886 | this.highlightPoint(d); 1887 | }) 1888 | // .on('mouseover', function (event, d) { 1889 | // this.focus(); 1890 | // }); 1891 | 1892 | if (this.config.z) { 1893 | let $filterSelect = this.$ariaG 1894 | .selectAll('.filter-ghost') 1895 | .data(this._data.filters); 1896 | 1897 | let $filterEnter = $filterSelect 1898 | .enter() 1899 | .append('g') 1900 | .attr( 1901 | 'class', 1902 | (d: any) => 1903 | 'filter-ghost value-' + formatStringClass(d.value) 1904 | ) 1905 | .attr('aria-hidden', 'false') 1906 | .attr('role', 'checkbox') 1907 | .attr('tabindex', '-1'); 1908 | 1909 | $filterEnter 1910 | .append('rect') 1911 | .attr('class', 'bg-ghost-container') 1912 | .attr('x', this._scales.x.range()[1] + 28) 1913 | .attr('y', (f, i) => i * 26 - 36) 1914 | .attr('height', 28) 1915 | .attr('width', 140) 1916 | .style('pointer-events', 'all'); 1917 | 1918 | // Change first and last day of month aria-label 1919 | let $filterMerge = $filterSelect 1920 | .merge($filterEnter) 1921 | .attr('aria-label', (f) => this.config.z.map[f.value]) 1922 | .attr('aria-checked', (f) => !f.filtered) 1923 | .on('click', (event, f) => { 1924 | this.hideTooltips(); 1925 | console.log(f); 1926 | this.filterData(); 1927 | }); 1928 | } 1929 | } 1930 | 1931 | ghostAnnotionLayer() { 1932 | const allAnnotations = [].concat.apply( 1933 | [], 1934 | this._data.annotations.map((at) => at.values) 1935 | ); 1936 | let $annotContainer = this.$ariaG.select('.annotation-ghost-container'); 1937 | 1938 | let $annotGroupGhostSelect = $annotContainer 1939 | .selectAll('.annotation-group-ghost') 1940 | .data(this._data.annotations); 1941 | 1942 | let $annotGroupGhostEnter = $annotGroupGhostSelect 1943 | .enter() 1944 | .append('g') 1945 | .attr( 1946 | 'class', 1947 | (d) => 'annotation-group-ghost key-' + formatStringClass(d.key) 1948 | ) 1949 | .attr('aria-hidden', 'false') 1950 | .attr('tabindex', '-1'); 1951 | 1952 | let $annotGroupGhostMerge = $annotGroupGhostSelect 1953 | .merge($annotGroupGhostEnter) 1954 | .attr('aria-label', (at) => { 1955 | return ( 1956 | at.key + ' data insights. ' + at.values.length + ' total.' 1957 | ); 1958 | }) 1959 | .on('focus', (event, d) => { 1960 | this.clearHighlightClassed(); 1961 | this.hideTooltips(); 1962 | }); 1963 | 1964 | let $annotGhostSelect = $annotGroupGhostMerge 1965 | .selectAll('.annotation-ghost') 1966 | .data((at) => at.values); 1967 | 1968 | let $annotGhostEnter = $annotGhostSelect 1969 | .enter() 1970 | .append('g') 1971 | .attr( 1972 | 'class', 1973 | (d) => 1974 | 'annotation-ghost annotation-' + d.index + ' key-' + d.type 1975 | ) 1976 | .attr('aria-hidden', 'false') 1977 | .attr('tabindex', '-1') 1978 | .attr( 1979 | 'transform', 1980 | (d) => 1981 | 'translate(' + 1982 | [ 1983 | this._scales.x.range()[1] - 1984 | (allAnnotations.length - d.index - 1) * 30, 1985 | -20, 1986 | ] + 1987 | ')' 1988 | ); 1989 | 1990 | $annotGhostEnter.append('circle').attr('r', 10); 1991 | 1992 | $annotGhostEnter 1993 | .append('text') 1994 | .style('text-anchor', 'middle') 1995 | .attr('dy', '0.3em') 1996 | .text((d) => d.index + 1); 1997 | 1998 | $annotGhostEnter 1999 | .append('rect') 2000 | .attr('width', 30) 2001 | .attr('height', 30) 2002 | .attr('x', -15) 2003 | .attr('y', -15); 2004 | 2005 | let $annotGhostMerge = $annotGhostSelect 2006 | .merge($annotGhostEnter) 2007 | .attr('aria-label', (d, i, array) => { 2008 | return ( 2009 | d.note.title.join(' ') + 2010 | '. ' + 2011 | d.note.label.join(' ') + // Already a period in label 2012 | (i + 1) + 2013 | ' of ' + 2014 | array.length + 2015 | ' ' + 2016 | d.type + 2017 | '.' 2018 | ); 2019 | }) 2020 | .on('focus', (event, d) => { 2021 | this.clearHighlightClassed(); 2022 | this.hideTooltips(); 2023 | this.fadeBaseMarks(); 2024 | 2025 | this.unfadeBaseMarks(d.target.data.flatMap((d) => d.values)); 2026 | 2027 | this.$ariaG 2028 | .selectAll('.annotation-ghost.annotation-' + d.index) 2029 | .classed('highlight', true); 2030 | this.$markG 2031 | .selectAll('.annotation-group.annotation-' + d.index) 2032 | .classed('highlight', true); 2033 | this.$annotG 2034 | .selectAll('.annotation.annotation-' + d.index) 2035 | .classed('highlight', true); 2036 | }) 2037 | // .on('mouseover', function (event, d) { 2038 | // this.focus(); 2039 | // }); 2040 | } 2041 | 2042 | drawContainers() { 2043 | this.$ariaG 2044 | .select('.x-ghost-container') 2045 | .attr( 2046 | 'aria-label', 2047 | 'X axis. This component displays stacked bars of ' + 2048 | this.config.x.label_group + 2049 | ' per ' + 2050 | this.config.x.label_axis + 2051 | '. There are ' + 2052 | this._data.all.x.length + 2053 | ' total ' + 2054 | this.config.x.label_axis + 2055 | ' categories, each with ' + 2056 | this._data.series.length + 2057 | ' data series.' 2058 | ); 2059 | const interval: number = this.config.y.interval 2060 | ? +this.config.y.interval 2061 | : 1; 2062 | this.$ariaG 2063 | .select('.y-ghost-container') 2064 | .attr( 2065 | 'aria-label', 2066 | 'Y axis displaying ' + 2067 | this.config.y.label_axis + 2068 | ', from ' + 2069 | this.config.y.format_long( 2070 | Math.floor(this._scales.y.domain()[0] / interval) * 2071 | interval 2072 | ) + 2073 | ' to ' + 2074 | this.config.y.format_long( 2075 | Math.ceil(this._scales.y.domain()[1] / interval) * 2076 | interval 2077 | ) + 2078 | '. This component displays stacked bars binned by increments of ' + 2079 | this.config.y.format_long(interval) + 2080 | ' ' + 2081 | this.config.y.label_group + 2082 | '. There are ' + 2083 | this._data.all.y.length + 2084 | ' total bins.' 2085 | ); 2086 | this.$ariaG 2087 | .select('.bar-ghost-container') 2088 | .attr( 2089 | 'aria-label', 2090 | 'Data points displaying ' + 2091 | this.config.y.label_axis + 2092 | ' per ' + 2093 | this.config.x.label_axis + 2094 | '. There are ' + 2095 | this._data.series.length + 2096 | ' ' + 2097 | this.config.z.label_axis + 2098 | ' series.' 2099 | ); 2100 | this.$ariaG 2101 | .select('.compare-ghost-container') 2102 | .attr( 2103 | 'aria-label', 2104 | 'Compare data between series with spatial audio. There are ' + 2105 | this._data.series.length + 2106 | ' ' + 2107 | this.config.z.label_axis + 2108 | ' series.' 2109 | ); 2110 | } 2111 | 2112 | drawChart() { 2113 | this.drawBaseMarks(); 2114 | this.drawAxisGroupLayer(); 2115 | this.drawAnnotationLayer(); 2116 | this.drawAxesLegends(); 2117 | this.drawContainers(); 2118 | 2119 | this.ghostSeriesLayer(); 2120 | this.ghostAnnotionLayer(); 2121 | } 2122 | 2123 | initChart() { 2124 | let $xGhostContainer = this.$ariaG 2125 | .append('g') 2126 | .attr('class', 'x-ghost-container') 2127 | .attr('aria-hidden', 'false') 2128 | .attr('tabindex', '-1') 2129 | .on('focus', (d) => { 2130 | this.hideTooltips(); 2131 | this.clearHighlightClassed(); 2132 | console.log('X axis has focus!!'); 2133 | }); 2134 | $xGhostContainer 2135 | .append('rect') 2136 | .attr('class', 'bg-ghost-container') 2137 | .attr('transform', `translate(${[0, this._scales.y.range()[0]]})`) 2138 | .attr('width', this._scales.x.range()[1]) 2139 | .attr('height', 60); 2140 | 2141 | let $yGhostContainer = this.$ariaG 2142 | .append('g') 2143 | .attr('class', 'y-ghost-container') 2144 | .attr('aria-hidden', 'false') 2145 | .attr('tabindex', '-1') 2146 | .on('focus', (d) => { 2147 | this.hideTooltips(); 2148 | this.clearHighlightClassed(); 2149 | console.log('Y axis has focus!!'); 2150 | }); 2151 | $yGhostContainer 2152 | .append('rect') 2153 | .attr('class', 'bg-ghost-container') 2154 | .attr('transform', `translate(${[-70, 0]})`) 2155 | .attr('height', this._scales.y.range()[0]) 2156 | .attr('width', 70); 2157 | 2158 | let $barGhostContainer = this.$ariaG 2159 | .append('g') 2160 | .attr('class', 'bar-ghost-container') 2161 | .attr('aria-hidden', 'false') 2162 | .attr('tabindex', '-1') 2163 | .on('focus', (d) => { 2164 | console.log('Bar has focus!!'); 2165 | this.hideTooltips(); 2166 | this.clearHighlightClassed(); 2167 | }); 2168 | $barGhostContainer 2169 | .append('rect') 2170 | .attr('class', 'bg-ghost-container') 2171 | .attr('width', this._scales.x.range()[1]) 2172 | .attr('height', this._scales.y.range()[0]); 2173 | 2174 | let $compareGhostContainer = this.$ariaG 2175 | .append('g') 2176 | .attr('class', 'compare-ghost-container') 2177 | .attr('aria-hidden', 'false') 2178 | .attr('tabindex', '-1') 2179 | .on('focus', (d) => { 2180 | console.log('Compare has focus!!'); 2181 | this.hideTooltips(); 2182 | this.clearHighlightClassed(); 2183 | }); 2184 | $compareGhostContainer 2185 | .append('rect') 2186 | .attr('class', 'bg-ghost-container') 2187 | .attr('width', this._scales.x.range()[1]) 2188 | .attr('height', this._scales.y.range()[0]); 2189 | 2190 | const allAnnotations = [].concat.apply( 2191 | [], 2192 | this._data.annotations.map((at) => at.values) 2193 | ); 2194 | let $annotContainer = this.$ariaG 2195 | .append('g') 2196 | .attr('class', 'annotation-ghost-container') 2197 | .attr( 2198 | 'aria-label', 2199 | 'Data Insights. There are ' + 2200 | allAnnotations.length + 2201 | ' total data insights: ' + 2202 | this._data.annotations 2203 | .map((at) => at.values.length + ' ' + at.key) 2204 | .join(', ') + 2205 | '.' 2206 | ) 2207 | .attr('aria-hidden', 'false') 2208 | .attr('tabindex', '-1') 2209 | .on('focus', function (event, d) { 2210 | console.log('Annotation has focus!!'); 2211 | }); 2212 | 2213 | if (this.config.z) { 2214 | let $filterGhostContainer = this.$ariaG 2215 | .append('g') 2216 | .attr('class', 'filter-ghost-container') 2217 | .attr( 2218 | 'aria-label', 2219 | 'Filter series from the chart. There are ' + 2220 | this._data.series.length + 2221 | ' series. ' 2222 | ) 2223 | .attr('aria-hidden', 'false') 2224 | .attr('tabindex', '-1') 2225 | .on('focus', (d) => { 2226 | console.log('Filter has focus!!'); 2227 | this.hideTooltips(); 2228 | this.clearHighlightClassed(); 2229 | }); 2230 | $filterGhostContainer 2231 | .append('rect') 2232 | .attr('class', 'bg-ghost-container') 2233 | .attr('x', this._scales.x.range()[1] + 32) 2234 | .attr('y', -48) 2235 | .attr('width', 140) 2236 | .attr('height', this._data.series.length * 26); 2237 | } 2238 | } 2239 | 2240 | initSonifier() { 2241 | this._sonifier.updateDomain( 2242 | d3.extent(this._data.filtered, (d) => d[this.config.y.name]) 2243 | ); 2244 | } 2245 | 2246 | onResize() { 2247 | const rect = this.$chartWrapper.node().getBoundingClientRect(); 2248 | this._dimensions.height = rect.height; 2249 | this._dimensions.width = rect.width; 2250 | } 2251 | 2252 | hideTooltips() { 2253 | this.$tooltips.y.container.style('visibility', 'hidden'); 2254 | this.$tooltips.x.container.style('visibility', 'hidden'); 2255 | this.$tooltips.raw.container.style('visibility', 'hidden'); 2256 | } 2257 | 2258 | fadeBaseMarks() { 2259 | this.$markG.selectAll('.base-mark').classed('fade', true); 2260 | } 2261 | 2262 | unfadeBaseMarks(values: any[]) { 2263 | let selector = values 2264 | .map( 2265 | (d) => 2266 | `.base-mark.x-value-${d[this.config.x.name]}.z-value-${ 2267 | this.config.z 2268 | ? formatStringClass(d[this.config.z.name]) 2269 | : 'Series1' 2270 | }` 2271 | ) 2272 | .join(','); 2273 | this.$markG.selectAll(selector).classed('fade', false); 2274 | } 2275 | 2276 | filterData() { 2277 | if (this.config.z) { 2278 | const series = this._data.filters 2279 | .filter((f) => !f.filtered) 2280 | .map((f) => f.value); 2281 | this._data.filtered = this._data.raw.filter( 2282 | (d) => series.indexOf(d[this.config.z.name]) > -1 2283 | ); 2284 | 2285 | this.updateData(); 2286 | console.log(this._data); 2287 | 2288 | // TODO update annotations based on new data 2289 | 2290 | // this.drawChart(); 2291 | this.drawBaseMarks(); 2292 | this.drawAxisGroupLayer(); 2293 | // this.drawAnnotationLayer(); 2294 | this.drawAxesLegends(); 2295 | this.drawContainers(); 2296 | 2297 | this.ghostSeriesLayer(); 2298 | // this.ghostAnnotionLayer(); 2299 | } 2300 | } 2301 | 2302 | updateData() { 2303 | const zDomain: any[] = this.config.z 2304 | ? [ 2305 | ...new Set( 2306 | this._data.filtered.map((d: any) => d[this.config.z.name]) 2307 | ), 2308 | ] 2309 | : ['Series1']; 2310 | 2311 | const layoutMap = new Map(); 2312 | 2313 | this._data.filtered 2314 | // Need to sort by z domain before applying layout 2315 | .sort((a: any, b: any) => { 2316 | if (this.config.z) { 2317 | return ( 2318 | zDomain.indexOf(a[this.config.z.name]) - 2319 | zDomain.indexOf(b[this.config.z.name]) 2320 | ); 2321 | } else { 2322 | return 0; 2323 | } 2324 | }) 2325 | .forEach((d: any) => { 2326 | // TODO bring in the z config layout 2327 | // TODO need to come up with a way to sort based on x or z config 2328 | if (!layoutMap.has(d[this.config.x.name])) { 2329 | layoutMap.set(d[this.config.x.name], [0, 0]); 2330 | } 2331 | let layout = layoutMap.get(d[this.config.x.name]); 2332 | if (layout) { 2333 | layout[0] = layout[1]; 2334 | layout[1] += d[this.config.y.name]; 2335 | d['layout'] = [...layout]; 2336 | layoutMap.set(d[this.config.x.name], layout); 2337 | } 2338 | }); 2339 | 2340 | this._data.series = [ 2341 | ...d3 2342 | .rollup( 2343 | this._data.filtered, 2344 | (v) => { 2345 | let key = this.config.z 2346 | ? v[0][this.config.z.name] 2347 | : 'Series1'; 2348 | return { key, raw: v }; 2349 | }, 2350 | (d) => (this.config.z ? d[this.config.z.name] : 'Series1') 2351 | ) 2352 | .values(), 2353 | ]; 2354 | 2355 | this._data.all = { 2356 | x: computeSubstrateData( 2357 | this.config.x, 2358 | this.config.y, 2359 | this.config.z, 2360 | zDomain, 2361 | this._data.filtered 2362 | ).map((s) => [s]), 2363 | y: computeSubstrateData( 2364 | this.config.y, 2365 | this.config.x, 2366 | this.config.z, 2367 | zDomain, 2368 | this._data.filtered 2369 | ).map((s) => [s]), 2370 | raw: computeAll(this._data.series, 'raw', this.config.x), 2371 | }; 2372 | 2373 | const xExtent = d3.map( 2374 | this._data.filtered, 2375 | (d) => d[this.config.x.name] 2376 | ); 2377 | const yMax = d3.max(this._data.all.raw.flat(), (d) => d['layout'][1]); 2378 | 2379 | if (this.config.y.interval) { 2380 | this._axes.y.tickValues( 2381 | d3.range( 2382 | 0, 2383 | (Math.ceil(yMax / +this.config.y.interval) + 1) * 2384 | +this.config.y.interval, 2385 | +this.config.y.interval 2386 | ) 2387 | ); 2388 | } else { 2389 | this._axes.y.ticks(6); 2390 | } 2391 | this._axes.x.tickValues(xExtent); 2392 | 2393 | this._scales.x.domain(xExtent); 2394 | this._scales.y.domain([0, yMax]); 2395 | this._scales.tooltip.domain(xExtent); 2396 | } 2397 | 2398 | async loadData(dataConfig, dataLoadedCallback) { 2399 | this._data.raw = await loadDataCsv( 2400 | dataConfig.raw.url, 2401 | dataConfig.raw.fields 2402 | ); 2403 | 2404 | this._data.filtered = [...this._data.raw]; 2405 | 2406 | console.log('Load Annotations'); 2407 | this._data.annotations = await loadAnnotations( 2408 | dataConfig.annotations.url 2409 | ); 2410 | 2411 | this.updateData(); 2412 | 2413 | if (this._data.annotations[0] && this._data.annotations[0].values[0]) { 2414 | this.$chartWrapper.attr( 2415 | 'aria-label', 2416 | 'Interactive chart. Press enter key to start. Summary Insight, ' + 2417 | this._data.annotations[0].values[0].note.label.join(' ') 2418 | ); 2419 | } 2420 | 2421 | const zDomain: any[] = this.config.z 2422 | ? this._data.series.map((s) => s.key) 2423 | : ['Series1']; 2424 | 2425 | this._scales.fill.domain(zDomain); 2426 | 2427 | if (this.config.z) { 2428 | this._data.filters = zDomain.map((z, i) => ({ 2429 | value: z, 2430 | filtered: false, 2431 | index: i, 2432 | })); 2433 | } 2434 | 2435 | console.log(this._data); 2436 | 2437 | dataLoadedCallback(); 2438 | } 2439 | } 2440 | 2441 | const formatStringClass = (value: string) => { 2442 | return value.replace(/[^a-z0-9]/g, function (s) { 2443 | var c = s.charCodeAt(0); 2444 | if (c == 32) return '-'; 2445 | if (c >= 65 && c <= 90) return '_' + s.toLowerCase(); 2446 | return '__' + ('000' + c.toString(16)).slice(-4); 2447 | }); 2448 | }; 2449 | 2450 | export { BarChart }; 2451 | -------------------------------------------------------------------------------- /src/core.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | interface ChartConfig { 5 | description: { title: string; caption: string }; 6 | x: AxisConfig; 7 | y: AxisConfig; 8 | z?: any; 9 | stroke?: any; 10 | } 11 | 12 | interface AxisConfig { 13 | name: string; 14 | label_group: string; 15 | label_axis: string; 16 | label_tooltip: string; 17 | format_short: (v: any) => string; 18 | format_long: (v: any) => string; 19 | format_abbrev: (v: any) => string; 20 | format_group: (v: any) => string; 21 | encode: 'x' | 'y' | 'z'; 22 | type: 'date' | 'number' | 'string'; 23 | aggregate: Aggregate[]; 24 | period?: TimePeriod; 25 | interval?: TimePeriod | number; 26 | layout?: 'stack' | 'group'; 27 | } 28 | 29 | interface DimensionConfig { 30 | width: number; 31 | height: number; 32 | margin: MarginConfig; 33 | } 34 | 35 | interface MarginConfig { 36 | l: number; 37 | t: number; 38 | r: number; 39 | b: number; 40 | } 41 | 42 | interface ColumnConfig { 43 | name: string; 44 | type: 'date' | 'number' | 'string'; 45 | format?: string; 46 | } 47 | 48 | interface DataConfig { 49 | annotations: { url: string }; 50 | raw: { url: string; columns: ColumnConfig[] }; 51 | } 52 | 53 | enum Aggregate { 54 | mean = 'mean', 55 | max = 'max', 56 | min = 'min', 57 | count = 'count', 58 | consecutive_days = 'consecutive_days', 59 | layout_sum = 'layout_sum' 60 | } 61 | 62 | enum TimePeriod { 63 | Second = 'Second', 64 | Minute = 'Minute', 65 | Hour = 'Hour', 66 | Day = 'Day', 67 | Week = 'Week', 68 | Month = 'Month', 69 | Year = 'Year', 70 | } 71 | 72 | export { ChartConfig, AxisConfig, DimensionConfig, MarginConfig, DataConfig, Aggregate, TimePeriod } -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import { LineChart } from './line-chart'; 5 | import { BarChart } from './bar-chart'; 6 | 7 | export { LineChart, BarChart }; 8 | -------------------------------------------------------------------------------- /src/navigation-controller.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import { ChartConfig } from './core'; 5 | import { Sonifier } from './sonify'; 6 | 7 | import * as d3 from 'd3'; 8 | 9 | class NavigationController { 10 | private _valueAtLevels: any[]; 11 | // private _selectedAtLevels: number[]; 12 | private _tree: Map; 13 | private _sonifier: Sonifier; 14 | private _focusMode: boolean; 15 | private _timeouts: number[]; 16 | private _highlightPoint: (d: any) => void; 17 | 18 | private _currEl: NavigationElement; 19 | 20 | constructor( 21 | sonifier: Sonifier, 22 | tree: Map, 23 | root: NavigationElement, 24 | highlightPoint: (d: any) => void 25 | ) { 26 | this._valueAtLevels = ['-', undefined, undefined, undefined, undefined]; 27 | 28 | this._tree = tree; 29 | 30 | this._sonifier = sonifier; 31 | this._highlightPoint = highlightPoint; 32 | 33 | this._timeouts = []; 34 | this._currEl = root; 35 | } 36 | 37 | public toggleSonifier( 38 | data: any, 39 | config: ChartConfig, 40 | $ariaG: d3.selection, 41 | $chartWrapper: d3.selection 42 | ) { 43 | if (this._sonifier.isPlaying) { 44 | this._timeouts.forEach((t) => { 45 | window.clearTimeout(t); 46 | }); 47 | this._timeouts = []; 48 | this._sonifier.togglePlay(); 49 | 50 | this.action('none', 1, data, config, $ariaG, $chartWrapper, false); 51 | } else { 52 | if ( 53 | this._currEl.type.startsWith('series') || 54 | this._currEl.type === 'data-sonify-values' 55 | ) { 56 | this._sonifier.togglePlay(); 57 | } else { 58 | this._sonifier.effectPlay('drop'); 59 | } 60 | } 61 | 62 | console.log('Toggle Sonifier!!!'); 63 | } 64 | 65 | public updateSonifier( 66 | data: any, 67 | config: ChartConfig, 68 | $ariaG: d3.selection, 69 | $chartWrapper: d3.selection, 70 | index: number, 71 | series: number 72 | ) { 73 | if ( 74 | this._currEl.type === 'series_reverse' || 75 | this._currEl.type === 'series_normal' || 76 | this._currEl.type === 'data-sonify-values' 77 | ) { 78 | this._currEl.selected = index; 79 | let dataLength = 80 | this._currEl.type === 'series_reverse' 81 | ? this._currEl.values[0].values.length 82 | : this._currEl.values.length; 83 | if (index + 1 >= dataLength) { 84 | this.action( 85 | 'none', 86 | 1, 87 | data, 88 | config, 89 | $ariaG, 90 | $chartWrapper, 91 | false 92 | ); 93 | } 94 | } else if (this._currEl.type === 'data-sonify-values') { 95 | 96 | } 97 | } 98 | 99 | public getSelectedIndex() { 100 | return this._currEl.selected; 101 | } 102 | 103 | public getSelectedLength() { 104 | switch (this._currEl.type) { 105 | case 'series_reverse': 106 | return this._currEl.values[this._currEl.series].values.length; 107 | // case 'control': 108 | // return this._tree.get(this._tree.get(this._currEl.id).parent).children.length; 109 | default: 110 | return this._currEl.values.length; 111 | } 112 | } 113 | 114 | public action( 115 | direction: string, 116 | distance: number, 117 | data: any, 118 | config: ChartConfig, 119 | $ariaG: d3.selection, 120 | $chartWrapper: d3.selection, 121 | isSonify: boolean 122 | ) { 123 | console.log(direction, distance, isSonify); 124 | switch (direction) { 125 | case 'up_down': 126 | if (distance > 0) { 127 | // Go down a level if possible 128 | let children = this._tree.get(this._currEl.id).children; 129 | 130 | if (children.length > 0) { 131 | let firstChild = this._tree.get(children[0]).element; 132 | if (firstChild.type === 'control') { 133 | this._currEl = this._tree.get( 134 | firstChild.values[firstChild.selected] 135 | ).element; 136 | } else { 137 | this._currEl = firstChild; 138 | this._currEl.values = this._currEl.getData( 139 | data, 140 | this._valueAtLevels 141 | ); 142 | } 143 | } else { 144 | this._sonifier.effectPlay('bonk'); 145 | } 146 | } else { 147 | // Esc gets out of focus mode 148 | if (!this._focusMode) { 149 | // Go up a level if possible 150 | let parentId = this._tree.get(this._currEl.id).parent; 151 | 152 | if (parentId > 0) { 153 | this._currEl = this._tree.get(parentId).element; 154 | } else { 155 | this._sonifier.effectPlay('bonk'); 156 | } 157 | } else { 158 | this._focusMode = false; 159 | } 160 | } 161 | break; 162 | case 'left_right': 163 | const newSelected = this._currEl.selected + distance; 164 | if (this._currEl.type.startsWith('series')) { 165 | const length = 166 | this._currEl.type === 'series_reverse' 167 | ? this._currEl.values[this._currEl.series].values 168 | .length 169 | : this._currEl.values.length; 170 | 171 | // Jump if supported and outside of bounds 172 | if ( 173 | this._currEl.jump !== 'disabled' && 174 | (newSelected < 0 || newSelected >= length) 175 | ) { 176 | let parent = this._tree.get( 177 | this._tree.get(this._currEl.id).parent 178 | )?.element; 179 | if ( 180 | parent && 181 | parent.selected + distance >= 0 && 182 | parent.selected + distance < parent.values?.length 183 | ) { 184 | parent.selected += newSelected < 0 ? -1 : 1; 185 | this._valueAtLevels[parent.level] = 186 | parent.values[parent.selected]; 187 | this._currEl.values = this._currEl.getData( 188 | data, 189 | this._valueAtLevels 190 | ); 191 | const newLength = 192 | this._currEl.type === 'series_reverse' 193 | ? this._currEl.values[this._currEl.series] 194 | .values.length 195 | : this._currEl.values.length; 196 | this._currEl.selected = 197 | newSelected < 0 ? newLength - 1 : 0; 198 | const newSeries = 199 | this._currEl.type === 'series_reverse' 200 | ? this._currEl.values.length 201 | : this._currEl.values[this._currEl.selected] 202 | .length; 203 | this._currEl.series = 204 | this._currEl.series >= newSeries 205 | ? newSeries - 1 206 | : this._currEl.series; 207 | } 208 | } else { 209 | this._currEl.selected = Math.min( 210 | Math.max(newSelected, 0), 211 | length - 1 212 | ); 213 | if (newSelected < 0 || newSelected >= length) { 214 | this._sonifier.effectPlay('bonk'); 215 | } 216 | } 217 | } else { 218 | this._currEl.selected = Math.min( 219 | Math.max(newSelected, 0), 220 | this._currEl.values.length - 1 221 | ); 222 | if ( 223 | newSelected < 0 || 224 | newSelected >= this._currEl.values.length 225 | ) { 226 | this._sonifier.effectPlay('bonk'); 227 | } 228 | if (this._currEl.jump !== 'disabled') { 229 | } else { 230 | } 231 | } 232 | if (this._currEl.type === 'control') { 233 | let parentId = this._tree.get(this._currEl.id).parent; 234 | let children = this._tree 235 | .get(parentId) 236 | .children.map((c) => this._tree.get(c).element); 237 | children.forEach( 238 | (c) => (c.selected = this._currEl.selected) 239 | ); 240 | this._currEl = children[this._currEl.selected]; 241 | } else { 242 | let children = this._tree 243 | .get(this._currEl.id) 244 | .children.map((c) => this._tree.get(c).element); 245 | children.forEach((c) => { 246 | c.selected = 0; 247 | c.series = 0; 248 | }); 249 | } 250 | break; 251 | case 'series_up_down': 252 | if (this._currEl.type.startsWith('series')) { 253 | this._currEl.series = clampLoop( 254 | this._currEl.series + distance, 255 | this._currEl.type === 'series_normal' 256 | ? this._currEl.values[this._currEl.selected].length 257 | : this._currEl.values.length 258 | ); 259 | } 260 | break; 261 | case 'control': 262 | let parentId = distance; 263 | switch (distance) { 264 | // I | X | Y | D | C | F 265 | case 73: 266 | parentId = 2; 267 | break; 268 | case 88: 269 | parentId = 3; 270 | break; 271 | case 89: 272 | parentId = 4; 273 | break; 274 | case 68: 275 | parentId = 5; 276 | break; 277 | case 67: 278 | parentId = 16; 279 | break; 280 | case 70: 281 | parentId = 19; 282 | break; 283 | } 284 | if (parentId > 0) { 285 | this._currEl = this._tree.get(parentId).element; 286 | } else { 287 | this._sonifier.effectPlay('bonk'); 288 | } 289 | break; 290 | } 291 | 292 | let currValue = this._currEl.values[this._currEl.selected]; 293 | 294 | switch (this._currEl.type) { 295 | case 'series_normal': 296 | case 'series_reverse': 297 | this._sonifier.updateData( 298 | this._currEl.values, 299 | config.y.name, 300 | this._currEl.type, 301 | this._currEl.selected, 302 | this._currEl.series 303 | ); 304 | break; 305 | case 'data-sonify-values': 306 | let yName = this._currEl.sonify 307 | ? this._currEl.sonify 308 | : config.y.name; 309 | let currValues = 310 | this._currEl.values[0] && 311 | this._currEl.values[0][0] && 312 | this._currEl.values[0][0].length 313 | ? this._currEl.values?.map((v) => v[0]) 314 | : this._currEl.values; 315 | this._sonifier.updateData( 316 | currValues, 317 | yName, 318 | this._currEl.type, 319 | this._currEl.selected, 320 | this._currEl.series 321 | ); 322 | break; 323 | case 'data-no-sonify': 324 | this._sonifier.updateData( 325 | [], 326 | config.y.name, 327 | this._currEl.type, 328 | 0, 329 | 0 330 | ); 331 | break; 332 | } 333 | 334 | // If any move action happens, stop any timeouts from starting sonifier later 335 | if (this._timeouts.length > 0) { 336 | this._timeouts.forEach((t) => { 337 | window.clearTimeout(t); 338 | }); 339 | this._timeouts = []; 340 | } 341 | 342 | // Stop sonifying if any move action happens 343 | if (!isSonify && this._sonifier.isPlaying) { 344 | this.toggleSonifier(data, config, $ariaG, $chartWrapper); 345 | } 346 | if (this._currEl.type.startsWith('series')) { 347 | currValue = 348 | this._currEl.type === 'series_normal' 349 | ? this._currEl.values[this._currEl.selected][ 350 | this._currEl.series 351 | ] 352 | : this._currEl.values[this._currEl.series].values[ 353 | this._currEl.selected 354 | ]; 355 | 356 | if (isSonify) { 357 | this._sonifier.notePlay(currValue[config.y.name]); 358 | this._highlightPoint(currValue); 359 | } 360 | } 361 | 362 | if (isSonify && !this._currEl.type.startsWith('series')) { 363 | if ( 364 | this._currEl.type !== 'data-no-sonify' && 365 | this._currEl.type !== 'control' 366 | ) { 367 | let yName = this._currEl.sonify 368 | ? this._currEl.sonify 369 | : config.y.name; 370 | if (currValue[0].length) { 371 | this._sonifier.spatialPlay( 372 | currValue[0].map((d) => d[yName]) 373 | ); 374 | } else { 375 | this._sonifier.spatialPlay(currValue.map((d) => d[yName])); 376 | } 377 | } else { 378 | this._sonifier.effectPlay('drop'); 379 | } 380 | } 381 | 382 | this._valueAtLevels[this._currEl.level] = currValue; 383 | 384 | let selectClass = this._currEl.getClass( 385 | currValue, 386 | this._valueAtLevels, 387 | config 388 | ); 389 | const $selected = 390 | this._currEl.level === 0 391 | ? $chartWrapper 392 | : $ariaG.select(selectClass); 393 | 394 | if (direction === 'focus') { 395 | if (this._currEl.type !== 'filter') { 396 | this._focusMode = !this._focusMode; 397 | } else { 398 | this._currEl.values[this._currEl.selected].filtered = 399 | !this._currEl.values[this._currEl.selected].filtered; 400 | $selected.dispatch('click'); 401 | } 402 | } 403 | 404 | if (this._focusMode) { 405 | const ariaLabel = $selected.attr('aria-label'); 406 | $chartWrapper 407 | .select('.chart-sr-focus .chart-sr-focus-content') 408 | .text(ariaLabel); 409 | $chartWrapper 410 | .select('.chart-sr-desc-container.chart-sr-focus') 411 | .node() 412 | .focus(); 413 | } else { 414 | if ( 415 | config.z && 416 | direction === 'series_up_down' && 417 | (this._currEl.type === 'series_normal' || 418 | this._currEl.type === 'series_reverse') 419 | ) { 420 | const ariaLabel = $selected.attr('aria-label'); 421 | const seriesLabel = 422 | config.z.map[currValue[config.z.name]] + ' Series. '; 423 | $selected.attr('aria-label', seriesLabel + ariaLabel); 424 | // Set timeout here to remove the text that was added 425 | window.setTimeout(() => { 426 | $selected.attr('aria-label', ariaLabel); 427 | }, 4e3); 428 | } 429 | 430 | console.log(this._currEl); 431 | 432 | // If not sonify announce right away 433 | if (!isSonify) { 434 | $selected.node().focus(); 435 | } else if ( 436 | this._currEl.type.startsWith('series') || 437 | this._currEl.type === 'data-sonify-values' 438 | ) { 439 | // Else if point sonifying, wait to focus the element 440 | const tid = window.setTimeout(() => { 441 | $selected.node().focus(); 442 | this._timeouts = this._timeouts.filter((t) => t !== tid); 443 | }, 350); 444 | this._timeouts.push(tid); 445 | } 446 | } 447 | } 448 | } 449 | 450 | const clampLoop = (i, length) => { 451 | return i >= length ? 0 : i < 0 ? length - 1 : i; 452 | }; 453 | 454 | interface NavigationElement { 455 | type: string; 456 | jump: string; 457 | sonify: string; 458 | level: number; 459 | id: number; 460 | selected: number; 461 | series?: number; 462 | values?: any[]; 463 | getData?: (data: any, valueAtLevels: any[]) => any; 464 | getClass?: (d: any, valueAtLevels: any[], config: ChartConfig) => string; 465 | } 466 | 467 | interface NavigationTreeNode { 468 | children: number[]; 469 | parent: number; 470 | element: NavigationElement; 471 | } 472 | 473 | export { NavigationController, NavigationElement, NavigationTreeNode }; 474 | -------------------------------------------------------------------------------- /src/sonify.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import * as d3 from 'd3'; 5 | 6 | class Sonifier { 7 | private _nextNoteTime: number; 8 | private _currentDataIndex: number; 9 | private _currentDataSeries: number; 10 | private _navType: string; 11 | private _data: any[]; 12 | private _name: string; 13 | 14 | private _gainNodeQueue: GainNode[]; 15 | private _highlightTimerQueue: number[]; 16 | private _timerId: number; 17 | private _speed: number; 18 | private _isLocked: boolean; 19 | private _isPlaying: boolean; 20 | private _audioContext: AudioContext; 21 | private _compressor: DynamicsCompressorNode; 22 | private _sounds: { bonk?: AudioBuffer; drop?: AudioBuffer }; 23 | private _onPlayData: (value: any, index: number, series: number) => void; 24 | 25 | private _scales: { normalize: any; gain: any }; 26 | 27 | constructor( 28 | onPlayData: (value: any, index: number, series: number) => void 29 | ) { 30 | this._nextNoteTime = 0.0; 31 | this._currentDataIndex = 0; 32 | this._currentDataSeries = 0; 33 | this._data = []; 34 | this._name = ''; 35 | this._navType = ''; 36 | this._gainNodeQueue = []; 37 | this._highlightTimerQueue = []; 38 | this._timerId = -1; 39 | this._speed = 1.0; 40 | this._isLocked = true; 41 | this._isPlaying = false; 42 | this._onPlayData = onPlayData; 43 | this._scales = { 44 | normalize: d3.scaleLinear().range([0, 1]), 45 | gain: d3.scaleLinear().domain(ISO226fqSPL).range(ISO226gnSPL), 46 | }; 47 | } 48 | 49 | get isPlaying() { 50 | return this._isPlaying; 51 | } 52 | 53 | get dataLength() { 54 | return this._navType === 'series_reverse' 55 | ? this._data[this._currentDataSeries].values.length 56 | : this._data.length; 57 | } 58 | 59 | updateDomain(domain: number[]) { 60 | this._scales.normalize.domain(domain); 61 | } 62 | 63 | updateData( 64 | data: any[], 65 | name: string, 66 | type: string, 67 | index: number, 68 | series: number 69 | ) { 70 | console.log('update data called'); 71 | this._data = data; 72 | this._name = name; 73 | this._navType = type; 74 | this._currentDataIndex = index; 75 | this._currentDataSeries = series; 76 | if (this.dataLength >= 50) { 77 | this._speed = 0.6667; 78 | } else { 79 | this._speed = 1.0; 80 | } 81 | console.log('NEW SPEED is ' + this._speed); 82 | } 83 | 84 | scheduleDataToSonify(value: any, time: number) { 85 | let normalized = this._scales.normalize(value), 86 | noteNumber = normalized * 44 + 40, // Between 1 to 88 87 | frequency = A4 * Math.pow(2, (noteNumber - 49) / 12), 88 | g = Math.min(2, Math.max(0.2, this._scales.gain(frequency))); 89 | 90 | let oscillatorFirst = this._audioContext.createOscillator(), 91 | oscillatorSecond = this._audioContext.createOscillator(), 92 | gain = this._audioContext.createGain(); 93 | oscillatorSecond.connect(gain); 94 | oscillatorFirst.connect(gain); 95 | 96 | gain.connect(this._compressor); 97 | this._compressor.connect(this._audioContext.destination); 98 | 99 | oscillatorFirst.frequency.value = frequency; 100 | oscillatorSecond.frequency.value = frequency * 2.0; 101 | oscillatorFirst.type = 'sine'; 102 | oscillatorSecond.type = 'sine'; 103 | oscillatorFirst.start(time); 104 | oscillatorSecond.start(time); 105 | 106 | gain.gain.setValueAtTime(0.00001, time); 107 | gain.gain.exponentialRampToValueAtTime( 108 | g, 109 | time + NOTE_LENGTH * 0.1 * this._speed 110 | ); 111 | gain.gain.setValueAtTime(g, time + NOTE_LENGTH * 0.5 * this._speed); 112 | gain.gain.exponentialRampToValueAtTime( 113 | 0.00001, 114 | time + NOTE_LENGTH * 0.6 * this._speed 115 | ); 116 | 117 | this._gainNodeQueue.push(gain); 118 | 119 | if (this._gainNodeQueue.length > 4) { 120 | this._gainNodeQueue = this._gainNodeQueue.slice(1, 5); 121 | } 122 | 123 | oscillatorFirst.stop(time + NOTE_LENGTH * this._speed); 124 | oscillatorSecond.stop(time + NOTE_LENGTH * this._speed); 125 | } 126 | 127 | scheduleDataToSpatialSonify(values: number[], time: number) { 128 | values.forEach((value: number, index: number) => { 129 | let normalized = this._scales.normalize(value), 130 | noteNumber = normalized * 44 + 40, // Between 1 to 88 131 | frequency = A4 * Math.pow(2, (noteNumber - 49) / 12), 132 | g = Math.min(2, Math.max(0.2, this._scales.gain(frequency))); 133 | 134 | const noteTime = time + NOTE_LENGTH * 0.67 * index * this._speed; 135 | 136 | let oscillatorFirst = this._audioContext.createOscillator(), 137 | oscillatorSecond = this._audioContext.createOscillator(), 138 | stereoPanner = this._audioContext.createStereoPanner(), 139 | gain = this._audioContext.createGain(); 140 | oscillatorSecond.connect(stereoPanner); 141 | oscillatorFirst.connect(stereoPanner); 142 | stereoPanner.connect(gain); 143 | 144 | gain.connect(this._compressor); 145 | this._compressor.connect(this._audioContext.destination); 146 | 147 | oscillatorFirst.frequency.value = frequency; 148 | oscillatorSecond.frequency.value = frequency * 2.0; 149 | oscillatorFirst.type = 'sine'; 150 | oscillatorSecond.type = 'sine'; 151 | oscillatorFirst.start(noteTime); 152 | oscillatorSecond.start(noteTime); 153 | 154 | let adjLength = values.length > 1 ? values.length - 1 : 1; 155 | if (values.length === 1) { 156 | stereoPanner.pan.value = 0; 157 | } else { 158 | stereoPanner.pan.value = (index / adjLength) * 2 - 1; 159 | } 160 | 161 | gain.gain.setValueAtTime(0.00001, noteTime); 162 | gain.gain.exponentialRampToValueAtTime( 163 | g, 164 | noteTime + NOTE_LENGTH * 0.1 * this._speed 165 | ); 166 | gain.gain.setValueAtTime( 167 | g, 168 | noteTime + NOTE_LENGTH * 0.5 * this._speed 169 | ); 170 | gain.gain.exponentialRampToValueAtTime( 171 | 0.00001, 172 | noteTime + NOTE_LENGTH * 0.6 * this._speed 173 | ); 174 | 175 | this._gainNodeQueue.push(gain); 176 | 177 | if (this._gainNodeQueue.length > 4) { 178 | this._gainNodeQueue = this._gainNodeQueue.slice(1, 5); 179 | } 180 | 181 | oscillatorFirst.stop(noteTime + NOTE_LENGTH * this._speed); 182 | oscillatorSecond.stop(noteTime + NOTE_LENGTH * this._speed); 183 | }); 184 | } 185 | 186 | scheduleEffect(value?: AudioBuffer, time?: number) { 187 | if (value) { 188 | let bufferSource = this._audioContext.createBufferSource(), 189 | gain = this._audioContext.createGain(); 190 | 191 | bufferSource.buffer = value; 192 | gain.gain.value = 0.5; 193 | 194 | bufferSource.connect(gain); 195 | gain.connect(this._compressor); 196 | this._compressor.connect(this._audioContext.destination); 197 | 198 | this._gainNodeQueue.push(gain); 199 | 200 | bufferSource.start(); 201 | 202 | if (this._gainNodeQueue.length > 4) { 203 | this._gainNodeQueue = this._gainNodeQueue.slice(1, 5); 204 | } 205 | } 206 | } 207 | 208 | scheduleDataToHighlight = (d, i, s, time) => { 209 | let highlightTimerId = window.setTimeout(() => { 210 | this._onPlayData(d, i, s); 211 | 212 | let timerIndex = 213 | this._highlightTimerQueue.indexOf(highlightTimerId); 214 | if (timerIndex > -1) { 215 | this._highlightTimerQueue.splice(timerIndex, 1); 216 | } 217 | }, time * 1e3); 218 | this._highlightTimerQueue.push(highlightTimerId); 219 | }; 220 | 221 | nextNote() { 222 | this._nextNoteTime += NOTE_LENGTH * this._speed; 223 | this._currentDataIndex++; 224 | } 225 | 226 | nextSpatialNote() { 227 | this._nextNoteTime += 228 | (NOTE_LENGTH * this._speed * this._data[0].length * 3) / 4; 229 | this._currentDataIndex++; 230 | } 231 | 232 | cleanUpSonifier() { 233 | // Come up with a way to turn off all nodes playing 234 | this._gainNodeQueue.forEach((gain) => { 235 | console.log('clean up gain node'); 236 | gain.gain.cancelScheduledValues(this._audioContext.currentTime); 237 | gain.gain.exponentialRampToValueAtTime( 238 | 0.00001, 239 | this._audioContext.currentTime + NOTE_LENGTH * this._speed 240 | ); 241 | }); 242 | this._highlightTimerQueue.forEach((highlightTimerId) => { 243 | window.clearTimeout(highlightTimerId); 244 | }); 245 | this._gainNodeQueue = []; 246 | this._highlightTimerQueue = []; 247 | } 248 | 249 | scheduler() { 250 | while ( 251 | this._nextNoteTime < 252 | this._audioContext.currentTime + 253 | SCHEDULE_AHEAD_TIME * this._speed && 254 | this._currentDataIndex < this.dataLength 255 | ) { 256 | let currData = undefined; 257 | switch (this._navType) { 258 | case 'series_normal': 259 | currData = 260 | this._data[this._currentDataIndex][ 261 | this._currentDataSeries 262 | ]; 263 | break; 264 | case 'series_reverse': 265 | currData = 266 | this._data[this._currentDataSeries].values[ 267 | this._currentDataIndex 268 | ]; 269 | break; 270 | case 'data-sonify-values': 271 | console.log(this._data[this._currentDataIndex]); 272 | currData = this._data[this._currentDataIndex]; 273 | break; 274 | } 275 | 276 | if (this._navType === 'data-sonify-values') { 277 | this.scheduleDataToSpatialSonify( 278 | currData.map((d) => d[this._name]), 279 | this._nextNoteTime 280 | ); 281 | this.scheduleDataToHighlight( 282 | currData, 283 | this._currentDataIndex, 284 | this._currentDataSeries, 285 | this._nextNoteTime - this._audioContext.currentTime 286 | ); 287 | this.nextSpatialNote(); 288 | } else { 289 | this.scheduleDataToSonify( 290 | currData[this._name], 291 | this._nextNoteTime 292 | ); 293 | this.scheduleDataToHighlight( 294 | currData, 295 | this._currentDataIndex, 296 | this._currentDataSeries, 297 | this._nextNoteTime - this._audioContext.currentTime 298 | ); 299 | this.nextNote(); 300 | } 301 | } 302 | 303 | // If at the end of playback, toggle play and clean up in the future 304 | if (this._currentDataIndex >= this.dataLength) { 305 | window.clearInterval(this._timerId); 306 | 307 | window.setTimeout(() => { 308 | console.log('Delayed playback stop is called'); 309 | this._currentDataIndex = 0; 310 | if (this._isPlaying) { 311 | this.togglePlay(); 312 | } 313 | }, (this._nextNoteTime - this._audioContext.currentTime) * 1e3); 314 | } 315 | } 316 | 317 | notePlay(value: number) { 318 | this.initSonifierFromUserEvent(); 319 | 320 | this._isPlaying = false; 321 | 322 | this.scheduleDataToSonify(value, this._audioContext.currentTime); 323 | } 324 | 325 | effectPlay(value: string) { 326 | console.log('bonk called'); 327 | this.initSonifierFromUserEvent(); 328 | this._isPlaying = false; 329 | switch (value) { 330 | case 'bonk': 331 | this.scheduleEffect( 332 | this._sounds.bonk, 333 | this._audioContext.currentTime 334 | ); 335 | break; 336 | case 'drop': 337 | this.scheduleEffect( 338 | this._sounds.drop, 339 | this._audioContext.currentTime 340 | ); 341 | break; 342 | default: 343 | break; 344 | } 345 | } 346 | 347 | spatialPlay(values: number[]) { 348 | this.initSonifierFromUserEvent(); 349 | 350 | this._isPlaying = false; 351 | 352 | this.scheduleDataToSpatialSonify( 353 | values, 354 | this._audioContext.currentTime 355 | ); 356 | } 357 | 358 | initSonifierFromUserEvent() { 359 | if (!this._audioContext) { 360 | this._audioContext = new AudioContext(); 361 | } 362 | if (!this._compressor) { 363 | this._compressor = this._audioContext.createDynamicsCompressor(); 364 | } 365 | if (this._isLocked) { 366 | // play silent buffer to unlock the audio 367 | let buffer = this._audioContext.createBuffer(1, 1, 22050); 368 | let node = this._audioContext.createBufferSource(); 369 | node.buffer = buffer; 370 | node.start(0); 371 | this._isLocked = false; 372 | } 373 | if (!this._sounds) { 374 | this._sounds = {}; 375 | this.fetchSound('/assets/media/bonk-sound-effect.mp3').then( 376 | (value) => { 377 | this._sounds.bonk = value; 378 | } 379 | ); 380 | this.fetchSound('/assets/media/water-drop-sound.mp3').then( 381 | (value) => { 382 | this._sounds.drop = value; 383 | } 384 | ); 385 | } 386 | } 387 | 388 | fetchSound(url: string): Promise { 389 | return window 390 | .fetch(url) 391 | .then((response: Response) => response.arrayBuffer()) 392 | .then((arrayBuffer: ArrayBuffer) => 393 | this._audioContext.decodeAudioData(arrayBuffer) 394 | ); 395 | } 396 | 397 | togglePlay() { 398 | this.initSonifierFromUserEvent(); 399 | 400 | this._isPlaying = !this._isPlaying; 401 | 402 | if (this._isPlaying) { 403 | this._audioContext.resume(); 404 | 405 | this._nextNoteTime = this._audioContext.currentTime; 406 | this.scheduler(); 407 | if (this.dataLength > Math.ceil(LOOKAHEAD / NOTE_LENGTH / 1e3)) { 408 | this._timerId = window.setInterval( 409 | this.scheduler.bind(this), 410 | LOOKAHEAD * this._speed 411 | ); 412 | } 413 | } else { 414 | window.clearInterval(this._timerId); 415 | this.cleanUpSonifier(); 416 | } 417 | } 418 | } 419 | 420 | const LOOKAHEAD = 600.0; 421 | const SCHEDULE_AHEAD_TIME = 0.6; 422 | 423 | const NOTE_LENGTH = 0.2; 424 | 425 | const ISO226dbSPL = [ 426 | 93.94, 88.17, 82.63, 77.78, 73.08, 68.48, 64.37, 60.59, 56.7, 53.41, 50.4, 427 | 47.58, 44.98, 43.05, 41.34, 40.06, 40.01, 41.82, 42.51, 39.23, 428 | ]; 429 | 430 | const ISO226fqSPL = [ 431 | 25, 31.5, 40, 50, 63, 80, 100, 125, 160, 200, 250, 315, 400, 500, 630, 800, 432 | 1000, 1250, 1600, 2000, 433 | ]; 434 | 435 | const A4 = 440; 436 | 437 | const ISO226gnSPL = ISO226dbSPL.map((db) => Math.pow(10, (db - 50) / 10)); 438 | 439 | export { Sonifier }; 440 | -------------------------------------------------------------------------------- /src/util.ts: -------------------------------------------------------------------------------- 1 | // Copyright (c) Microsoft Corporation. 2 | // Licensed under the MIT license. 3 | 4 | import * as d3 from 'd3'; 5 | import { Aggregate, AxisConfig } from './core'; 6 | 7 | const convertToClass = (value: any) => value; 8 | 9 | const loadDataCsv = async (dataUrl: string, dataFields) => { 10 | const parseFields = dataFields.map((field) => { 11 | let parse = (d) => d[field.name]; 12 | switch (field.type) { 13 | case 'date': 14 | const parseFormat = d3.timeParse(field.format); 15 | parse = (d) => parseFormat(d[field.name]); 16 | break; 17 | case 'number': 18 | parse = (d) => parseFloat(d[field.name]); 19 | break; 20 | } 21 | return { name: field.name, parse: parse }; 22 | }); 23 | 24 | let raw = await d3.csv(dataUrl, (d) => { 25 | let row = {}; 26 | parseFields.forEach((pf) => { 27 | row[pf.name] = pf.parse(d); 28 | }); 29 | return row; 30 | }); 31 | 32 | return raw; 33 | }; 34 | 35 | const loadAnnotations = async (annotationUrl) => { 36 | const annotations = await d3.json(annotationUrl); 37 | return [ 38 | ...d3 39 | .rollup( 40 | annotations, 41 | (v) => v, 42 | (a: any) => a['type'] 43 | ) 44 | .entries(), 45 | ].map((e) => ({ key: e[0], values: e[1] })); 46 | }; 47 | 48 | const identity = (d: any) => d; 49 | 50 | const updateConfigWithFormats = (config) => { 51 | if (config.type === 'number') { 52 | config.format_long = d3.format( 53 | config.format_long ? config.format_long : ',d' 54 | ); 55 | config.format_short = d3.format( 56 | config.format_short ? config.format_short : ',d' 57 | ); 58 | } else if (config.type === 'string') { 59 | config.format_long = config.map ? (d: any) => config.map[d] : identity; 60 | config.format_short = config.map ? (d: any) => config.map[d] : identity; 61 | config.format_abbrev = identity; 62 | } else if (config.type === 'date') { 63 | switch (config.period) { 64 | case 'Second': 65 | break; 66 | case 'Minute': 67 | break; 68 | case 'Hour': 69 | break; 70 | case 'Day': 71 | config.format_long = d3.timeFormat('%B %-d, %Y'); 72 | config.format_short = d3.timeFormat('%B %-d'); 73 | config.format_abbrev = d3.timeFormat('%b %-d'); 74 | break; 75 | case 'Week': 76 | config.format_long = d3.timeFormat('%B %-d, %Y'); 77 | config.format_short = d3.timeFormat('%B %-d'); 78 | config.format_abbrev = d3.timeFormat('%b %-d'); 79 | break; 80 | case 'Month': 81 | config.format_long = d3.timeFormat('%B %Y'); 82 | config.format_short = d3.timeFormat('%B %Y'); 83 | config.format_abbrev = d3.timeFormat('%b'); 84 | break; 85 | case 'Year': 86 | config.format_long = d3.timeFormat('%Y'); 87 | config.format_short = d3.timeFormat('%Y'); 88 | config.format_abbrev = d3.timeFormat("'%y"); 89 | break; 90 | } 91 | switch (config.interval) { 92 | case 'Second': 93 | break; 94 | case 'Minute': 95 | break; 96 | case 'Hour': 97 | break; 98 | case 'Day': 99 | config.format_group = [ 100 | d3.timeFormat('%B %-d, %Y'), 101 | d3.timeFormat('%B %-d'), 102 | d3.timeFormat('%Y'), 103 | ]; 104 | break; 105 | case 'Week': 106 | config.format_group = [ 107 | d3.timeFormat('%B %-d, %Y'), 108 | d3.timeFormat('%B %-d'), 109 | d3.timeFormat('%Y'), 110 | ]; 111 | break; 112 | case 'Month': 113 | config.format_group = [ 114 | d3.timeFormat('%B %Y'), 115 | d3.timeFormat('%B'), 116 | d3.timeFormat('%Y'), 117 | ]; 118 | break; 119 | case 'Year': 120 | config.format_group = [ 121 | d3.timeFormat('%Y'), 122 | d3.timeFormat('%Y'), 123 | d3.timeFormat(''), 124 | ]; 125 | break; 126 | } 127 | } 128 | return config; 129 | }; 130 | 131 | const computeAll = (series: any[], type: string, config: AxisConfig): any[] => { 132 | let map = new Map(); 133 | series.forEach((s: any) => { 134 | s[type].forEach((v: any) => { 135 | let k = type === 'raw' ? v[config.name].toString() : v.key; 136 | let a = map.get(k) || new Map(); 137 | a.set(s.key, v); 138 | map.set(k, a); 139 | }); 140 | }); 141 | let ret = [...map.values()]; 142 | // TODO add key + values instead of an array 143 | ret = ret 144 | .map((a) => 145 | [...a.entries()] 146 | .map((e) => ({ 147 | ...e[1], 148 | series: e[0], 149 | })) 150 | .sort((a: any, b: any) => { 151 | // Sort count 152 | if (type === 'x') { 153 | return a.mean - b.mean; 154 | } else if (type === 'y') { 155 | return a.count - b.count; 156 | } else { 157 | return 0; // TODO have same series order? 158 | } 159 | }) 160 | ) 161 | .sort((a: any, b: any) => { 162 | if (config.type === 'date') { 163 | return 0; 164 | } else { 165 | return a[0].key - b[0].key; 166 | } 167 | }); 168 | return ret; 169 | }; 170 | 171 | const computeSubstrateData = ( 172 | configBase: AxisConfig, 173 | configValue: AxisConfig, 174 | configSeries: AxisConfig, 175 | configDomain: string[], 176 | raw: any[] 177 | ): any[] => { 178 | const rollupValues = (v) => { 179 | const r = { values: v }; 180 | configBase.aggregate.forEach((a: Aggregate) => { 181 | r[a] = AGGREGATE[a]( 182 | v, 183 | configBase, 184 | configValue, 185 | configSeries, 186 | configDomain, 187 | raw 188 | ); 189 | }); 190 | return r; 191 | }; 192 | 193 | const rollupKey = (d) => { 194 | if (configBase.type === 'date') { 195 | let timeInterval = d3['time' + configBase.interval]; 196 | return timeInterval(d[configBase.name]).toString(); 197 | } else if ( 198 | configBase.type === 'number' && 199 | typeof configBase.interval === 'number' && 200 | configBase.aggregate[0] !== 'layout_sum' 201 | ) { 202 | return Math.floor(d[configBase.name] / configBase.interval); 203 | } else if ( 204 | configBase.type === 'number' && 205 | typeof configBase.interval === 'number' && 206 | configBase.aggregate[0] === 'layout_sum' 207 | ) { 208 | return Math.floor(d.layout[1] / configBase.interval); 209 | } else if (configBase.type === 'string') { 210 | return d[configBase.name]; 211 | } 212 | }; 213 | 214 | return [...d3.rollup(raw, rollupValues, rollupKey).entries()] 215 | .map((e) => ({ 216 | ...e[1], 217 | key: e[0], 218 | })) 219 | .sort((a: any, b: any) => { 220 | if ( 221 | configBase.type === 'number' && 222 | typeof configBase.interval === 'number' 223 | ) { 224 | return a.key - b.key; 225 | } else { 226 | return 0; 227 | } 228 | }); 229 | }; 230 | 231 | const AGGREGATE = { 232 | mean: (v, configBase, configValue, configSeries?, configDomain?, raw?) => 233 | d3.mean(v, (d) => d[configValue.name]), 234 | max: (v, configBase, configValue, configSeries?, configDomain?, raw?) => 235 | d3.max(v, (d) => d[configValue.name]), 236 | min: (v, configBase, configValue, configSeries?, configDomain?, raw?) => 237 | d3.min(v, (d) => d[configValue.name]), 238 | sum: (v, configBase, configValue, configSeries?, configDomain?, raw?) => 239 | d3.sum(v, (d) => d[configValue.name]), 240 | count: (v, configBase, configValue, configSeries?, configDomain?, raw?) => 241 | v.length, 242 | consecutive_days: ( 243 | v, 244 | configBase, 245 | configValue, 246 | configSeries?, 247 | configDomain?, 248 | raw? 249 | ) => { 250 | let timePeriod = d3['time' + configValue.period]; 251 | let ai = 0; 252 | let m = v.reduce((arr, d, i) => { 253 | let curr = arr[ai], 254 | p = curr ? curr[curr.length - 1] : undefined; 255 | 256 | if ( 257 | p && 258 | timePeriod.count(p[configValue.name], d[configValue.name]) > 1 259 | ) { 260 | ai++; 261 | } 262 | 263 | if (!arr[ai]) { 264 | arr[ai] = []; 265 | } 266 | 267 | arr[ai].push(d); 268 | 269 | return arr; 270 | }, []); 271 | 272 | return m.map((vv) => ({ 273 | values: vv, 274 | days: vv.length, 275 | date_label: createDateLabel(vv, configBase, configValue), 276 | date_aria: createDateAria(vv, configBase, configValue), 277 | date_class: createDateClass(vv, configBase, configValue), 278 | })); 279 | }, 280 | layout_sum: ( 281 | v, 282 | configBase, 283 | configValue, 284 | configSeries?, 285 | configDomain?, 286 | raw? 287 | ) => { 288 | return v.map((ee: any) => { 289 | let [series, description, label] = createStackSeries( 290 | ee[configSeries.name], 291 | configSeries, 292 | configDomain 293 | ); 294 | // TODO get rid of hardcoded values 295 | let values = raw.filter( 296 | (dd) => 297 | dd[configValue.name] === ee[configValue.name] && 298 | series.indexOf(dd[configSeries.name]) > -1 299 | ); 300 | return { 301 | key: ee[configValue.name], 302 | values, 303 | description, 304 | label, 305 | series, 306 | }; 307 | }); 308 | }, 309 | }; 310 | 311 | const createDateLabel = (v, configBase, configValue) => { 312 | let d0 = v[0][configValue.name], 313 | d1 = v[v.length - 1][configValue.name]; 314 | // TODO figure out a way to concatenate dates at right level if they run together 315 | if (v.length === 1) { 316 | return configValue.format_abbrev(d0); 317 | } else { 318 | return ( 319 | configValue.format_abbrev(d0) + 320 | ' - ' + 321 | configValue.format_abbrev(d1) 322 | ); 323 | } 324 | }; 325 | 326 | const joinArrayWithCommasAnd = (v: string[]): string => { 327 | return ( 328 | v.slice(0, v.length - 1).join(', ') + 329 | (v.length > 1 ? ', and ' : '') + 330 | v[v.length - 1] 331 | ); 332 | }; 333 | 334 | const createStackAria = ( 335 | series: string, 336 | seriesConfig: AxisConfig, 337 | seriesDomain: string[] 338 | ): any => { 339 | const index = seriesDomain.indexOf(series); 340 | if (index === seriesDomain.length - 1) { 341 | return 'Total'; 342 | } else { 343 | return seriesDomain.slice(0, index + 1).join(' plus '); 344 | } 345 | }; 346 | 347 | const createStackSeries = ( 348 | series: string, 349 | seriesConfig: AxisConfig, 350 | seriesDomain: string[] 351 | ): any[] => { 352 | const index = seriesDomain.indexOf(series); 353 | if (index === seriesDomain.length - 1) { 354 | return [ 355 | [...seriesDomain], 356 | 'Total', 357 | 'Total', 358 | ]; 359 | } else { 360 | let slice = seriesDomain.slice(0, index + 1); 361 | return [ 362 | slice, 363 | seriesDomain.slice(0, index + 1).join(' plus '), 364 | slice.join(' + '), 365 | ]; 366 | } 367 | }; 368 | 369 | const createDateClass = ( 370 | v: any, 371 | configBase: AxisConfig, 372 | configValue: AxisConfig 373 | ) => { 374 | let d0 = v[0][configValue.name], 375 | d1 = v[v.length - 1][configValue.name]; 376 | return formatDateClass(d0) + '_' + formatDateClass(d1); 377 | }; 378 | 379 | const createDateAria = ( 380 | v, 381 | configBase: AxisConfig, 382 | configValue: AxisConfig 383 | ): string => { 384 | let d0 = v[0][configValue.name], 385 | d1 = v[v.length - 1][configValue.name]; 386 | if (v.length === 1) { 387 | return configValue.format_long(d0); 388 | // TODO figure out a way to concatenate dates at right level if they run together 389 | } else { 390 | return ( 391 | configValue.format_long(d0) + ' to ' + configValue.format_long(d1) 392 | ); 393 | } 394 | }; 395 | 396 | const createDateAriaForStartEnd = ( 397 | start: Date, 398 | end: Date, 399 | config: AxisConfig 400 | ): string => { 401 | let formatYear = d3.timeFormat('%Y'), 402 | formatMonth = d3.timeFormat('%B'), 403 | formatDate = d3.timeFormat('%-d'); 404 | 405 | let Y = [formatYear(start), formatYear(end)], 406 | M = [formatMonth(start), formatMonth(end)], 407 | D = [formatDate(start), formatDate(end)]; 408 | 409 | switch (config.period) { 410 | case 'Second': 411 | break; 412 | case 'Minute': 413 | break; 414 | case 'Hour': 415 | break; 416 | case 'Day': 417 | case 'Week': 418 | if (Y[0] === Y[1]) { 419 | if (M[0] === M[1]) { 420 | if (D[0] === D[1]) { 421 | return M[0] + ' ' + D[0] + ', ' + Y[0]; 422 | } else { 423 | return M[0] + ' ' + D[0] + ' to ' + D[1] + ', ' + Y[0]; 424 | } 425 | } else { 426 | return ( 427 | M[0] + 428 | ' ' + 429 | D[0] + 430 | ' to ' + 431 | M[1] + 432 | ' ' + 433 | D[1] + 434 | ', ' + 435 | Y[0] 436 | ); 437 | } 438 | } else { 439 | return ( 440 | M[0] + 441 | ' ' + 442 | D[0] + 443 | ', ' + 444 | Y[0] + 445 | ' to ' + 446 | M[1] + 447 | ' ' + 448 | D[1] + 449 | ', ' + 450 | Y[1] 451 | ); 452 | } 453 | case 'Month': 454 | if (Y[0] === Y[1]) { 455 | if (M[0] === M[1]) { 456 | return M[0] + ' ' + Y[0]; 457 | } else { 458 | return M[0] + ' to ' + M[1] + ' ' + Y[0]; 459 | } 460 | } else { 461 | return M[0] + ' ' + Y[0] + ' to ' + M[1] + ' ' + Y[1]; 462 | } 463 | case 'Year': 464 | if (Y[0] === Y[1]) { 465 | return Y[0]; 466 | } else { 467 | return Y[0] + ' to ' + Y[1]; 468 | } 469 | default: 470 | return ''; 471 | } 472 | return ''; 473 | }; 474 | 475 | const computeCombine = ( 476 | raw: any[], 477 | type: string, 478 | series: string[], 479 | configBase: AxisConfig, 480 | configValue: AxisConfig, 481 | configSeries?: AxisConfig 482 | ): any[] => { 483 | const rollupValues = (v) => { 484 | const r = { 485 | values: v, 486 | value: v[0][configValue.name], 487 | series: configSeries 488 | ? v.map((d) => d[configSeries.name]) 489 | : ['Series1'], 490 | }; 491 | return r; 492 | }; 493 | 494 | const rollupKeyBase = (d) => { 495 | if (configBase.type === 'date') { 496 | let timeInterval = d3['time' + configBase.interval]; 497 | return timeInterval(d[configBase.name]).toString(); 498 | } else if ( 499 | configBase.type === 'number' && 500 | typeof configBase.interval === 'number' 501 | ) { 502 | return Math.floor(d[configBase.name] / configBase.interval); 503 | } 504 | }; 505 | 506 | let rollup = [ 507 | ...d3 508 | .rollup( 509 | raw, 510 | rollupValues, 511 | rollupKeyBase, 512 | (d) => d[configValue.name] 513 | ) 514 | .entries(), 515 | ].map((e) => ({ 516 | values: [...e[1].values()], 517 | key: e[0], 518 | })); 519 | 520 | let interval = configBase.interval ? configBase.interval : 1; 521 | return rollup 522 | .map((r) => { 523 | const range = [r.key * +interval, (r.key + 1) * +interval]; 524 | let combined = findAll( 525 | r.values, 526 | configSeries ? series : ['Series1'], 527 | configValue, 528 | configSeries, 529 | range 530 | ); 531 | return { 532 | values: combined, 533 | key: r.key, 534 | range, 535 | }; 536 | }) 537 | .sort((a, b) => a.key - b.key); 538 | }; 539 | 540 | const findLongestSubarray = ( 541 | array: any[], 542 | series: string[], 543 | configValue: AxisConfig 544 | ) => { 545 | let max = 1, 546 | len = 1, 547 | index = -1; 548 | 549 | for (let i = 1; i < array.length; i++) { 550 | if ( 551 | d3['time' + configValue.period].count( 552 | array[i - 1].value, 553 | array[i].value 554 | ) <= 1 && 555 | d3.intersection(array[i].series, array[i - 1].series, series) 556 | .size === series.length 557 | ) { 558 | len++; 559 | } else { 560 | if (max < len) { 561 | max = len; 562 | index = i - max; 563 | } 564 | len = 1; 565 | } 566 | } 567 | 568 | if (max < len) { 569 | max = len; 570 | index = array.length - max; 571 | } 572 | 573 | return [index, index + max]; 574 | }; 575 | 576 | const createCombination = function (a, min) { 577 | var fn = function (n, src, got, all) { 578 | if (n == 0) { 579 | if (got.length > 0) { 580 | if (all[got.length]) { 581 | all[got.length].push(got); 582 | } else { 583 | all[got.length] = [got]; 584 | } 585 | } 586 | return; 587 | } 588 | for (var j = 0; j < src.length; j++) { 589 | fn(n - 1, src.slice(j + 1), got.concat([src[j]]), all); 590 | } 591 | return; 592 | }; 593 | var all: any[] = []; 594 | for (var i = min; i < a.length; i++) { 595 | fn(i, a, [], all); 596 | } 597 | all.push([a]); 598 | return all.reverse(); 599 | }; 600 | 601 | const findAll = ( 602 | array: any[], 603 | series: string[], 604 | configValue: AxisConfig, 605 | configSeries: any, 606 | range: number[] 607 | ) => { 608 | let combinations = createCombination(series, 1).slice(0, series.length); 609 | let all: any[] = []; 610 | 611 | let copy = [...array]; 612 | 613 | let c = 0; 614 | 615 | let n = 0; 616 | 617 | let longestSubarrayForCombinations = (arr, com) => { 618 | let maxIndices = [-1, 0]; 619 | let maxComIndex = 0; 620 | for (let i = 0; i < com.length; i++) { 621 | let indices = findLongestSubarray(arr, com[i], configValue); 622 | if (indices[1] - indices[0] > maxIndices[1] - maxIndices[0]) { 623 | maxIndices = indices; 624 | maxComIndex = i; 625 | } 626 | } 627 | return [maxIndices[0], maxIndices[1], maxComIndex]; 628 | }; 629 | 630 | let keepOtherCombinations = (arr, com) => { 631 | let other: any[] = []; 632 | for (let i = 0; i < arr.length; i++) { 633 | let diff = d3.difference(arr[i].series, com); 634 | if (diff.size > 0) { 635 | let values = arr[i].values.filter((d) => 636 | diff.has(d[configSeries.name]) 637 | ); 638 | arr[i].values = arr[i].values.filter( 639 | (d) => !diff.has(d[configSeries.name]) 640 | ); 641 | other.push({ 642 | value: new Date(arr[i].value.getTime()), 643 | values, 644 | series: [...diff], 645 | }); 646 | } 647 | } 648 | return other; 649 | }; 650 | 651 | while (c < combinations.length && copy.length > 1 && n < 10) { 652 | n++; 653 | // Check combinations at this level 654 | let ret = longestSubarrayForCombinations(copy, combinations[c]); 655 | 656 | let indices = ret.slice(0, 2); 657 | let cc = ret[2]; 658 | 659 | let nextIndices = [0, 0]; 660 | let nextCC = 0; 661 | if (combinations[c + 1]) { 662 | // Check combinations at the next level 663 | ret = longestSubarrayForCombinations(copy, combinations[c + 1]); 664 | nextIndices = ret.slice(0, 2); 665 | nextCC = ret[2]; 666 | } 667 | 668 | if ( 669 | indices[1] - indices[0] > 1 || 670 | nextIndices[1] - nextIndices[0] > 1 671 | ) { 672 | if ( 673 | indices[1] - indices[0] > 674 | (nextIndices[1] - nextIndices[0]) / 2 675 | ) { 676 | let staying = []; 677 | all.push({ 678 | series: combinations[c][cc], 679 | range, 680 | values: copy.slice(indices[0], indices[1]).map((v) => { 681 | if (!configSeries) { 682 | return v.values; 683 | } else { 684 | return combinations[c][cc].map((s) => 685 | v.values.find((d) => s === d[configSeries.name]) 686 | ); 687 | } 688 | }), 689 | }); 690 | let other = keepOtherCombinations( 691 | copy.slice(indices[0], indices[1]), 692 | combinations[c][cc] 693 | ); 694 | copy = copy 695 | .slice(0, indices[0]) 696 | .concat(other) 697 | .concat(copy.slice(indices[1], copy.length)); 698 | } else { 699 | all.push({ 700 | series: combinations[c + 1][nextCC], 701 | range, 702 | values: copy 703 | .slice(nextIndices[0], nextIndices[1]) 704 | .map((v) => { 705 | if (!configSeries) { 706 | return v.values; 707 | } else { 708 | return combinations[c + 1][nextCC].map((s) => 709 | v.values.find( 710 | (d) => s === d[configSeries.name] 711 | ) 712 | ); 713 | } 714 | }), 715 | }); 716 | let other = keepOtherCombinations( 717 | copy.slice(nextIndices[0], nextIndices[1]), 718 | combinations[c + 1][nextCC] 719 | ); 720 | copy = copy 721 | .slice(0, nextIndices[0]) 722 | .concat(other) 723 | .concat(copy.slice(nextIndices[1], copy.length)); 724 | c++; 725 | } 726 | } else { 727 | c++; 728 | } 729 | } 730 | return all; 731 | }; 732 | 733 | const computeAllCombine = ( 734 | data: any, 735 | type: string, 736 | configBase: AxisConfig, 737 | configValue: AxisConfig, 738 | configSeries?: AxisConfig 739 | ) => { 740 | let all = computeAll(data['series'], type, configBase); 741 | let combine = computeCombine( 742 | data['raw'], 743 | type, 744 | data['series'].map((s) => s.key), 745 | configBase, 746 | configValue, 747 | configSeries 748 | ); 749 | for (let i = 0; i < combine.length; i++) { 750 | combine[i]['all'] = all[i]; 751 | } 752 | return combine; 753 | }; 754 | 755 | const formatDateClass = d3.timeFormat('%b-%d-%y'); 756 | 757 | export { 758 | updateConfigWithFormats, 759 | convertToClass, 760 | loadDataCsv, 761 | loadAnnotations, 762 | joinArrayWithCommasAnd, 763 | computeAll, 764 | computeAllCombine, 765 | computeCombine, 766 | computeSubstrateData, 767 | createDateAria, 768 | createDateAriaForStartEnd, 769 | createDateClass, 770 | createStackAria, 771 | createStackSeries, 772 | AGGREGATE, 773 | }; 774 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | 3 | module.exports = { 4 | mode: 'development', 5 | entry: { 6 | chartreader: path.resolve(__dirname, 'src', 'index.ts'), 7 | }, 8 | output: { 9 | path: path.resolve(__dirname, 'build'), 10 | filename: '[name].bundle.js', 11 | library: { 12 | type: 'umd', 13 | name: '[name]', 14 | }, 15 | }, 16 | devtool: 'source-map', 17 | resolve: { 18 | extensions: ['.js', '.jsx', '.ts', '.tsx'], 19 | }, 20 | module: { 21 | rules: [ 22 | { 23 | test: /\.(js|jsx|tsx|ts)$/, 24 | exclude: /node_modules/, 25 | use: { 26 | loader: 'babel-loader', 27 | }, 28 | }, 29 | ], 30 | }, 31 | }; 32 | --------------------------------------------------------------------------------