├── .gitignore
├── assets
├── path.png
├── inputs.png
├── journey.png
├── static.png
└── data_navigator.png
├── src
├── index.ts
├── utilities.ts
├── input.ts
├── consts.ts
├── data-navigator.ts
└── rendering.ts
├── tsconfig.json
├── .prettierrc
├── .prettierignore
├── examples
├── basic_list
│ ├── basic_list.css
│ ├── bokeh.js
│ ├── basic_list.html
│ └── basic_list.js
├── bokeh.js
├── style.css
├── force-graph.js
├── vega-lite-app.js
└── docs.js
├── webpack.config.js
├── CONTRIBUTING.md
├── LICENSE
├── plan.md
├── package.json
├── vega-lite.html
├── README.md
├── vis-demo.html
├── testing.html
├── app
└── v-bundle.js
└── index.html
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | yarn-debug.log*
3 | yarn-error.log*
4 | .DS-Store
5 | dist
--------------------------------------------------------------------------------
/assets/path.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmudig/data-navigator/HEAD/assets/path.png
--------------------------------------------------------------------------------
/assets/inputs.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmudig/data-navigator/HEAD/assets/inputs.png
--------------------------------------------------------------------------------
/assets/journey.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmudig/data-navigator/HEAD/assets/journey.png
--------------------------------------------------------------------------------
/assets/static.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmudig/data-navigator/HEAD/assets/static.png
--------------------------------------------------------------------------------
/assets/data_navigator.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/cmudig/data-navigator/HEAD/assets/data_navigator.png
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import { default as structure } from './structure';
2 | import { default as input } from './input';
3 | import { default as rendering } from './rendering';
4 |
5 | export default { structure, input, rendering };
6 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "outDir": "./dist",
4 | "allowJs": true,
5 | "target": "es5",
6 | "declaration": true
7 | },
8 | "include": ["./src/**/*", "./examples/**/*"]
9 | }
10 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 120,
3 | "singleQuote": true,
4 | "useTabs": false,
5 | "tabWidth": 4,
6 | "trailingComma": "none",
7 | "bracketSpacing": true,
8 | "insertPragma": false,
9 | "semi": true,
10 | "requirePragma": false,
11 | "proseWrap": "preserve",
12 | "arrowParens": "avoid"
13 | }
--------------------------------------------------------------------------------
/.prettierignore:
--------------------------------------------------------------------------------
1 | *.lock
2 | **/node_modules/
3 | **/*.lock
4 | **/*.log
5 | **/*.d.ts
6 | **/*.config.js
7 | **/*.editorconfig
8 | **/*.gitignore
9 | **/LICENSE
10 | **/LICENSE HEADER
11 | .prettierignore
12 | *.log
13 | **/*.png
14 | **/*.jpg
15 | **/*.ico
16 | **/*.svg
17 | **/*.prod
18 | **/*.staging
19 | **/build/
20 | **/dist/
21 | **/app/
22 | **/*.snap
23 | **/*.env
--------------------------------------------------------------------------------
/examples/basic_list/basic_list.css:
--------------------------------------------------------------------------------
1 | main {
2 | padding-top: 20px;
3 | }
4 |
5 | .dn-root {
6 | position: relative;
7 | }
8 |
9 | .dn-wrapper {
10 | position: absolute;
11 | top: 0px;
12 | left: 0px;
13 | }
14 |
15 | .dn-node {
16 | position: absolute;
17 | padding: 0px;
18 | margin: 0px;
19 | overflow: visible;
20 | border: 1px solid white;
21 | outline: #00000000 solid 1px;
22 | }
23 |
24 | .dn-node:focus {
25 | border: 1px solid white;
26 | outline: #313131 solid 1px;
27 | }
28 |
29 | .dn-node-text {
30 | width: 100%;
31 | pointer-events: none;
32 | }
33 |
34 | .dn-entry-button {
35 | position: relative;
36 | top: -20px;
37 | z-index: 999;
38 | }
39 |
--------------------------------------------------------------------------------
/src/utilities.ts:
--------------------------------------------------------------------------------
1 | import { DatumObject, DescriptionOptions } from './data-navigator';
2 |
3 | export const describeNode = (d: DatumObject, descriptionOptions?: DescriptionOptions) => {
4 | const keys = Object.keys(d);
5 | let description = '';
6 | keys.forEach(key => {
7 | description += `${descriptionOptions && descriptionOptions.omitKeyNames ? '' : key + ': '}${d[key]}. `;
8 | });
9 | description += (descriptionOptions && descriptionOptions.semanticLabel) || 'Data point.';
10 | return description;
11 | };
12 |
13 | export const createValidId = (s: string): string => {
14 | // We start the string with an underscore, then replace all invalid characters with underscores
15 | return '_' + s.replace(/[^a-zA-Z0-9_-]+/g, '_');
16 | };
17 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | const path = require('path');
2 |
3 | module.exports = {
4 | entry: {
5 | 'v-bundle': './examples/vega-lite-app.js',
6 | 'static-bundle': './examples/static-app.js',
7 | 'testing-bundle': './examples/testing-environment.js',
8 | 'docs': './examples/docs.js',
9 | 'vis': './examples/vis-demo.js'
10 | },
11 | output: {
12 | filename: '[name].js',
13 | path: path.resolve(__dirname, 'app'),
14 | },
15 | resolve: {
16 | extensions: ["", ".webpack.js", ".web.js", ".ts", ".tsx", ".js"],
17 | },
18 | module: {
19 | rules: [
20 | { test: /\.tsx?$/, loader: "ts-loader" },
21 | { test: /\.js$/, loader: "source-map-loader" },
22 | ],
23 | },
24 | optimization: {
25 | minimize: true
26 | },
27 | };
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # Contributing
2 |
3 | ## Dependencies
4 |
5 | Check the [package.json](./package.json) for dev dependencies. Currently data-navigator does not use external dependencies. It is a vanilla library.
6 |
7 | ## Getting started
8 |
9 | - Fork or clone and branch
10 | - Install stuff: `yarn`
11 | - Look at our types: [data-navigator.d.ts](./data-navigator.d.ts)
12 | - Look at our scripts and things: [package.json](./package.json)
13 | - Look at our current plan: [plan.md](./plan.md)
14 |
15 | ## After making changes
16 |
17 | - Get stuff looking good: `yarn prettier-all`
18 | - Build it: `yarn build`
19 | - Serve: `yarn server`
20 | - Test with different assistive technologies
21 | - Once it looks good, open a PR
22 |
23 | ## Releasing
24 |
25 | (only from main branch, only core dev team can do this)
26 |
27 | - Bump version: `npm version` and specify `major|minor|patch`
28 | - Get it out there: `npm publish`
29 | - Update repo: `git push`
30 | - Update repo tags: `git push --tags`
31 | - Create github release
32 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2023 Carnegie Mellon University
4 |
5 | Permission is hereby granted, free of charge, to any person obtaining a copy
6 | of this software and associated documentation files (the "Software"), to deal
7 | in the Software without restriction, including without limitation the rights
8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9 | copies of the Software, and to permit persons to whom the Software is
10 | furnished to do so, subject to the following conditions:
11 |
12 | The above copyright notice and this permission notice shall be included in all
13 | copies or substantial portions of the Software.
14 |
15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21 | SOFTWARE.
22 |
--------------------------------------------------------------------------------
/plan.md:
--------------------------------------------------------------------------------
1 | # Plan
2 |
3 | ## First priorities:
4 |
5 | - build out the `structure` module, which should generate node-edge data structure from input JSON
6 | - create toGraphViz method
7 | - create fromGraphViz method: look at perfopticon? (see how they did it)
8 | - create node builder, edgebuilder, and navbuilder (and wrap all 3 into 1)
9 | - add example with set diagram
10 |
11 | ## Follow-up work (big picture stuff):
12 |
13 | - Build UI tool for creating DN strcture visually
14 | - Models to help with UI tool?
15 | - Keyboard instructions
16 | - Build examples:
17 | - Visualizations:
18 | - Maps/spatial
19 | - Graphs/diagrams
20 | - Sankey/alluvial/flow
21 | - Math functions
22 | - Super cool custom work
23 | - Non-visualizations:
24 | - Simple game UI in canvas or webGL
25 | - Simple Doc UI in canvas or webGL
26 | - ??
27 | - Patterns:
28 | - Serial (bar)
29 | - Simple Nested (scatter aka vega-lite example we currently have)
30 | - Grouped (bar/line)
31 | - Binned (histogram/continuous scale line/scatter)
32 | - Multi-tree (existing example with highcharts)
33 | - ??
34 | - Input modalities
35 | - Screen reader/keyboard
36 | - Voice
37 | - Hand gesture
38 | - touch (and mobile screen reader)
39 | - Click + focus handling (with on-demand rendering?)
40 | - Run user studies on previous examples
41 | - Put user study results on the webpage itself perhaps?
42 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "data-navigator",
3 | "author": "Frank Elavsky",
4 | "version": "2.2.0",
5 | "main": "./dist/index.jsm",
6 | "module": "./dist/index",
7 | "types": "./dist/src/index.d.ts",
8 | "files": [
9 | "dist/**/*"
10 | ],
11 | "exports": {
12 | ".": {
13 | "import": {
14 | "types": "./dist/src/index.d.ts",
15 | "default": "./dist/index.js"
16 | },
17 | "require": {
18 | "types": "./dist/src/index.d.ts",
19 | "default": "./dist/index.mjs"
20 | }
21 | }
22 | },
23 | "keywords": [
24 | "visualization",
25 | "accessibility",
26 | "touch",
27 | "keyboard"
28 | ],
29 | "description": "Data-navigator is a JavaScript library that allows for serial navigation of data structures using a variety of input modalities and assistive technologies.",
30 | "scripts": {
31 | "clean": "rm -rf ./dist && rm -rf ./app",
32 | "build": "yarn build:app && yarn build:index && yarn build:modules",
33 | "build:app": "webpack",
34 | "build:index": "tsup src/index.ts --format cjs,esm",
35 | "build:modules": "tsup src/structure.ts src/input.ts src/rendering.ts src/utilities.ts src/consts.ts --format cjs,esm --minify",
36 | "server": "python -m http.server",
37 | "prettier-all-check": "prettier --config ./.prettierrc --ignore ./.prettierignore --debug-check \"**/*.{js,jsx,ts,tsx,html,jsx,json,css,scss,md}\"",
38 | "prettier-all": "prettier --config ./.prettierrc --ignore ./.prettierignore --write \"**/*.{js,jsx,ts,tsx,html,jsx,json,css,scss,md}\""
39 | },
40 | "devDependencies": {
41 | "@swc/core": "^1.3.75",
42 | "prettier": "^2.6.2",
43 | "source-map-loader": "^4.0.1",
44 | "ts-loader": "^9.4.4",
45 | "tsup": "^7.2.0",
46 | "typescript": "^5.1.6",
47 | "webpack": "^5.76.0",
48 | "webpack-cli": "^4.9.2"
49 | }
50 | }
51 |
--------------------------------------------------------------------------------
/examples/basic_list/bokeh.js:
--------------------------------------------------------------------------------
1 | export const plot = (id, focusData) => {
2 | const stores = ['a', 'b'];
3 | const plt = Bokeh.Plotting;
4 |
5 | const p = plt.figure({
6 | x_range: stores,
7 | y_range: [0, 5.5],
8 | height: 300,
9 | width: 300,
10 | title: 'Fruit cost by store',
11 | output_backend: 'svg',
12 | toolbar_location: null,
13 | tools: ''
14 | });
15 |
16 | p.vbar({
17 | x: stores,
18 | top: [3, 2.75],
19 | bottom: [0, 0],
20 | width: 0.8,
21 | color: ['#FCB5B6', '#FCB5B6'],
22 | line_color: ['#8F0002', '#8F0002']
23 | });
24 |
25 | p.vbar({
26 | x: stores,
27 | top: [3.75, 4],
28 | bottom: [3, 2.75],
29 | width: 0.8,
30 | color: ['#F9E782', '#F9E782'],
31 | line_color: ['#766500', '#766500']
32 | });
33 |
34 | if (focusData) {
35 | p.vbar({
36 | x: stores,
37 | top: focusData.top,
38 | bottom: focusData.bottom,
39 | width: 0.8,
40 | line_width: 3,
41 | color: ['none', 'none'],
42 | line_color: focusData.line_color
43 | });
44 | }
45 |
46 | const r1 = p.square([-10000], [-10000], { color: '#FCB5B6', line_color: '#8F0002' });
47 | const r2 = p.square([-10000], [-10000], { color: '#F9E782', line_color: '#766500' });
48 |
49 | const legend_items = [
50 | new Bokeh.LegendItem({ label: 'apple', renderers: [r1] }),
51 | new Bokeh.LegendItem({ label: 'banana', renderers: [r2] })
52 | ];
53 | const legend = new Bokeh.Legend({ items: legend_items, location: 'top_left', orientation: 'horizontal' });
54 | p.add_layout(legend);
55 |
56 | plt.show(p, `#${id}`);
57 | const plotToHide = document.getElementById(id);
58 | if (plotToHide) {
59 | plotToHide.inert = true; //we need to do this in order to disable the bad accessibility bokeh currently has
60 | }
61 | const wrapper = document.getElementById(`${id}-wrapper`);
62 | wrapper.setAttribute('aria-label', 'Fruit cost by store. Bokeh stacked bar chart.');
63 | };
64 |
--------------------------------------------------------------------------------
/examples/bokeh.js:
--------------------------------------------------------------------------------
1 | export const plot = (id, focusData) => {
2 | const stores = ['a', 'b'];
3 | const plt = Bokeh.Plotting;
4 |
5 | const p = plt.figure({
6 | x_range: stores,
7 | y_range: [0, 5.5],
8 | height: 300,
9 | width: 300,
10 | title: 'Fruit cost by store',
11 | output_backend: 'svg',
12 | toolbar_location: null,
13 | tools: ''
14 | });
15 |
16 | p.vbar({
17 | x: stores,
18 | top: [3, 2.75],
19 | bottom: [0, 0],
20 | width: 0.8,
21 | color: ['#FCB5B6', '#FCB5B6'],
22 | line_color: ['#8F0002', '#8F0002']
23 | });
24 |
25 | p.vbar({
26 | x: stores,
27 | top: [3.75, 4],
28 | bottom: [3, 2.75],
29 | width: 0.8,
30 | color: ['#F9E782', '#F9E782'],
31 | line_color: ['#766500', '#766500']
32 | });
33 |
34 | if (focusData) {
35 | p.vbar({
36 | x: stores,
37 | top: focusData.top,
38 | bottom: focusData.bottom,
39 | width: 0.8,
40 | line_width: 3,
41 | color: ['none', 'none'],
42 | line_color: focusData.line_color
43 | });
44 | }
45 |
46 | const r1 = p.square([-10000], [-10000], { color: '#FCB5B6', line_color: '#8F0002' });
47 | const r2 = p.square([-10000], [-10000], { color: '#F9E782', line_color: '#766500' });
48 |
49 | const legend_items = [
50 | new Bokeh.LegendItem({ label: 'apple', renderers: [r1] }),
51 | new Bokeh.LegendItem({ label: 'banana', renderers: [r2] })
52 | ];
53 | const legend = new Bokeh.Legend({ items: legend_items, location: 'top_left', orientation: 'horizontal' });
54 | p.add_layout(legend);
55 |
56 | plt.show(p, `#${id}`);
57 | const plotToHide = document.getElementById(id);
58 | if (plotToHide) {
59 | plotToHide.inert = true; //we need to do this in order to disable the bad accessibility bokeh currently has
60 | }
61 | const wrapper = document.getElementById(`${id}-wrapper`);
62 | wrapper.setAttribute(
63 | 'aria-label',
64 | 'Fruit cost by store. Bokeh stacked bar chart. Store a: Apple 3, Banana 0.75. Store b: Apple 2.75, Banana 1.25.'
65 | );
66 | };
67 |
--------------------------------------------------------------------------------
/examples/basic_list/basic_list.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Data Navigator Basic List
6 |
7 |
8 |
9 |
10 |
20 |
21 |
22 |
27 |
32 |
37 |
42 |
47 |
52 |
53 |
54 |
55 |
56 |
--------------------------------------------------------------------------------
/vega-lite.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Data Navigator Examples
6 |
7 |
13 |
29 |
30 |
35 |
36 |
37 |
38 |
39 | skip to main content
40 |
43 |
44 | Data Navigator Vega-Lite Examples
45 | Hi! Welcome to our testing area :)
46 |
47 |
48 |
49 |
50 |
51 |
52 |
53 |
54 |
--------------------------------------------------------------------------------
/src/input.ts:
--------------------------------------------------------------------------------
1 | import { defaultKeyBindings, GenericFullNavigationRules } from './consts';
2 | import type { InputOptions } from './data-navigator';
3 |
4 | export default (options: InputOptions) => {
5 | let inputHandler = {} as any;
6 | let keyBindings = defaultKeyBindings;
7 | let directions = GenericFullNavigationRules;
8 |
9 | inputHandler.moveTo = id => {
10 | const target = options.structure.nodes[id];
11 | if (target) {
12 | return target;
13 | }
14 | return;
15 | };
16 | inputHandler.move = (currentFocus, direction) => {
17 | if (currentFocus) {
18 | const d = options.structure.nodes[currentFocus];
19 | if (d.edges) {
20 | let target = null;
21 | let i = 0;
22 | const navRule = directions[direction];
23 | if (!navRule) {
24 | return;
25 | }
26 | const findTarget = (rule, edge) => {
27 | if (!(rule === direction)) {
28 | return null;
29 | }
30 | let resolvedNodes = {
31 | target: typeof edge.target === 'string' ? edge.target : edge.target(d, currentFocus),
32 | source: typeof edge.source === 'string' ? edge.source : edge.source(d, currentFocus)
33 | };
34 | return !(resolvedNodes[navRule.direction] === currentFocus)
35 | ? resolvedNodes[navRule.direction]
36 | : null;
37 | };
38 | for (i = 0; i < d.edges.length; i++) {
39 | const edge = options.structure.edges[d.edges[i]];
40 | // if (Array.isArray(types)) {
41 | edge.navigationRules.forEach(rule => {
42 | if (!target) {
43 | target = findTarget(rule, edge);
44 | }
45 | });
46 | // } else {
47 | // target = verifyEdge(types, edge);
48 | // }
49 | if (target) {
50 | break;
51 | }
52 | }
53 | if (target) {
54 | return inputHandler.moveTo(target);
55 | }
56 | return undefined;
57 | }
58 | }
59 | };
60 | inputHandler.enter = () => {
61 | if (options.entryPoint) {
62 | return inputHandler.moveTo(options.entryPoint);
63 | } else {
64 | console.error('No entry point was specified in InputOptions, returning undefined');
65 | return;
66 | }
67 | };
68 | inputHandler.exit = () => {
69 | if (options.exitPoint) {
70 | return options.exitPoint;
71 | } else {
72 | console.error('No exit point was specified in InputOptions, returning undefined');
73 | return;
74 | }
75 | };
76 | inputHandler.keydownValidator = e => {
77 | const direction = keyBindings[e.code];
78 | if (direction) {
79 | return direction;
80 | }
81 | };
82 | inputHandler.focus = renderId => {
83 | const node = document.getElementById(renderId);
84 | if (node) {
85 | node.focus();
86 | }
87 | };
88 | inputHandler.setNavigationKeyBindings = navKeyBindings => {
89 | if (!navKeyBindings) {
90 | keyBindings = defaultKeyBindings;
91 | directions = GenericFullNavigationRules;
92 | } else {
93 | keyBindings = {};
94 | directions = navKeyBindings;
95 | Object.keys(navKeyBindings).forEach(direction => {
96 | const navOption = navKeyBindings[direction];
97 | keyBindings[navOption.key] = direction;
98 | });
99 | }
100 | };
101 |
102 | inputHandler.setNavigationKeyBindings(options.navigationRules);
103 | return inputHandler;
104 | };
105 |
--------------------------------------------------------------------------------
/src/consts.ts:
--------------------------------------------------------------------------------
1 | import type { DatumObject, NavigationRules, RenderObject } from './data-navigator';
2 |
3 | export const SemanticKeys = {
4 | Escape: true,
5 | Enter: true,
6 | Backspace: true,
7 | ArrowLeft: true,
8 | ArrowRight: true,
9 | ArrowUp: true,
10 | ArrowDown: true
11 | };
12 |
13 | export const defaultKeyBindings = {
14 | ArrowLeft: 'left',
15 | ArrowRight: 'right',
16 | ArrowUp: 'up',
17 | ArrowDown: 'down',
18 | Period: 'forward',
19 | Comma: 'backward',
20 | Escape: 'parent',
21 | Enter: 'child'
22 | } as DatumObject;
23 |
24 | export const TypicallyUnreservedKeys = ['KeyW', 'KeyJ', 'LeftBracket', 'RightBracket', 'Slash', 'Backslash'];
25 |
26 | export const TypicallyUnreservedSoloKeys = ['KeyW', 'KeyJ'];
27 |
28 | export const TypicallyUnreservedKeyPairs = [
29 | ['LeftBracket', 'RightBracket'],
30 | ['Slash', 'Backslash']
31 | ];
32 |
33 | export const GenericFullNavigationRules = {
34 | left: {
35 | key: 'ArrowLeft',
36 | direction: 'source'
37 | },
38 | right: {
39 | key: 'ArrowRight',
40 | direction: 'target'
41 | },
42 | up: {
43 | key: 'ArrowUp',
44 | direction: 'source'
45 | },
46 | down: {
47 | key: 'ArrowDown',
48 | direction: 'target'
49 | },
50 | child: {
51 | key: 'Enter',
52 | direction: 'target'
53 | },
54 | parent: {
55 | key: 'Backspace',
56 | direction: 'source'
57 | },
58 | backward: {
59 | key: 'Comma',
60 | direction: 'source'
61 | },
62 | forward: {
63 | key: 'Period',
64 | direction: 'target'
65 | },
66 | previous: {
67 | key: 'Semicolon',
68 | direction: 'source'
69 | },
70 | next: {
71 | key: 'Quote',
72 | direction: 'target'
73 | },
74 | exit: {
75 | key: 'Escape',
76 | direction: 'target'
77 | },
78 | help: {
79 | key: 'KeyY',
80 | direction: 'target'
81 | },
82 | undo: {
83 | key: 'KeyZ',
84 | direction: 'target'
85 | }
86 | } as NavigationRules;
87 |
88 | export const GenericFullNavigationDimensions = [
89 | ['left', 'right'],
90 | ['up', 'down'],
91 | ['backward', 'forward'],
92 | ['previous', 'next']
93 | ];
94 | export const GenericFullNavigationPairs = {
95 | left: ['left', 'right'],
96 | right: ['left', 'right'],
97 | up: ['up', 'down'],
98 | down: ['up', 'down'],
99 | backward: ['backward', 'forward'],
100 | forward: ['backward', 'forward'],
101 | previous: ['previous', 'next'],
102 | next: ['previous', 'next'],
103 | parent: ['parent', 'child'],
104 | child: ['parent', 'child'],
105 | exit: ['exit', 'undo'],
106 | undo: ['undo', 'undo']
107 | };
108 |
109 | export const GenericLimitedNavigationRules = {
110 | right: {
111 | key: 'ArrowRight',
112 | direction: 'target'
113 | },
114 | left: {
115 | key: 'ArrowLeft',
116 | direction: 'source'
117 | },
118 | down: {
119 | key: 'ArrowDown',
120 | direction: 'target'
121 | },
122 | up: {
123 | key: 'ArrowUp',
124 | direction: 'source'
125 | },
126 | child: {
127 | key: 'Enter',
128 | direction: 'target'
129 | },
130 | parent: {
131 | key: 'Backspace',
132 | direction: 'source'
133 | },
134 | exit: {
135 | key: 'Escape',
136 | direction: 'target'
137 | },
138 | undo: {
139 | key: 'Period',
140 | direction: 'target'
141 | },
142 | legend: {
143 | key: 'KeyL',
144 | direction: 'target'
145 | }
146 | } as NavigationRules;
147 |
148 | export const NodeElementDefaults = {
149 | cssClass: '',
150 | spatialProperties: {
151 | x: 0,
152 | y: 0,
153 | width: 0,
154 | height: 0,
155 | path: ''
156 | },
157 | semantics: {
158 | label: '',
159 | elementType: 'div',
160 | role: 'image',
161 | attributes: undefined
162 | },
163 | parentSemantics: {
164 | label: '',
165 | elementType: 'figure',
166 | role: 'figure',
167 | attributes: undefined
168 | },
169 | existingElement: {
170 | useForSpatialProperties: false,
171 | spatialProperties: undefined
172 | }
173 | } as RenderObject;
174 |
--------------------------------------------------------------------------------
/examples/style.css:
--------------------------------------------------------------------------------
1 | body {
2 | /* font-size: 1.25em; */
3 | margin: 0 auto;
4 | max-width: 40em;
5 | padding: 50px;
6 | }
7 |
8 | img {
9 | width: 100%;
10 | }
11 |
12 | .dn-root {
13 | position: relative;
14 | }
15 |
16 | .dn-wrapper {
17 | position: absolute;
18 | top: 0px;
19 | left: 0px;
20 | }
21 |
22 | .dn-node {
23 | position: absolute;
24 | padding: 0px;
25 | margin: 0px;
26 | overflow: visible;
27 | border: 2px solid white;
28 | outline: #000000 solid 1px;
29 | }
30 |
31 | .dn-node:focus {
32 | border: 2px solid white;
33 | outline: #000000 solid 3px;
34 | }
35 |
36 | .dn-manual-focus-node {
37 | border: 1px solid white;
38 | outline: #00000000 solid 1px;
39 | }
40 |
41 | .dn-manual-focus-node:focus {
42 | border: 1px solid white;
43 | outline: #313131 solid 1px;
44 | }
45 |
46 | .dn-node-svg {
47 | position: absolute;
48 | pointer-events: none;
49 | }
50 |
51 | .dn-node-path {
52 | fill: none;
53 | stroke: #000000;
54 | stroke-width: 4px;
55 | transform: translateY(2px);
56 | }
57 |
58 | .dn-exit-position {
59 | display: none;
60 | position: absolute;
61 | bottom: -18px;
62 | }
63 |
64 | .dn-exit-position:focus {
65 | display: block;
66 | }
67 |
68 | .dn-entry-button {
69 | position: relative;
70 | top: -21px;
71 | z-index: 999;
72 | }
73 |
74 | .wrapper {
75 | position: relative;
76 | }
77 |
78 | .canvasbox {
79 | border-radius: 3px;
80 | margin-right: 10px;
81 | width: 450px;
82 | height: 338px;
83 | border-bottom: 3px solid #0063ff;
84 | box-shadow: 0 2px 3px 0 rgba(0, 0, 0, 0.2), 0 4px 10px 0 #00000030;
85 | }
86 |
87 | .videobox {
88 | display: none;
89 | }
90 |
91 | .hidden {
92 | display: none;
93 | }
94 |
95 | .column {
96 | float: left;
97 | padding-right: 15px;
98 | }
99 |
100 | .left {
101 | width: 400px;
102 | }
103 |
104 | .right {
105 | max-width: 50%;
106 | }
107 |
108 | .row:after {
109 | content: '';
110 | display: table;
111 | clear: both;
112 | }
113 |
114 | .alert {
115 | color: darkred;
116 | }
117 |
118 | .tooltip {
119 | position: absolute;
120 | padding: 10px;
121 | background-color: white;
122 | border: 1px solid black;
123 | width: 200px;
124 | top: 0;
125 | left: 0;
126 | }
127 |
128 | #docs .tooltip {
129 | border: 1px solid rgba(0, 0, 0, 0.6);
130 | border-left: 1px solid white;
131 | }
132 |
133 | #fist {
134 | position: absolute;
135 | top: 0;
136 | left: 0;
137 | font-size: 2em;
138 | display: none;
139 | }
140 |
141 | #copy-announcer {
142 | font-size: 0.8em;
143 | font-weight: 100;
144 | color: #444;
145 | }
146 |
147 | p {
148 | margin-top: 0;
149 | }
150 |
151 | .video-wrapper {
152 | position: relative;
153 | }
154 |
155 | th {
156 | text-align: left;
157 | }
158 |
159 | h1 {
160 | font-size: 2em;
161 | }
162 |
163 | h2 {
164 | font-size: 1.8em;
165 | }
166 |
167 | h3 {
168 | margin-bottom: 0.2em;
169 | font-size: 1.6em;
170 | }
171 |
172 | h4 {
173 | margin-bottom: 0.2em;
174 | font-size: 1.4em;
175 | }
176 |
177 | h5 {
178 | margin-bottom: 0.2em;
179 | font-size: 1.2em;
180 | }
181 |
182 | .cell {
183 | padding: 2px 10px 2px 0px;
184 | font-weight: normal;
185 | }
186 |
187 | .t-column {
188 | padding-right: 10px;
189 | }
190 |
191 | summary {
192 | padding-bottom: 10px;
193 | font-style: italic;
194 | }
195 |
196 | .limited {
197 | max-width: 365px;
198 | }
199 |
200 | kbd {
201 | border: 1px solid black;
202 | border-radius: 3px;
203 | padding: 0px 0.35em;
204 | }
205 |
206 | #testing-environment .dn-node {
207 | border-radius: 7px;
208 | border: 0px solid white;
209 | outline: #000000 solid 0px;
210 | }
211 |
212 | #testing-environment .dn-node:focus {
213 | outline: #000000 solid 0px;
214 | }
215 |
216 | #testing-environment .dn-node-path {
217 | stroke-width: 2px;
218 | transform: translateY(1px);
219 | }
220 |
221 | #testing-environment .dn-exit-position {
222 | bottom: 0px;
223 | }
224 |
225 | #testing-environment .dn-entry-button {
226 | top: 0px;
227 | }
228 |
229 | #dn-wrapper-data-navigator-schema-added {
230 | width: inherit !important;
231 | }
232 |
233 | #testing-environment code {
234 | background: #333;
235 | padding: 0.1em 0.35em;
236 | color: white;
237 | }
238 |
239 | #testing-environment code.block {
240 | display: block;
241 | overflow: auto;
242 | white-space: pre;
243 | }
244 |
245 | #docs code {
246 | background-color: rgb(43, 43, 43);
247 | color: rgb(248, 248, 242);
248 | padding: 0.1em 0.35em;
249 | border-radius: 3px;
250 | font-size: 0.85em;
251 | }
252 |
253 | #docs .hljs-string {
254 | color: #abe338;
255 | }
256 |
257 | #docs .function_ {
258 | color: #00e0e0;
259 | }
260 |
261 | #docs pre code {
262 | font-size: 0.75em;
263 | border-radius: 20px;
264 | padding: 1.5em;
265 | }
266 |
267 | #docs svg {
268 | border: 1px solid rgba(0, 0, 0, 0.6);
269 | }
270 |
271 | .side-by-side {
272 | display: inline-block;
273 | margin-right: 2em;
274 | }
275 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Data Navigator
2 |
3 | 
4 |
5 | Data Navigator is a JavaScript library that allows for navigation of data structures. Data Navigator makes png, svg, canvas, and even webgl graphics accessible to a wide array of assistive technologies and input modalities.
6 |
7 | Check out [our online, interactive demo](http://dig.cmu.edu/data-navigator/) for a video introduction and to try out Data Navigator hands-on.
8 |
9 | Check out [our docs](http://dig.cmu.edu/data-navigator/docs.html) to learn more about getting started.
10 |
11 | ## Data Navigator's approach
12 |
13 | Data Navigator has abstracted navigation into commands, which enables it to easily receive input from screen readers and keyboards to more interesting modalities like touch and mouse swiping, spoken commands, hand gestures, and even fabricated or totally novel interfaces, like bananas.
14 |
15 | 
16 |
17 | Data Navigator is expressive for builders and enables entire toolkits or ecosystems to become more accessible. The system provides low-level control over narratives, interaction paths, and journeys a user might want to take through an image or graphic.
18 |
19 | Developers can build schemas that scale to work with any chart in a charting library or a single, bespoke implemetation for use in story-telling contexts like journalism, reports, presentations, and interactive infographics.
20 |
21 | 
22 |
23 | Not only are paths through an image customizeable but so are the visual indications that are rendered alongside those journeys. These visual indications use semantically rich, native HTML elements for maximized accessibility.
24 |
25 | 
26 |
27 | Visit [our landing page](http://dig.cmu.edu/data-navigator/) to try our demo, which shows a png image made into navigable experience. A variety of input modalities are enabled, including touch and mouse swiping, keyboard, screen reader, text input, voice control, and hand gesture recognition.
28 |
29 | We also have [a vega-lite demo online](https://dig.cmu.edu/data-navigator/vega-lite.html), which (under the hood) shows how someone could write one schema that serves any chart in an ecosystem.
30 |
31 | ## System design
32 |
33 | Data Navigator is organized into 3 separately composable modules: the first is a graph-based _structure_ of nodes and edges, the second handles _input_ and navigation logic, and the third _renders_ the structure. These may be leveraged together or independently. Read our paper to learn more!
34 |
35 | ### Types
36 |
37 | Our types are consolidated into a single [types export file](./src/data-navigator.ts), designed (mostly) as a grammar. Each major module is broken down into subparts, each with their own types, all the way to the primitive-most types used.
38 |
39 | ## Getting started
40 |
41 | We have a thorough introduction to building a navigable visualization on [our docs site](http://dig.cmu.edu/data-navigator/docs.html). But for basic installation, see below:
42 |
43 | You can install or use both esm and cjs modules in a variety of ways, in addition to importing all of data-navigator or just one part.
44 |
45 | ```
46 | # to install into a project
47 | npm install data-navigator
48 | ```
49 |
50 | ```js
51 | // to use it in a .js or .ts file
52 | import { default as dataNavigator } from 'data-navigator';
53 |
54 | // whole ecosystem
55 | console.log('dataNavigator', dataNavigator);
56 |
57 | // one module in the ecosystem
58 | console.log('dataNavigator.rendering', dataNavigator.rendering);
59 | ```
60 |
61 | ```html
62 |
63 |
68 | ```
69 |
70 | ## Credit
71 |
72 | Data-Navigator was developed at CMU's [Data Interaction Group](https://dig.cmu.edu/) (CMU DIG), primarily by [Frank Elavsky](https://frank.computer).
73 |
74 | ## Citing Data Navigator
75 |
76 | ```bib
77 | @article{2023-data-navigator,
78 | year = {2023},
79 | author = {Frank Elavsky and Lucas Nadolskis and Dominik Moritz},
80 | title = {{Data Navigator:} An Accessibility-Centered Data Navigation Toolkit},
81 | journal = {{IEEE} {VIS}}
82 | }
83 | ```
84 |
--------------------------------------------------------------------------------
/examples/basic_list/basic_list.js:
--------------------------------------------------------------------------------
1 | import dataNavigator from 'https://cdn.jsdelivr.net/npm/data-navigator@2.2.0/dist/index.mjs';
2 | import { describeNode } from 'https://cdn.jsdelivr.net/npm/data-navigator@2.2.0/dist/utilities.mjs';
3 | import { plot } from './bokeh.js';
4 |
5 | const width = 300;
6 | const height = 300;
7 | const id = 'chart';
8 | let current = null;
9 | let previous = null;
10 |
11 | const interactiveData = {
12 | data: [
13 | [
14 | [3, 2.75],
15 | [0, 0]
16 | ],
17 | [
18 | [3.75, 4],
19 | [3, 2.75]
20 | ]
21 | ],
22 | indices: {
23 | fruit: {
24 | apple: 0,
25 | banana: 1
26 | },
27 | store: {
28 | a: 0,
29 | b: 1
30 | }
31 | }
32 | };
33 |
34 | // begin structure scaffolding
35 |
36 | const structure = {
37 | nodes: {
38 | _0: {
39 | id: '_0',
40 | data: {
41 | fruit: 'apple',
42 | store: 'a',
43 | cost: 3
44 | },
45 | edges: ['_0-_1', 'any-exit']
46 | },
47 | _1: {
48 | id: '_1',
49 | data: {
50 | fruit: 'banana',
51 | store: 'a',
52 | cost: 0.75
53 | },
54 | edges: ['_0-_1', '_1-_2', 'any-exit']
55 | },
56 | _2: {
57 | id: '_2',
58 | data: {
59 | fruit: 'apple',
60 | store: 'b',
61 | cost: 2.75
62 | },
63 | edges: ['_1-_2', '_2-_3', 'any-exit']
64 | },
65 | _3: {
66 | id: '_3',
67 | data: {
68 | fruit: 'banana',
69 | store: 'b',
70 | cost: 1.25
71 | },
72 | edges: ['_2-_3', 'any-exit']
73 | }
74 | },
75 | edges: {
76 | '_0-_1': {
77 | source: '_0',
78 | target: '_1',
79 | navigationRules: ['left', 'right']
80 | },
81 | '_1-_2': {
82 | source: '_1',
83 | target: '_2',
84 | navigationRules: ['left', 'right']
85 | },
86 | '_2-_3': {
87 | source: '_2',
88 | target: '_3',
89 | navigationRules: ['left', 'right']
90 | },
91 | 'any-exit': {
92 | source: (_d, c) => c,
93 | target: () => {
94 | exit();
95 | return '';
96 | },
97 | navigationRules: ['exit']
98 | }
99 | },
100 | navigationRules: {
101 | left: { key: 'ArrowLeft', direction: 'source' }, // moves backward when pressing ArrowLeft on the keyboard
102 | right: { key: 'ArrowRight', direction: 'target' }, // moves forward when pressing ArrowRight on the keyboard
103 | exit: { key: 'Escape', direction: 'target' } // exits the structure when pressing Escape on the keyboard
104 | }
105 | };
106 |
107 | // begin rendering scaffolding
108 |
109 | const addRenderingProperties = nodes => {
110 | // we want to loop over all of our nodes:
111 | Object.keys(nodes).forEach(k => {
112 | let node = nodes[k];
113 |
114 | if (!node.renderId) {
115 | node.renderId = node.id;
116 | }
117 |
118 | node.semantics = {
119 | label: describeNode(node.data, {})
120 | };
121 |
122 | // all of our elements will start at 0,0 and be full width/height
123 | node.spatialProperties = {
124 | x: -2,
125 | y: -2,
126 | width: width,
127 | height: height
128 | };
129 | });
130 | };
131 | addRenderingProperties(structure.nodes);
132 |
133 | const rendering = dataNavigator.rendering({
134 | elementData: structure.nodes,
135 | defaults: {
136 | cssClass: 'dn-test-class'
137 | },
138 | suffixId: id,
139 | root: {
140 | id: id + '-wrapper',
141 | cssClass: '',
142 | width: '100%'
143 | },
144 | entryButton: {
145 | include: true,
146 | callbacks: {
147 | click: () => {
148 | enter();
149 | }
150 | }
151 | },
152 | exitElement: {
153 | include: true
154 | }
155 | });
156 |
157 | // initialize
158 | plot('chart');
159 | rendering.initialize();
160 |
161 | // begin input scaffolding
162 | const exit = () => {
163 | rendering.exitElement.style.display = 'block';
164 | input.focus(exitPoint);
165 | previous = current;
166 | current = null;
167 | rendering.remove(previous);
168 | };
169 |
170 | const enter = () => {
171 | const nextNode = input.enter();
172 | if (nextNode) {
173 | initiateLifecycle(nextNode);
174 | }
175 | };
176 |
177 | const move = direction => {
178 | const nextNode = input.move(current, direction);
179 |
180 | if (nextNode) {
181 | initiateLifecycle(nextNode);
182 | }
183 | };
184 |
185 | const initiateLifecycle = nextNode => {
186 | // we make a node to turn into an element
187 | const renderedNode = rendering.render({
188 | renderId: nextNode.renderId,
189 | datum: nextNode
190 | });
191 |
192 | // we add event listeners
193 | renderedNode.addEventListener('keydown', e => {
194 | // input has a keydown validator
195 | const direction = input.keydownValidator(e);
196 | if (direction) {
197 | e.preventDefault();
198 | move(direction); // we need to add this function still
199 | }
200 | });
201 |
202 | renderedNode.addEventListener('blur', _e => {});
203 |
204 | renderedNode.addEventListener('focus', _e => {
205 | const i = interactiveData.indices.fruit[nextNode.data.fruit];
206 | const d = interactiveData.data[i];
207 | const target = interactiveData.indices.store[nextNode.data.store];
208 | const line_color = target ? ['none', '#000000'] : ['#000000', 'none'];
209 | document.getElementById('chart').innerHTML = '';
210 | plot('chart', {
211 | top: d[0],
212 | bottom: d[1],
213 | line_color
214 | });
215 | });
216 |
217 | // focus the new element, using the renderId for it
218 | input.focus(nextNode.renderId);
219 |
220 | // set state variables
221 | previous = current;
222 | current = nextNode.id;
223 |
224 | // delete the old element
225 | rendering.remove(previous);
226 | };
227 |
228 | const entryPoint =
229 | structure.nodes[Object.keys(structure.nodes)[0]].id || structure.nodes[Object.keys(structure.nodes)[0]].nodeId;
230 | const exitPoint = rendering.exitElement.id;
231 |
232 | const input = dataNavigator.input({
233 | structure,
234 | navigationRules: structure.navigationRules,
235 | entryPoint,
236 | exitPoint
237 | });
238 |
--------------------------------------------------------------------------------
/examples/force-graph.js:
--------------------------------------------------------------------------------
1 | // Copyright 2021-2024 Observable, Inc.
2 | // Released under the ISC license.
3 | // https://observablehq.com/@d3/force-directed-graph
4 | /*
5 | @frankelavsky's NOTE: This works beautifully out of the box for my purposes, but I did make a few changes
6 | Record of changes:
7 | - Added a limit to the simulation so that nodes don't fly out of view if without links
8 | - Hide root svg and circle elements from screen readers
9 | */
10 | export function ForceGraph(
11 | {
12 | nodes, // an iterable of node objects (typically [{id}, …])
13 | links // an iterable of link objects (typically [{source, target}, …])
14 | },
15 | {
16 | nodeId = d => d.id, // given d in nodes, returns a unique identifier (string)
17 | nodeGroup, // given d in nodes, returns an (ordinal) value for color
18 | nodeGroups, // an array of ordinal values representing the node groups
19 | nodeTitle, // given d in nodes, a title string
20 | nodeFill = 'currentColor', // node stroke fill (if not using a group color encoding)
21 | nodeStroke = '#fff', // node stroke color
22 | nodeStrokeWidth = 1.5, // node stroke width, in pixels
23 | nodeStrokeOpacity = 1, // node stroke opacity
24 | nodeRadius = 5, // node radius, in pixels
25 | nodeStrength,
26 | linkSource = ({ source }) => source, // given d in links, returns a node identifier string
27 | linkTarget = ({ target }) => target, // given d in links, returns a node identifier string
28 | linkStroke = '#999', // link stroke color
29 | linkStrokeOpacity = 0.6, // link stroke opacity
30 | linkStrokeWidth = 1.5, // given d in links, returns a stroke width in pixels
31 | linkStrokeLinecap = 'round', // link stroke linecap
32 | linkStrength,
33 | colors = d3.schemeTableau10, // an array of color strings, for the node groups
34 | width = 640, // outer width, in pixels
35 | height = 400, // outer height, in pixels
36 | invalidation, // when this promise resolves, stop the simulation
37 | description,
38 | hide
39 | } = {}
40 | ) {
41 | // Compute values.
42 | const N = d3.map(nodes, nodeId).map(intern);
43 | const R = typeof nodeRadius !== 'function' ? null : d3.map(nodes, nodeRadius);
44 | const LS = d3.map(links, linkSource).map(intern);
45 | const LT = d3.map(links, linkTarget).map(intern);
46 | if (nodeTitle === undefined) nodeTitle = (_, i) => N[i];
47 | const T = nodeTitle == null ? null : d3.map(nodes, nodeTitle);
48 | const G = nodeGroup == null ? null : d3.map(nodes, nodeGroup).map(intern);
49 | const W = typeof linkStrokeWidth !== 'function' ? null : d3.map(links, linkStrokeWidth);
50 | const L = typeof linkStroke !== 'function' ? null : d3.map(links, linkStroke);
51 |
52 | // Replace the input nodes and links with mutable objects for the simulation.
53 | nodes = d3.map(nodes, (_, i) => ({ id: N[i] }));
54 | links = d3.map(links, (_, i) => ({ source: LS[i], target: LT[i] }));
55 |
56 | description =
57 | (typeof description === 'function' ? description(nodes, links) : description) ||
58 | `Node-link graph. Contains ${nodes.length} node${nodes.length !== 1 ? 's' : ''} and ${links.length} link${
59 | links.length !== 1 ? 's' : ''
60 | }.`;
61 |
62 | // Compute default domains.
63 | if (G && nodeGroups === undefined) nodeGroups = d3.sort(G);
64 |
65 | // Construct the scales.
66 | const color = nodeGroup == null ? null : d3.scaleOrdinal(nodeGroups, colors);
67 |
68 | // Construct the forces.
69 | const forceNode = d3.forceManyBody();
70 | const forceLink = d3.forceLink(links).id(({ index: i }) => N[i]);
71 | if (nodeStrength !== undefined) forceNode.strength(nodeStrength);
72 | if (linkStrength !== undefined) forceLink.strength(linkStrength);
73 |
74 | const simulation = d3
75 | .forceSimulation(nodes)
76 | .force('link', forceLink)
77 | .force('charge', forceNode)
78 | .force('center', d3.forceCenter())
79 | .on('tick', ticked);
80 |
81 | const svg = d3
82 | .create('svg')
83 | .attr('width', width)
84 | .attr('height', height)
85 | .attr('viewBox', [-width / 2, -height / 2, width, height])
86 | .attr('role', hide ? 'presentation' : 'img')
87 | .attr('aria-label', hide ? null : description)
88 | .attr('style', 'max-width: 100%; height: auto; height: intrinsic;');
89 |
90 | const link = svg
91 | .append('g')
92 | .attr('stroke', typeof linkStroke !== 'function' ? linkStroke : null)
93 | .attr('stroke-opacity', linkStrokeOpacity)
94 | .attr('stroke-width', typeof linkStrokeWidth !== 'function' ? linkStrokeWidth : null)
95 | .attr('stroke-linecap', linkStrokeLinecap)
96 | .selectAll('line')
97 | .data(links)
98 | .join('line');
99 |
100 | const node = svg
101 | .append('g')
102 | .attr('fill', nodeFill)
103 | .attr('stroke', nodeStroke)
104 | .attr('stroke-opacity', nodeStrokeOpacity)
105 | .attr('stroke-width', nodeStrokeWidth)
106 | .selectAll('circle')
107 | .data(nodes)
108 | .join('circle')
109 | .attr('r', nodeRadius)
110 | .attr('role', 'presentation')
111 | .call(drag(simulation));
112 |
113 | if (W) link.attr('stroke-width', ({ index: i }) => W[i]);
114 | if (L) link.attr('stroke', ({ index: i }) => L[i]);
115 | if (G) node.attr('fill', ({ index: i }) => color(G[i]));
116 | if (R) node.attr('r', ({ index: i }) => R[i]);
117 | if (T) node.append('title').text(({ index: i }) => T[i]);
118 | if (invalidation != null) invalidation.then(() => simulation.stop());
119 |
120 | function intern(value) {
121 | return value !== null && typeof value === 'object' ? value.valueOf() : value;
122 | }
123 |
124 | function ticked() {
125 | link.attr('x1', d => d.source.x)
126 | .attr('y1', d => d.source.y)
127 | .attr('x2', d => d.target.x)
128 | .attr('y2', d => d.target.y);
129 |
130 | node.attr('cx', d => {
131 | // if nodes lack links, they will fly off screen
132 | // this keeps nodes within the svg
133 | let limit = width / 2 - nodeRadius;
134 | if (d.x > limit || d.x < -limit) {
135 | return limit;
136 | }
137 | return d.x;
138 | }).attr('cy', d => {
139 | // if nodes lack links, they will fly off screen
140 | // this keeps nodes within the svg
141 | let limit = height / 2 - nodeRadius;
142 | if (d.y > limit || d.y < -limit) {
143 | return limit;
144 | }
145 | return d.y;
146 | });
147 | }
148 |
149 | function drag(simulation) {
150 | function dragstarted(event) {
151 | if (!event.active) simulation.alphaTarget(0.3).restart();
152 | event.subject.fx = event.subject.x;
153 | event.subject.fy = event.subject.y;
154 | }
155 |
156 | function dragged(event) {
157 | event.subject.fx = event.x;
158 | event.subject.fy = event.y;
159 | }
160 |
161 | function dragended(event) {
162 | if (!event.active) simulation.alphaTarget(0);
163 | event.subject.fx = null;
164 | event.subject.fy = null;
165 | }
166 |
167 | return d3.drag().on('start', dragstarted).on('drag', dragged).on('end', dragended);
168 | }
169 |
170 | return Object.assign(svg.node(), { scales: { color } });
171 | }
172 |
--------------------------------------------------------------------------------
/examples/vega-lite-app.js:
--------------------------------------------------------------------------------
1 | import { default as dataNavigator } from '../src/index.ts';
2 | import { describeNode } from '../src/utilities.ts';
3 |
4 | let view;
5 | let spec;
6 | let dn;
7 | let entered;
8 | let current;
9 | let previous;
10 | const getCurrent = () => current;
11 | const getPrevious = () => previous;
12 | const groupInclusionCriteria = (item, _i, _spec) => {
13 | return item.marktype && !(item.marktype === 'text'); // item.marktype !== 'group' && item.marktype !== 'text'
14 | };
15 | const itemInclusionCriteria = (_item, _i, group, _spec) => {
16 | return !(group.role === 'axis' || group.role === 'legend'); // true
17 | };
18 | const datumInclusionCriteria = (_key, _value, _d, _level, _spec) => {
19 | return true;
20 | };
21 | const nodeDescriber = (d, item, level) => {
22 | if (Object.keys(d).length) {
23 | return describeNode(d, {});
24 | } else {
25 | d.role = item.role;
26 | if (item.role === 'axis') {
27 | const ticks = item.items[0].items[0].items;
28 | const type =
29 | item.items[0].datum.scale === 'yscale' ? 'Y ' : item.items[0].datum.scale === 'xscale' ? 'X ' : '';
30 | return `${type}Axis. Values range from ${ticks[0].datum.label} to ${ticks[ticks.length - 1].datum.label}.`;
31 | } else if (item.role === 'mark') {
32 | return `${item.items.length} navigable data elements. Group. Enter using Enter Key.`;
33 | } else if (item.role === 'legend') {
34 | const labels = item.items[0].items[0].items[0].items[0].items;
35 | return `Legend: ${spec.legends[0].title}. Showing values from ${
36 | labels[0].items[1].items[0].datum.label
37 | } to ${labels[labels.length - 1].items[1].items[0].datum.label}.`;
38 | } else {
39 | return `${level}.`;
40 | }
41 | }
42 | };
43 |
44 | const initiateLifecycle = nextNode => {
45 | const node = dn.rendering.render({
46 | renderId: nextNode.renderId,
47 | datum: nextNode
48 | });
49 | node.addEventListener('keydown', e => {
50 | // myFunction(e) // could run whatever here, of course
51 | const direction = dn.input.keydownValidator(e);
52 | if (direction) {
53 | e.preventDefault();
54 | move(direction);
55 | }
56 | });
57 | node.addEventListener('blur', () => {
58 | entered = false;
59 | });
60 | // showTooltip(nextNode)
61 | dn.input.focus(nextNode.renderId); // actually focuses the element
62 | entered = true;
63 | previous = current;
64 | current = nextNode.id;
65 | if (previous) {
66 | dn.rendering.remove(dn.structure.nodes[previous].renderId);
67 | }
68 | };
69 |
70 | const enter = () => {
71 | const nextNode = dn.input.enter();
72 | if (nextNode) {
73 | entered = true;
74 | initiateLifecycle(nextNode);
75 | }
76 | };
77 |
78 | const move = direction => {
79 | const nextNode = dn.input.move(current, direction); // .moveTo does the same thing but only uses NodeId
80 | if (nextNode) {
81 | initiateLifecycle(nextNode);
82 | }
83 | };
84 |
85 | const exit = () => {
86 | entered = false;
87 | dn.rendering.exitElement.style.display = 'block';
88 | dn.input.focus(dn.rendering.exitElement.id); // actually focuses the element
89 | previous = current;
90 | current = null;
91 | dn.rendering.remove(previous);
92 | };
93 |
94 | fetch('https://vega.github.io/vega/examples/scatter-plot.vg.json')
95 | // fetch('https://vega.github.io/vega/examples/bar-chart.vg.json')
96 | .then(res => {
97 | return res.json();
98 | })
99 | .then(specification => {
100 | spec = specification;
101 | return render(specification);
102 | })
103 | .then(v => {
104 | const structure = dataNavigator.structure({
105 | dataType: 'vega-lite',
106 | vegaLiteView: v,
107 | vegaLiteSpec: spec,
108 | groupInclusionCriteria,
109 | itemInclusionCriteria,
110 | datumInclusionCriteria,
111 | keyRenamingHash: {},
112 | nodeDescriber,
113 | getCurrent,
114 | getPrevious,
115 | exitFunction: exit
116 | });
117 |
118 | const rendering = dataNavigator.rendering({
119 | elementData: structure.elementData,
120 | suffixId: 'data-navigator-schema',
121 | root: {
122 | id: 'view',
123 | cssClass: '',
124 | width: '100%',
125 | height: 0
126 | },
127 | entryButton: {
128 | include: true,
129 | callbacks: {
130 | click: () => {
131 | enter();
132 | }
133 | }
134 | },
135 | exitElement: {
136 | include: true
137 | }
138 | });
139 |
140 | // create data navigator
141 | rendering.initialize();
142 | const input = dataNavigator.input({
143 | structure: {
144 | nodes: structure.nodes,
145 | edges: structure.edges
146 | },
147 | navigationRules: structure.navigationRules,
148 | entryPoint: Object.keys(structure.nodes)[0],
149 | exitPoint: rendering.exitElement.id
150 | });
151 |
152 | dn = {
153 | structure,
154 | input,
155 | rendering
156 | };
157 | // window.dn = dn
158 | return dn;
159 | })
160 | .catch(err => console.error(err));
161 |
162 | const render = spec => {
163 | view = new vega.View(vega.parse(spec), {
164 | renderer: 'canvas', // renderer (canvas or svg)
165 | container: '#view', // parent DOM container
166 | hover: true // enable hover processing
167 | });
168 | return view.runAsync();
169 | };
170 |
171 | const touchHandler = new Hammer(document.body, {});
172 | touchHandler.get('pinch').set({ enable: false });
173 | touchHandler.get('rotate').set({ enable: false });
174 | touchHandler.get('pan').set({ enable: false });
175 | touchHandler.get('swipe').set({ direction: Hammer.DIRECTION_ALL, velocity: 0.2 });
176 |
177 | touchHandler.on('press', ev => {
178 | // dn.enter()
179 | });
180 | touchHandler.on('pressup', ev => {
181 | dn.enter();
182 | });
183 | touchHandler.on('swipe', ev => {
184 | const larger = Math.abs(ev.deltaX) > Math.abs(ev.deltaY) ? 'X' : 'Y';
185 | // const smaller = ev.deltaX <= ev.deltaY ? ev.deltaX : ev.deltaY
186 | const ratio =
187 | (Math.abs(ev['delta' + larger]) + 0.000000001) /
188 | (Math.abs(ev['delta' + (larger === 'X' ? 'Y' : 'X')]) + 0.000000001);
189 | const left = ev.deltaX < 0;
190 | const right = ev.deltaX > 0;
191 | const up = ev.deltaY < 0;
192 | const down = ev.deltaY > 0;
193 | const direction =
194 | ratio > 0.99 && ratio <= 2
195 | ? right && up
196 | ? 'forward'
197 | : right && down
198 | ? 'child'
199 | : left && down
200 | ? 'backward'
201 | : left && up
202 | ? 'parent'
203 | : null
204 | : right && larger === 'X'
205 | ? 'right'
206 | : down && larger === 'Y'
207 | ? 'down'
208 | : left && larger === 'X'
209 | ? 'left'
210 | : up && larger === 'Y'
211 | ? 'up'
212 | : null;
213 | if (dn.getCurrentFocus() && direction) {
214 | dn.move(direction);
215 | }
216 | });
217 |
--------------------------------------------------------------------------------
/src/data-navigator.ts:
--------------------------------------------------------------------------------
1 | export type StructureOptions = {
2 | data: GenericDataset;
3 | idKey: DynamicNodeIdKey;
4 | renderIdKey?: DynamicRenderIdKey;
5 | dimensions?: DimensionOptions;
6 | genericEdges?: EdgeOptions;
7 | useDirectedEdges?: boolean;
8 | dataType?: DataType;
9 | addIds?: boolean;
10 | keysForIdGeneration?: KeyList;
11 | navigationRules?: NavigationRules;
12 | };
13 |
14 | export type InputOptions = {
15 | structure: Structure;
16 | navigationRules: NavigationRules;
17 | entryPoint?: NodeId;
18 | exitPoint?: RenderId;
19 | };
20 |
21 | export type RenderingOptions = {
22 | elementData: ElementData | Nodes;
23 | suffixId: string;
24 | root: RootObject;
25 | defaults?: RenderObject;
26 | entryButton?: EntryObject;
27 | exitElement?: ExitObject;
28 | };
29 |
30 | export type DimensionOptions = {
31 | values: DimensionList;
32 | parentOptions?: {
33 | level1Options?: {
34 | order: AddOrReferenceNodeList;
35 | behavior?: Level1Behavior;
36 | navigationRules?: DimensionNavigationRules;
37 | };
38 | addLevel0?: NodeObject;
39 | };
40 | adjustDimensions?: AdjustingFunction;
41 | };
42 |
43 | export type Structure = {
44 | nodes: Nodes;
45 | edges: Edges;
46 | dimensions?: Dimensions;
47 | navigationRules?: NavigationRules;
48 | elementData?: ElementData;
49 | };
50 |
51 | export type Nodes = Record;
52 | export type Edges = Record;
53 | export type Dimensions = Record;
54 | export type NavigationRules = Record;
55 | export type ElementData = Record;
56 | export type DimensionDivisions = Record;
57 |
58 | export type AddOrReferenceNodeList = Array;
59 | export type EdgeList = Array;
60 | export type GenericDataset = Array;
61 | export type NavigationList = Array;
62 | export type DimensionNavigationPair = [NavId, NavId];
63 | export type NumericalExtentsPair = [number, number];
64 | export type DimensionList = Array;
65 | export type EdgeOptions = Array;
66 | export type KeyList = Array;
67 |
68 | export type Semantics = ((RenderObject?, DatumObject?) => SemanticsObject) | SemanticsObject;
69 | export type SpatialProperties = ((RenderObject?, DatumObject?) => SpatialObject) | SpatialObject;
70 | export type Attributes = ((RenderObject?, DatumObject?) => AttributesObject) | AttributesObject;
71 |
72 | export type NodeObject = {
73 | id: NodeId;
74 | edges: EdgeList;
75 | renderId?: RenderId;
76 | renderingStrategy?: RenderingStrategy;
77 | derivedNode?: DerivedNode;
78 | dimensionLevel?: DimensionLevel;
79 | [key: string | number]: any; // NodeObjects can be lazily used as generic objects (like ElementObjects) too
80 | };
81 |
82 | export type EdgeObject = {
83 | source: (() => NodeId) | NodeId;
84 | target: (() => NodeId) | NodeId;
85 | navigationRules: NavigationList;
86 | edgeId?: EdgeId;
87 | };
88 |
89 | export type EdgeDatum = {
90 | edgeId: EdgeId;
91 | edge: EdgeObject;
92 | conditional?: ConditionalFunction;
93 | };
94 |
95 | // output
96 | export type DimensionObject = {
97 | nodeId: NodeId;
98 | dimensionKey: DimensionKey;
99 | divisions: DimensionDivisions;
100 | operations: {
101 | compressSparseDivisions: boolean; // if no division more than 1 child, create 1 division with all children, runs after filtering and sorting
102 | sortFunction?: SortingFunction; // by default sorts numerical in ascending, does not sort categorical
103 | };
104 | behavior?: DimensionBehavior;
105 | navigationRules?: DimensionNavigationRules;
106 | type?: DimensionType;
107 | numericalExtents?: NumericalExtentsPair;
108 | subdivisions?: NumericallySubdivide;
109 | divisionOptions?: DivisionOptions;
110 | };
111 |
112 | // input
113 | export type DimensionDatum = {
114 | dimensionKey: DimensionKey;
115 | behavior?: DimensionBehavior;
116 | navigationRules?: DimensionNavigationRules;
117 | type?: DimensionType;
118 | operations?: DimensionOperations;
119 | nodeId?: DynamicDimensionId;
120 | renderId?: DynamicDimensionRenderId;
121 | renderingStrategy?: RenderingStrategy;
122 | divisionOptions?: DivisionOptions;
123 | };
124 |
125 | export type DimensionNavigationRules = {
126 | sibling_sibling: DimensionNavigationPair;
127 | parent_child: DimensionNavigationPair;
128 | };
129 |
130 | export type DivisionOptions = {
131 | sortFunction?: SortingFunction; // by default does not sort
132 | divisionNodeIds?: (dimensionKey: DimensionKey, keyValue: any, i: number) => string;
133 | divisionRenderIds?: (dimensionKey: DimensionKey, keyValue: any, i: number) => string;
134 | renderingStrategy?: RenderingStrategy;
135 | };
136 |
137 | export type DimensionOperations = {
138 | filterFunction?: FilteringFunction;
139 | sortFunction?: SortingFunction; // by default sorts numerical in ascending, does not sort categorical
140 | createNumericalSubdivisions?: NumericallySubdivide; // (if not set, defaults to 1)
141 | compressSparseDivisions?: boolean; // if no division more than 1 child, create 1 division with all children, runs after filtering and sorting
142 | };
143 |
144 | export type DivisionObject = {
145 | id: NodeId;
146 | values: Nodes;
147 | sortFunction?: SortingFunction; // by default does not sort
148 | };
149 |
150 | export type NavObject = {
151 | direction: Direction;
152 | key?: string;
153 | };
154 |
155 | export type RenderObject = {
156 | cssClass?: DynamicString;
157 | spatialProperties?: SpatialProperties;
158 | semantics?: Semantics;
159 | parentSemantics?: Semantics;
160 | existingElement?: ExistingElement;
161 | showText?: boolean;
162 | };
163 |
164 | export type RootObject = {
165 | id: string;
166 | cssClass?: string;
167 | description?: string;
168 | width?: string | number;
169 | height?: string | number;
170 | };
171 |
172 | export type EntryObject = {
173 | include: boolean;
174 | callbacks?: EntryCallbacks;
175 | };
176 |
177 | export type ExitObject = {
178 | include: boolean;
179 | callbacks?: ExitCallbacks;
180 | };
181 |
182 | export type SemanticsObject = {
183 | label?: DynamicString;
184 | elementType?: DynamicString;
185 | role?: DynamicString;
186 | attributes?: Attributes;
187 | };
188 |
189 | export type SpatialObject = {
190 | x?: DynamicNumber;
191 | y?: DynamicNumber;
192 | width?: DynamicNumber;
193 | height?: DynamicNumber;
194 | path?: DynamicString;
195 | };
196 |
197 | export type DimensionBehavior = {
198 | extents: ExtentType;
199 | customBridgePrevious?: NodeId;
200 | customBridgePost?: NodeId;
201 | childmostNavigation?: ChildmostNavigationStrategy;
202 | childmostMatching?: ChildmostMatchingStrategy;
203 | };
204 |
205 | export type Level1Behavior = {
206 | extents: Level0ExtentType;
207 | customBridgePrevious?: NodeId;
208 | customBridgePost?: NodeId;
209 | };
210 |
211 | export type DescriptionOptions = {
212 | omitKeyNames?: boolean;
213 | semanticLabel?: string;
214 | };
215 |
216 | export type ExistingElement = {
217 | useForSpatialProperties: boolean;
218 | spatialProperties?: SpatialProperties;
219 | };
220 |
221 | export type EntryCallbacks = {
222 | focus?: Function;
223 | click?: Function;
224 | };
225 |
226 | export type ExitCallbacks = {
227 | focus?: Function;
228 | blur?: Function;
229 | };
230 |
231 | export type DatumObject = {
232 | [key: string | number]: any;
233 | };
234 |
235 | export type AttributesObject = {
236 | [key: string]: string;
237 | };
238 |
239 | export type DynamicNumber = ((r?: RenderObject, d?: DatumObject) => number) | number;
240 |
241 | export type DynamicString = ((r?: RenderObject, d?: DatumObject) => string) | string;
242 |
243 | export type DynamicNodeId = ((d?: DatumObject, dim?: DimensionDatum) => NodeId) | NodeId;
244 |
245 | export type DynamicRenderId = ((d?: DatumObject) => RenderId) | RenderId;
246 |
247 | export type DynamicNodeIdKey = ((d?: DatumObject) => string) | string;
248 |
249 | export type DynamicRenderIdKey = ((d?: DatumObject) => string) | string;
250 |
251 | export type DynamicDimensionId = ((d?: DimensionDatum, a?: GenericDataset) => NodeId) | NodeId;
252 |
253 | export type DynamicDimensionRenderId = ((d?: DimensionDatum, a?: GenericDataset) => RenderId) | RenderId;
254 |
255 | export type NumericallySubdivide = ((d?: DimensionKey, n?: Nodes) => number) | number;
256 |
257 | export type ChildmostMatchingStrategy = (
258 | index?: number,
259 | currentDivisionChild?: DatumObject,
260 | currentDivision?: DivisionObject,
261 | nextDivision?: DivisionObject
262 | ) => DatumObject | undefined;
263 |
264 | export type AdjustingFunction = (d: Dimensions) => Dimensions;
265 |
266 | export type SortingFunction = (a: DatumObject, b: DatumObject, c?: any) => number;
267 |
268 | export type FilteringFunction = (a: DatumObject, b?: any) => boolean;
269 |
270 | export type ConditionalFunction = (n: NodeObject, d: EdgeDatum) => boolean;
271 |
272 | export type NodeId = string;
273 |
274 | export type EdgeId = string;
275 |
276 | export type RenderId = string;
277 |
278 | export type NavId = string;
279 |
280 | export type DimensionId = string;
281 |
282 | export type DimensionKey = string;
283 |
284 | export type NodeToAddOrReference = NodeObject | NodeId;
285 |
286 | export type Direction = 'target' | 'source';
287 |
288 | export type RenderingStrategy = 'outlineEach' | 'convexHull' | 'singleSquare' | 'custom'; // this has yet to be implemented!
289 |
290 | export type DimensionType = 'numerical' | 'categorical';
291 |
292 | export type ExtentType = 'circular' | 'terminal' | 'bridgedCousins' | 'bridgedCustom';
293 |
294 | export type ChildmostNavigationStrategy = 'within' | 'across';
295 |
296 | export type Level0ExtentType = 'circular' | 'terminal' | 'bridgedCustom';
297 |
298 | export type DataType = 'vega-lite' | 'vl' | 'Vega-Lite' | 'generic' | 'default';
299 |
300 | export type DimensionLevel = 0 | 1 | 2 | 3;
301 |
302 | export type DerivedNode = string;
303 |
--------------------------------------------------------------------------------
/vis-demo.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Data Navigator IEEE VIS Lemonstration
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
42 |
43 |
129 |
130 |
131 |
132 | Data Navigator IEEE VIS "Lemonstration" 🍋
133 |
134 |
135 |
136 |
137 |
138 |
139 | Data Navigator is a project that allows all kinds of assistive technologies and input modalities
140 | to navigate data structures, even novel, fabricated fruit-based interfaces!
141 |
142 |
Instructions
143 |
Touch the foil and tap the fruits to move around!
144 |
145 |
146 |
147 | Fruit
148 | Expected output
149 |
150 |
151 |
152 |
153 | L lemon
154 | Move left
155 |
156 |
157 | R lemon
158 | Move right
159 |
160 |
161 | U lemon
162 | Move up
163 |
164 |
165 | D lemon
166 | Move down
167 |
168 |
169 | Lime
170 | Drill down (to children)
171 |
172 |
173 | Mandarin
174 | Drill up (towards x-axis)
175 |
176 |
177 | Apple
178 | Drill up (towards legend)
179 |
180 |
181 |
182 |
183 |
184 |
185 |
186 |
187 |
192 |
193 |
194 |
195 |
196 |
197 |
198 | Check out our paper talk on Data Navigator this Thursday in 105
199 | (And read our paper to learn more!)
200 |
201 | @article{2023-elavsky-data-navigator,
202 | title = {{Data Navigator}: An Accessibility-Centered Data Navigation Toolkit},
203 | publisher = {{IEEE}},
204 | author = {Frank Elavsky and Lucas Nadolskis and Dominik Moritz},
205 | journal = {{IEEE} Transactions on Visualization and Computer Graphics},
206 | year = {2023},
207 | url = {http://dig.cmu.edu/data-navigator/}
208 | }
209 |
210 |
211 |
212 |
213 |
214 |
215 |
240 |
241 |
242 |
--------------------------------------------------------------------------------
/testing.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Data Navigator demo
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
42 |
43 |
76 |
77 |
78 |
79 |
80 |
Instructions
81 |
82 | Technically this is just a test environment for different structures as I am creating the
83 | dimensions API for Data Navigator. But that being said, if someone comes across this
84 | page, here are the keyboard instructions:
85 |
86 |
87 |
88 |
89 |
90 | Command
91 | Expected input
92 |
93 |
94 |
95 |
96 | Enter the visualization
97 | Activate the "Enter navigation area" button.
98 |
99 |
100 | Exit the visualization
101 | ESC key.
102 |
103 |
104 | Left : Backward along category or dimension
105 | ← (left arrow key).
106 |
107 |
108 | Right : Forward along category or dimension
109 | → (right arrow key).
110 |
111 |
112 | Up : Backward along sorted metric
113 | ↑ (up arrow key).
114 |
115 |
116 | Down : Forward along sorted metric
117 | ↓ (down arrow key).
118 |
119 |
120 | Drill down to child
121 | ENTER key.
122 |
123 |
124 | Drill up to parent-cat egory node
125 | W key.
126 |
127 |
128 | Drill up to parent-num eric node
129 | J key.
130 |
131 |
132 |
133 |
134 |
135 | The dimensions API does a few things to the navigation experience by default, notably both are about
136 | the creation of "divisions" of that dimension: if a metric/numeric value is used, then it simply
137 | becomes a sorted list of all values in the dataset in ascending order. The api can be used to
138 | numericallySubdivide metrics. For categorical dimensions, they will be nested by
139 | default. However, if a category contains all unique values (see last 2 examples), then the dimension
140 | can be compressed using compressSparseDivisions.
141 |
142 |
Description of visualizations
143 | All of these visualizations are visual representations of the structure created from different datasets
144 | that have been sent into Data Navigator's
dimensions API. I'm playing with the declarative
145 | props for the API with these different visualizations as well as testing to see if the structure looks
146 | as expected (and the API performs as expected).
147 |
148 |
149 |
Simple test (colors = dimension level)
150 |
153 |
154 |
155 |
156 |
157 |
Testing added data and generic edges -> rules generation
158 |
161 |
162 |
163 |
164 |
165 |
Testing larger data (a stacked bar chart)
166 |
169 |
170 |
171 |
172 |
173 |
Testing maintaining stacked bar chart child nav direction
174 |
175 | In the previous example, navigation seems counter-intuitive at the childmost level: left-right
176 | navigates between months at the division level, but drilling into a month (since these are children
177 | of the date's division) also maintain left-right. But at the childmost level, left-right moves
178 | across groups instead. This fixes the lack of intuitive navigation for something like a stacked bar
179 | chart: left-right always moves across dates.
180 |
181 |
184 |
185 |
186 |
187 |
188 |
Testing sparse categorical data (one child per parent)
189 |
192 |
193 |
194 |
195 |
196 |
Testing compressing sparse example into a list
197 |
200 |
201 |
202 |
203 |
204 |
Testing a manually-built list without a parent node
205 |
208 |
209 |
210 |
211 |
212 |
213 |
214 |
215 |
216 |
--------------------------------------------------------------------------------
/src/rendering.ts:
--------------------------------------------------------------------------------
1 | import { NodeElementDefaults } from './consts';
2 | import type { RenderingOptions, NodeObject } from './data-navigator';
3 |
4 | export default (options: RenderingOptions) => {
5 | const setActiveDescendant = e => {
6 | renderer.wrapper.setAttribute('aria-activedescendant', e.srcElement.id);
7 | };
8 | const removeActiveDescendant = () => {
9 | renderer.wrapper.setAttribute('aria-activedescendant', '');
10 | };
11 | let renderer = {} as any;
12 | let initialized = false;
13 | let defaults = {
14 | cssClass: NodeElementDefaults.cssClass,
15 | spatialProperties: { ...NodeElementDefaults.spatialProperties },
16 | semantics: { ...NodeElementDefaults.semantics },
17 | parentSemantics: { ...NodeElementDefaults.parentSemantics },
18 | existingElement: { ...NodeElementDefaults.existingElement }
19 | };
20 | if (options.defaults) {
21 | defaults.cssClass = options.defaults.cssClass || defaults.cssClass;
22 | defaults.spatialProperties = options.defaults.spatialProperties
23 | ? { ...defaults.spatialProperties, ...options.defaults.spatialProperties }
24 | : defaults.spatialProperties;
25 | defaults.semantics = options.defaults.semantics
26 | ? { ...defaults.semantics, ...options.defaults.semantics }
27 | : defaults.semantics;
28 | defaults.parentSemantics = options.defaults.parentSemantics
29 | ? { ...defaults.parentSemantics, ...options.defaults.parentSemantics }
30 | : defaults.parentSemantics;
31 | defaults.existingElement = options.defaults.existingElement
32 | ? { ...defaults.existingElement, ...options.defaults.existingElement }
33 | : defaults.existingElement;
34 | }
35 | renderer.initialize = () => {
36 | if (initialized) {
37 | console.error(
38 | `The renderer wrapper has already been initialized successfully, RenderingOptions.suffixId is: ${options.suffixId}. No further action was taken.`
39 | );
40 | return;
41 | }
42 | if (options.root && document.getElementById(options.root.id)) {
43 | renderer.root = document.getElementById(options.root.id);
44 | } else {
45 | console.error(
46 | 'No root element found, cannot build: RenderingOptions.root.id must reference an existing DOM element in order to render children.'
47 | );
48 | return;
49 | }
50 | renderer.root.style.position = 'relative';
51 | renderer.root.classList.add('dn-root');
52 | if (!options.suffixId) {
53 | console.error('No suffix id found: options.suffixId must be specified.');
54 | return;
55 | }
56 | // build renderer.wrapper
57 | renderer.wrapper = document.createElement('div');
58 | renderer.wrapper.id = 'dn-wrapper-' + options.suffixId;
59 | renderer.wrapper.setAttribute('role', 'application');
60 | renderer.wrapper.setAttribute('aria-label', options.root.description || 'Data navigation structure');
61 | renderer.wrapper.setAttribute('aria-activedescendant', '');
62 | renderer.wrapper.classList.add('dn-wrapper');
63 | renderer.wrapper.style.width = options.root && options.root.width ? options.root.width : '100%';
64 | if (options.root && options.root.height) {
65 | renderer.wrapper.style.height = options.root.height;
66 | }
67 |
68 | // TO-DO: build interaction instructions/menu
69 |
70 | // build entry button
71 | if (options.entryButton && options.entryButton.include) {
72 | renderer.entryButton = document.createElement('button');
73 | renderer.entryButton.id = 'dn-entry-button-' + options.suffixId;
74 | renderer.entryButton.classList.add('dn-entry-button');
75 | renderer.entryButton.innerText = `Enter navigation area`;
76 | if (options.entryButton.callbacks && options.entryButton.callbacks.click) {
77 | renderer.entryButton.addEventListener('click', options.entryButton.callbacks.click);
78 | }
79 | if (options.entryButton.callbacks && options.entryButton.callbacks.focus) {
80 | renderer.entryButton.addEventListener('focus', options.entryButton.callbacks.focus);
81 | }
82 | renderer.wrapper.appendChild(renderer.entryButton);
83 | }
84 |
85 | renderer.root.appendChild(renderer.wrapper);
86 |
87 | if (options.exitElement?.include) {
88 | renderer.exitElement = document.createElement('div');
89 | renderer.exitElement.id = 'dn-exit-' + options.suffixId;
90 | renderer.exitElement.classList.add('dn-exit-position');
91 | renderer.exitElement.innerText = `End of data structure.`;
92 | renderer.exitElement.setAttribute('aria-label', `End of data structure.`);
93 | renderer.exitElement.setAttribute('role', 'note');
94 | renderer.exitElement.setAttribute('tabindex', '-1');
95 | renderer.exitElement.style.display = 'none';
96 | renderer.exitElement.addEventListener('focus', e => {
97 | renderer.exitElement.style.display = 'block';
98 | renderer.clearStructure();
99 | if (options.exitElement?.callbacks?.focus) {
100 | options.exitElement.callbacks.focus(e);
101 | }
102 | });
103 | renderer.exitElement.addEventListener('blur', e => {
104 | renderer.exitElement.style.display = 'none';
105 | if (options.exitElement?.callbacks?.blur) {
106 | options.exitElement.callbacks.blur(e);
107 | }
108 | });
109 |
110 | renderer.root.appendChild(renderer.exitElement);
111 | }
112 | initialized = true;
113 | return renderer.root;
114 | };
115 | renderer.render = (nodeData: NodeObject) => {
116 | const id = nodeData.renderId + '';
117 | let d = options.elementData[id];
118 | if (!d) {
119 | console.warn(`Render data not found with renderId: ${id}. Failed to render.`);
120 | return;
121 | }
122 |
123 | if (!initialized) {
124 | console.error('render() was called before initialize(), renderer must be initialized first.');
125 | return;
126 | }
127 | let useExisting = false;
128 | let existingSpatialProperties = {};
129 | const resolveProp = (prop, subprop?, checkExisting?) => {
130 | const p1 = d[prop] || defaults[prop];
131 | const s1 = !(checkExisting && useExisting) ? p1[subprop] : existingSpatialProperties[subprop];
132 | const s2 = defaults[prop][subprop];
133 | return typeof p1 === 'function'
134 | ? p1(d, nodeData.datum)
135 | : typeof s1 === 'function'
136 | ? s1(d, nodeData.datum)
137 | : s1 || s2 || (!subprop ? p1 : undefined);
138 | };
139 | useExisting = resolveProp('existingElement', 'useForSpatialProperties');
140 | existingSpatialProperties = resolveProp('existingElement', 'spatialProperties');
141 | const width = parseFloat(resolveProp('spatialProperties', 'width', true) || 0);
142 | const height = parseFloat(resolveProp('spatialProperties', 'height', true) || 0);
143 | const x = parseFloat(resolveProp('spatialProperties', 'x', true) || 0);
144 | const y = parseFloat(resolveProp('spatialProperties', 'y', true) || 0);
145 | const node = document.createElement(resolveProp('parentSemantics', 'elementType'));
146 | const wrapperAttrs = resolveProp('parentSemantics', 'attributes');
147 | if (typeof wrapperAttrs === 'object') {
148 | Object.keys(wrapperAttrs).forEach(wrapperAttr => {
149 | node.setAttribute(wrapperAttr, wrapperAttrs[wrapperAttr]);
150 | });
151 | }
152 | node.setAttribute('role', resolveProp('parentSemantics', 'role'));
153 | node.id = id;
154 | node.classList.add('dn-node');
155 | node.classList.add(resolveProp('cssClass'));
156 | node.style.width = width + 'px';
157 | node.style.height = height + 'px';
158 | node.style.left = x + 'px';
159 | node.style.top = y + 'px';
160 | node.setAttribute('tabindex', '0');
161 | node.addEventListener('focus', setActiveDescendant);
162 | node.addEventListener('blur', removeActiveDescendant);
163 |
164 | const nodeText = document.createElement(resolveProp('semantics', 'elementType'));
165 | const attributes = resolveProp('semantics', 'attributes');
166 | if (typeof attributes === 'object') {
167 | Object.keys(attributes).forEach(attribute => {
168 | node.setAttribute(attribute, attributes[attribute]);
169 | });
170 | }
171 | nodeText.setAttribute('role', resolveProp('semantics', 'role'));
172 | nodeText.classList.add('dn-node-text');
173 | if (d.showText) {
174 | nodeText.innerText = d.semantics.label;
175 | }
176 | const label = resolveProp('semantics', 'label');
177 | if (!label) {
178 | console.error(
179 | 'Accessibility error: a label must be supplied to every rendered element using semantics.label.'
180 | );
181 | }
182 | nodeText.setAttribute('aria-label', label);
183 |
184 | node.appendChild(nodeText);
185 | const hasPath = resolveProp('spatialProperties', 'path');
186 | if (hasPath) {
187 | const totalWidth = width + x + 10;
188 | const totalHeight = height + y + 10;
189 | const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
190 | svg.setAttribute('width', totalWidth + '');
191 | svg.setAttribute('height', totalHeight + '');
192 | svg.setAttribute('viewBox', `0 0 ${totalWidth} ${totalHeight}`);
193 | svg.style.left = -x + 'px';
194 | svg.style.top = -y + 'px';
195 | svg.classList.add('dn-node-svg');
196 | svg.setAttribute('role', 'presentation');
197 | svg.setAttribute('focusable', 'false');
198 |
199 | const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
200 | path.setAttribute('d', hasPath);
201 | path.classList.add('dn-node-path');
202 | svg.appendChild(path);
203 | node.appendChild(svg);
204 | }
205 | renderer.wrapper.appendChild(node);
206 | return node;
207 | };
208 | renderer.remove = renderId => {
209 | const node = document.getElementById(renderId);
210 | if (renderer.wrapper.getAttribute('aria-activedescendant') === renderId) {
211 | renderer.wrapper.setAttribute('aria-activedescendant', '');
212 | }
213 | if (node) {
214 | node.removeEventListener('focus', setActiveDescendant);
215 | node.removeEventListener('blur', removeActiveDescendant);
216 | node.remove();
217 | }
218 | };
219 | renderer.clearStructure = () => {
220 | [...renderer.wrapper.children].forEach(child => {
221 | if (!(renderer.entryButton && renderer.entryButton === child)) {
222 | renderer.remove(child.id);
223 | }
224 | });
225 | };
226 | return renderer;
227 | };
228 |
--------------------------------------------------------------------------------
/examples/docs.js:
--------------------------------------------------------------------------------
1 | import { default as dataNavigator } from '../src/index.ts';
2 | import { ForceGraph } from './force-graph.js';
3 | import { describeNode } from '../src/utilities.ts';
4 | import { plot } from './bokeh.js';
5 |
6 | let exit = {};
7 |
8 | const interactiveData = {
9 | data: [
10 | [
11 | [3, 2.75],
12 | [0, 0]
13 | ],
14 | [
15 | [3.75, 4],
16 | [3, 2.75]
17 | ]
18 | ],
19 | indices: {
20 | fruit: {
21 | apple: 0,
22 | banana: 1
23 | },
24 | store: {
25 | a: 0,
26 | b: 1
27 | }
28 | }
29 | };
30 |
31 | const convertToArray = (o, include, exclude) => {
32 | let x = [];
33 | if (include) {
34 | include.forEach(i => {
35 | let n = { id: i };
36 | x.push(n);
37 | });
38 | }
39 | Object.keys(o).forEach(k => {
40 | if (exclude) {
41 | let excluding = false;
42 | exclude.forEach(e => {
43 | if (k === e) {
44 | excluding = true;
45 | }
46 | });
47 | if (excluding) {
48 | return;
49 | }
50 | }
51 | x.push(o[k]);
52 | });
53 | return x;
54 | };
55 |
56 | const addRenderingProperties = (nodes, root, size, showWrapper, useFull) => {
57 | if (showWrapper) {
58 | Object.keys(nodes).forEach(k => {
59 | let node = nodes[k];
60 | // our rendering engine looks for a "renderId", we will just use our id
61 | // however, if you have an element that is already rendered (like SVG),
62 | // then you can use that id, and then use "node.existingElement" api
63 | if (!node.renderId) {
64 | node.renderId = node.id;
65 | }
66 | // our main job to make this navigation experience accessible, so
67 | // we need to have semantics, which means we need a descriptive label
68 |
69 | node.semantics = {
70 | label: describeNode(node.data, {})
71 | };
72 |
73 | node.spatialProperties = {
74 | x: -2,
75 | y: -2,
76 | width: size,
77 | height: size
78 | };
79 | });
80 | } else if (useFull) {
81 | Object.keys(nodes).forEach(k => {
82 | let node = nodes[k];
83 | if (!node.renderId) {
84 | node.renderId = node.id;
85 | }
86 | let label = '';
87 | node.existingElement = {
88 | useForSpatialProperties: true,
89 | spatialProperties: () => {
90 | let box = document
91 | .getElementById(root)
92 | .querySelector('#svg' + node.renderId)
93 | .getBBox();
94 | return {
95 | x: box.x + size / 2 - 0.91,
96 | y: box.y + size / 2 - 0.91,
97 | width: box.width,
98 | height: box.height
99 | };
100 | }
101 | };
102 | if (!node.derivedNode) {
103 | label = describeNode(node.data, {});
104 | } else {
105 | if (node.data.dimensionKey) {
106 | // dimension
107 | let count = 0;
108 | let divisions = Object.keys(node.data.divisions);
109 | if (divisions.length) {
110 | divisions.forEach(div => {
111 | count += Object.keys(node.data.divisions[div].values).length;
112 | });
113 | }
114 | label = `${node.derivedNode}.`;
115 | label +=
116 | divisions.length && count
117 | ? ` Contains ${divisions.length} division${
118 | divisions.length > 1 ? 's' : ''
119 | } which contain ${count} datapoint${count > 1 ? 's' : ''} total.`
120 | : ' Contains no child data points.';
121 | label += ` ${node.data.type} dimension.`;
122 | } else {
123 | // division
124 | label = `${node.derivedNode}: ${node.data[node.derivedNode]}. Contains ${
125 | Object.keys(node.data.values).length
126 | } child data point${Object.keys(node.data.values).length > 1 ? 's' : ''}. Division of ${
127 | node.derivedNode
128 | } dimension.`;
129 | }
130 | }
131 | node.semantics = {
132 | label
133 | };
134 | });
135 | } else {
136 | Object.keys(nodes).forEach(k => {
137 | let node = nodes[k];
138 | // our rendering engine looks for a "renderId", we will just use our id
139 | // however, if you have an element that is already rendered (like SVG),
140 | // then you can use that id, and then use "node.existingElement" api
141 | if (!node.renderId) {
142 | node.renderId = node.id;
143 | }
144 |
145 | // our main job to make this navigation experience accessible, so
146 | // we need to have semantics, which means we need a descriptive label
147 |
148 | node.semantics = {
149 | label: describeNode(node.data, {})
150 | };
151 | });
152 | }
153 | };
154 |
155 | const createRenderer = (structure, id, enter, interactivePlot) => {
156 | return dataNavigator.rendering({
157 | elementData: structure.nodes,
158 | defaults: {
159 | cssClass: !interactivePlot ? 'dn-test-class' : 'dn-manual-focus-node'
160 | },
161 | suffixId: 'data-navigator-schema-' + id,
162 | root: {
163 | id: id + '-wrapper',
164 | cssClass: '',
165 | width: '100%',
166 | height: 0
167 | },
168 | entryButton: {
169 | include: true,
170 | callbacks: {
171 | click: () => {
172 | enter();
173 | }
174 | }
175 | },
176 | exitElement: {
177 | include: true
178 | }
179 | });
180 | };
181 |
182 | const hideTooltip = (id, hideIndicator) => {
183 | document.getElementById(id + '-tooltip').classList.add('hidden');
184 | if (hideIndicator) {
185 | document.getElementById(id + '-focus-indicator').classList.add('hidden');
186 | }
187 | };
188 |
189 | const showTooltip = (d, id, size, coloredBy, showIndicator) => {
190 | const tooltip = document.getElementById(id + '-tooltip');
191 | tooltip.classList.remove('hidden');
192 | tooltip.innerText =
193 | d.semantics?.label ||
194 | `${d.id}${d.data?.[coloredBy] ? ', ' + d.data[coloredBy] : ''} (generic node, edges hidden).`;
195 | const bbox = tooltip.getBoundingClientRect();
196 | // const offset = bbox.width / 2;
197 | const yOffset = bbox.height / 2;
198 | tooltip.style.textAlign = 'left';
199 | tooltip.style.transform = `translate(${size + 1}px,${size / 2 - yOffset}px)`;
200 | if (showIndicator) {
201 | const svg = document.getElementById(id + '-wrapper').parentNode.parentNode.querySelector('svg');
202 | let indicator = document.getElementById(id + '-focus-indicator');
203 | if (!indicator) {
204 | indicator = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
205 | indicator.id = id + '-focus-indicator';
206 | indicator.setAttribute('class', 'dn-focus-indicator hidden');
207 | indicator.setAttribute('cx', 0);
208 | indicator.setAttribute('cy', 0);
209 | indicator.setAttribute('r', 6.5);
210 | indicator.setAttribute('fill', 'none');
211 | indicator.setAttribute('stroke', '#000');
212 | indicator.setAttribute('stroke-width', '2');
213 | svg.appendChild(indicator);
214 | }
215 | let target = svg.querySelector('#svg' + d.renderId);
216 | indicator.setAttribute('cx', target.getAttribute('cx'));
217 | indicator.setAttribute('cy', target.getAttribute('cy'));
218 | indicator.classList.remove('hidden');
219 | }
220 | };
221 |
222 | const initializeDataNavigator = (structure, rootId, entryPoint, size, colorBy, interactivePlot) => {
223 | let previous;
224 | let current;
225 |
226 | const enter = () => {
227 | const nextNode = input.enter();
228 | if (nextNode) {
229 | initiateLifecycle(nextNode);
230 | }
231 | };
232 |
233 | const move = direction => {
234 | const nextNode = input.move(current, direction);
235 | if (nextNode) {
236 | initiateLifecycle(nextNode);
237 | }
238 | };
239 |
240 | exit[rootId] = () => {
241 | rendering.exitElement.style.display = 'block';
242 | input.focus(rendering.exitElement.id);
243 | previous = current;
244 | current = null;
245 | rendering.remove(previous);
246 | };
247 |
248 | const rendering = createRenderer(structure, rootId, enter, interactivePlot);
249 | rendering.initialize();
250 | const input = dataNavigator.input({
251 | structure,
252 | navigationRules: structure.navigationRules,
253 | entryPoint,
254 | exitPoint: rendering.exitElement.id
255 | });
256 |
257 | const initiateLifecycle = nextNode => {
258 | // should we remove existing nodes?
259 |
260 | const renderedNode = rendering.render({
261 | renderId: nextNode.renderId,
262 | datum: nextNode
263 | });
264 | renderedNode.addEventListener('keydown', e => {
265 | const direction = input.keydownValidator(e);
266 | if (direction) {
267 | e.preventDefault();
268 | move(direction);
269 | }
270 | });
271 | renderedNode.addEventListener('blur', _e => {
272 | hideTooltip(rootId, true);
273 | });
274 | renderedNode.addEventListener('focus', _e => {
275 | showTooltip(nextNode, rootId, size, colorBy, true);
276 | if (interactivePlot) {
277 | const i = interactiveData.indices.fruit[nextNode.data.fruit];
278 | const d = interactiveData.data[i];
279 | const target = interactiveData.indices.store[nextNode.data.store];
280 | const line_color = target ? ['none', '#000000'] : ['#000000', 'none'];
281 | document.getElementById('wrappedIndicatorChart').innerHTML = '';
282 | plot('wrappedIndicatorChart', {
283 | top: d[0],
284 | bottom: d[1],
285 | line_color
286 | });
287 | }
288 | });
289 | input.focus(nextNode.renderId);
290 | previous = current;
291 | current = nextNode.id;
292 | rendering.remove(previous);
293 | };
294 | };
295 |
296 | const buildGraph = (
297 | structure,
298 | rootId,
299 | size,
300 | colorBy,
301 | entryPoint,
302 | inclusions,
303 | exclusions,
304 | disableRenderer,
305 | hideEdges,
306 | showWrapper,
307 | useFull
308 | ) => {
309 | addRenderingProperties(structure.nodes, rootId, size, showWrapper, useFull);
310 |
311 | let graph = ForceGraph(
312 | {
313 | nodes: convertToArray(structure.nodes, inclusions),
314 | links: convertToArray(structure.edges, [], exclusions)
315 | },
316 | {
317 | nodeId: d => d.id,
318 | nodeGroup: d => (colorBy === 'dimensionLevel' ? d.dimensionLevel : d.data?.[colorBy]),
319 | width: size,
320 | linkStrokeOpacity: !hideEdges ? 0.75 : 0,
321 | height: size
322 | }
323 | );
324 | document.getElementById(rootId).appendChild(graph);
325 | document
326 | .getElementById(rootId)
327 | .querySelectorAll('circle')
328 | .forEach(c => {
329 | c.id = 'svg' + c.__data__?.id;
330 | c.addEventListener('mousemove', e => {
331 | if (e.target?.__data__?.id) {
332 | let d = e.target.__data__;
333 | showTooltip(structure.nodes[d.id] || d, rootId, size, colorBy);
334 | }
335 | });
336 | c.addEventListener('mouseleave', () => {
337 | hideTooltip(rootId);
338 | });
339 | });
340 |
341 | if (!disableRenderer) {
342 | initializeDataNavigator(structure, rootId, entryPoint, size, colorBy);
343 | }
344 | };
345 |
346 | const data = [
347 | {
348 | fruit: 'apple',
349 | store: 'a',
350 | cost: 3
351 | },
352 | {
353 | fruit: 'banana',
354 | store: 'a',
355 | cost: 0.75
356 | },
357 | {
358 | fruit: 'apple',
359 | store: 'b',
360 | cost: 2.75
361 | },
362 | {
363 | fruit: 'banana',
364 | store: 'b',
365 | cost: 1.25
366 | }
367 | ];
368 | plot('slot1');
369 |
370 | let nodesOnlyStructure = {
371 | nodes: {
372 | _0: {
373 | id: '_0',
374 | data: {
375 | fruit: 'apple',
376 | store: 'a',
377 | cost: 3
378 | },
379 | edges: ['_0-_1']
380 | },
381 | _1: {
382 | id: '_1',
383 | data: {
384 | fruit: 'banana',
385 | store: 'a',
386 | cost: 0.75
387 | },
388 | edges: ['_0-_1', '_1-_2']
389 | },
390 | _2: {
391 | id: '_2',
392 | data: {
393 | fruit: 'apple',
394 | store: 'b',
395 | cost: 2.75
396 | },
397 | edges: ['_1-_2', '_2-_3']
398 | },
399 | _3: {
400 | id: '_3',
401 | data: {
402 | fruit: 'banana',
403 | store: 'b',
404 | cost: 1.25
405 | },
406 | edges: ['_2-_3']
407 | }
408 | },
409 | edges: {
410 | '_0-_1': {
411 | source: '_0',
412 | target: '_1'
413 | },
414 | '_1-_2': {
415 | source: '_1',
416 | target: '_2'
417 | },
418 | '_2-_3': {
419 | source: '_2',
420 | target: '_3'
421 | }
422 | },
423 | navigationRules: {
424 | left: { key: 'ArrowLeft', direction: 'source' },
425 | right: { key: 'ArrowRight', direction: 'target' },
426 | exit: { key: 'Escape', direction: 'target' }
427 | }
428 | };
429 | buildGraph(
430 | nodesOnlyStructure,
431 | 'nodesOnly',
432 | 300,
433 | 'dimensionLevel', // 'cat',
434 | nodesOnlyStructure.nodes[Object.keys(nodesOnlyStructure.nodes)[0]].id,
435 | [],
436 | [],
437 | true,
438 | true
439 | );
440 |
441 | buildGraph(
442 | nodesOnlyStructure,
443 | 'basicList',
444 | 300,
445 | 'dimensionLevel', // 'cat',
446 | nodesOnlyStructure.nodes[Object.keys(nodesOnlyStructure.nodes)[0]].id,
447 | [],
448 | [],
449 | true
450 | );
451 |
452 | Object.keys(nodesOnlyStructure.nodes).forEach(nodeId => {
453 | nodesOnlyStructure.nodes[nodeId].edges.push('any-exit');
454 | });
455 |
456 | nodesOnlyStructure.edges['any-exit'] = {
457 | source: (_d, c) => c,
458 | target: () => {
459 | exit['chart']();
460 | return '';
461 | },
462 | navigationRules: ['exit']
463 | };
464 |
465 | buildGraph(
466 | nodesOnlyStructure,
467 | 'listWithExit',
468 | 300,
469 | 'dimensionLevel', // 'cat',
470 | nodesOnlyStructure.nodes[Object.keys(nodesOnlyStructure.nodes)[0]].id,
471 | ['exit'],
472 | ['any-exit'],
473 | true
474 | );
475 |
476 | plot('chart');
477 |
478 | Object.keys(nodesOnlyStructure.edges).forEach(edgeId => {
479 | if (edgeId !== 'any-exit') {
480 | nodesOnlyStructure.edges[edgeId].navigationRules = ['left', 'right'];
481 | }
482 | });
483 | initializeDataNavigator(
484 | nodesOnlyStructure,
485 | 'chart',
486 | nodesOnlyStructure.nodes[Object.keys(nodesOnlyStructure.nodes)[0]].id,
487 | 300,
488 | 'dimensionLevel'
489 | );
490 |
491 | buildGraph(
492 | nodesOnlyStructure,
493 | 'enterExitOnly',
494 | 300,
495 | 'dimensionLevel', // 'cat',
496 | nodesOnlyStructure.nodes[Object.keys(nodesOnlyStructure.nodes)[0]].id,
497 | ['exit'],
498 | ['any-exit'],
499 | true
500 | );
501 |
502 | plot('focusIndicatorChart');
503 |
504 | const oneSpatialNode = {
505 | nodes: {
506 | _0: {
507 | id: '_0',
508 | data: {
509 | fruit: 'apple',
510 | store: 'a',
511 | cost: 3
512 | },
513 | edges: ['_0-_1', 'any-exit'],
514 | renderId: '_0',
515 | semantics: {
516 | label: 'fruit: apple. store: a. cost: 3. Data point.'
517 | }
518 | },
519 | _1: {
520 | id: '_1',
521 | data: {
522 | fruit: 'banana',
523 | store: 'a',
524 | cost: 0.75
525 | },
526 | edges: ['_0-_1', '_1-_2', 'any-exit'],
527 | renderId: '_1',
528 | semantics: {
529 | label: 'fruit: banana. store: a. cost: 0.75. Data point.'
530 | },
531 | spatialProperties: {
532 | height: 33.545448303222656,
533 | width: 110.3,
534 | x: 32,
535 | y: 103.7727279663086
536 | }
537 | },
538 | _2: {
539 | id: '_2',
540 | data: {
541 | fruit: 'apple',
542 | store: 'b',
543 | cost: 2.75
544 | },
545 | edges: ['_1-_2', '_2-_3', 'any-exit'],
546 | renderId: '_2',
547 | semantics: {
548 | label: 'fruit: apple. store: b. cost: 2.75. Data point.'
549 | }
550 | },
551 | _3: {
552 | id: '_3',
553 | data: {
554 | fruit: 'banana',
555 | store: 'b',
556 | cost: 1.25
557 | },
558 | edges: ['_2-_3', 'any-exit'],
559 | renderId: '_3',
560 | semantics: {
561 | label: 'fruit: banana. store: b. cost: 1.25. Data point.'
562 | }
563 | }
564 | },
565 | edges: {
566 | '_0-_1': {
567 | source: '_0',
568 | target: '_1',
569 | navigationRules: ['left', 'right']
570 | },
571 | '_1-_2': {
572 | source: '_1',
573 | target: '_2',
574 | navigationRules: ['left', 'right']
575 | },
576 | '_2-_3': {
577 | source: '_2',
578 | target: '_3',
579 | navigationRules: ['left', 'right']
580 | },
581 | 'any-exit': {
582 | source: (_d, c) => c,
583 | target: () => {
584 | exit['focusIndicatorChart']();
585 | return '';
586 | },
587 | navigationRules: ['exit']
588 | }
589 | },
590 | navigationRules: {
591 | left: {
592 | key: 'ArrowLeft',
593 | direction: 'source'
594 | },
595 | right: {
596 | key: 'ArrowRight',
597 | direction: 'target'
598 | },
599 | exit: {
600 | key: 'Escape',
601 | direction: 'target'
602 | }
603 | }
604 | };
605 |
606 | initializeDataNavigator(
607 | oneSpatialNode,
608 | 'focusIndicatorChart',
609 | oneSpatialNode.nodes[Object.keys(oneSpatialNode.nodes)[0]].id,
610 | 300,
611 | 'dimensionLevel'
612 | );
613 |
614 | buildGraph(
615 | oneSpatialNode,
616 | 'focusIndicator',
617 | 300,
618 | 'dimensionLevel', // 'cat',
619 | oneSpatialNode.nodes[Object.keys(oneSpatialNode.nodes)[0]].id,
620 | ['exit'],
621 | ['any-exit'],
622 | true
623 | );
624 |
625 | plot('wrappedIndicatorChart');
626 |
627 | const wrappedSpatialStrategy = {
628 | nodes: {
629 | _0: {
630 | id: '_0',
631 | data: {
632 | fruit: 'apple',
633 | store: 'a',
634 | cost: 3
635 | },
636 | edges: ['_0-_1', 'any-exit'],
637 | renderId: '_0',
638 | semantics: {
639 | label: 'fruit: apple. store: a. cost: 3. Data point.'
640 | }
641 | },
642 | _1: {
643 | id: '_1',
644 | data: {
645 | fruit: 'banana',
646 | store: 'a',
647 | cost: 0.75
648 | },
649 | edges: ['_0-_1', '_1-_2', 'any-exit'],
650 | renderId: '_1',
651 | semantics: {
652 | label: 'fruit: banana. store: a. cost: 0.75. Data point.'
653 | },
654 | spatialProperties: {
655 | height: 33.545448303222656,
656 | width: 110.3,
657 | x: 32,
658 | y: 103.7727279663086
659 | }
660 | },
661 | _2: {
662 | id: '_2',
663 | data: {
664 | fruit: 'apple',
665 | store: 'b',
666 | cost: 2.75
667 | },
668 | edges: ['_1-_2', '_2-_3', 'any-exit'],
669 | renderId: '_2',
670 | semantics: {
671 | label: 'fruit: apple. store: b. cost: 2.75. Data point.'
672 | }
673 | },
674 | _3: {
675 | id: '_3',
676 | data: {
677 | fruit: 'banana',
678 | store: 'b',
679 | cost: 1.25
680 | },
681 | edges: ['_2-_3', 'any-exit'],
682 | renderId: '_3',
683 | semantics: {
684 | label: 'fruit: banana. store: b. cost: 1.25. Data point.'
685 | }
686 | }
687 | },
688 | edges: {
689 | '_0-_1': {
690 | source: '_0',
691 | target: '_1',
692 | navigationRules: ['left', 'right']
693 | },
694 | '_1-_2': {
695 | source: '_1',
696 | target: '_2',
697 | navigationRules: ['left', 'right']
698 | },
699 | '_2-_3': {
700 | source: '_2',
701 | target: '_3',
702 | navigationRules: ['left', 'right']
703 | },
704 | 'any-exit': {
705 | source: (_d, c) => c,
706 | target: () => {
707 | exit['wrappedIndicatorChart']();
708 | return '';
709 | },
710 | navigationRules: ['exit']
711 | }
712 | },
713 | navigationRules: {
714 | left: {
715 | key: 'ArrowLeft',
716 | direction: 'source'
717 | },
718 | right: {
719 | key: 'ArrowRight',
720 | direction: 'target'
721 | },
722 | exit: {
723 | key: 'Escape',
724 | direction: 'target'
725 | }
726 | }
727 | };
728 |
729 | initializeDataNavigator(
730 | wrappedSpatialStrategy,
731 | 'wrappedIndicatorChart',
732 | wrappedSpatialStrategy.nodes[Object.keys(wrappedSpatialStrategy.nodes)[0]].id,
733 | 300,
734 | 'dimensionLevel',
735 | true
736 | );
737 |
738 | buildGraph(
739 | wrappedSpatialStrategy,
740 | 'wrappedIndicator',
741 | 300,
742 | 'dimensionLevel', // 'cat',
743 | wrappedSpatialStrategy.nodes[Object.keys(wrappedSpatialStrategy.nodes)[0]].id,
744 | ['exit'],
745 | ['any-exit'],
746 | true,
747 | false,
748 | true
749 | );
750 |
--------------------------------------------------------------------------------
/app/v-bundle.js:
--------------------------------------------------------------------------------
1 | (()=>{"use strict";var e={312:(e,i)=>{Object.defineProperty(i,"__esModule",{value:!0}),i.NodeElementDefaults=i.GenericLimitedNavigationRules=i.GenericFullNavigationPairs=i.GenericFullNavigationDimensions=i.GenericFullNavigationRules=i.TypicallyUnreservedKeyPairs=i.TypicallyUnreservedSoloKeys=i.TypicallyUnreservedKeys=i.defaultKeyBindings=i.SemanticKeys=void 0,i.SemanticKeys={Escape:!0,Enter:!0,Backspace:!0,ArrowLeft:!0,ArrowRight:!0,ArrowUp:!0,ArrowDown:!0},i.defaultKeyBindings={ArrowLeft:"left",ArrowRight:"right",ArrowUp:"up",ArrowDown:"down",Period:"forward",Comma:"backward",Escape:"parent",Enter:"child"},i.TypicallyUnreservedKeys=["KeyW","KeyJ","LeftBracket","RightBracket","Slash","Backslash"],i.TypicallyUnreservedSoloKeys=["KeyW","KeyJ"],i.TypicallyUnreservedKeyPairs=[["LeftBracket","RightBracket"],["Slash","Backslash"]],i.GenericFullNavigationRules={left:{key:"ArrowLeft",direction:"source"},right:{key:"ArrowRight",direction:"target"},up:{key:"ArrowUp",direction:"source"},down:{key:"ArrowDown",direction:"target"},child:{key:"Enter",direction:"target"},parent:{key:"Backspace",direction:"source"},backward:{key:"Comma",direction:"source"},forward:{key:"Period",direction:"target"},previous:{key:"Semicolon",direction:"source"},next:{key:"Quote",direction:"target"},exit:{key:"Escape",direction:"target"},help:{key:"KeyY",direction:"target"},undo:{key:"KeyZ",direction:"target"}},i.GenericFullNavigationDimensions=[["left","right"],["up","down"],["backward","forward"],["previous","next"]],i.GenericFullNavigationPairs={left:["left","right"],right:["left","right"],up:["up","down"],down:["up","down"],backward:["backward","forward"],forward:["backward","forward"],previous:["previous","next"],next:["previous","next"],parent:["parent","child"],child:["parent","child"],exit:["exit","undo"],undo:["undo","undo"]},i.GenericLimitedNavigationRules={right:{key:"ArrowRight",direction:"target"},left:{key:"ArrowLeft",direction:"source"},down:{key:"ArrowDown",direction:"target"},up:{key:"ArrowUp",direction:"source"},child:{key:"Enter",direction:"target"},parent:{key:"Backspace",direction:"source"},exit:{key:"Escape",direction:"target"},undo:{key:"Period",direction:"target"},legend:{key:"KeyL",direction:"target"}},i.NodeElementDefaults={cssClass:"",spatialProperties:{x:0,y:0,width:0,height:0,path:""},semantics:{label:"",elementType:"div",role:"image",attributes:void 0},parentSemantics:{label:"",elementType:"figure",role:"figure",attributes:void 0},existingElement:{useForSpatialProperties:!1,spatialProperties:void 0}}},607:(e,i,t)=>{var n=t(4),o=t(489),r=t(992);i.Z={structure:n.default,input:o.default,rendering:r.default}},489:(e,i,t)=>{Object.defineProperty(i,"__esModule",{value:!0});var n=t(312);i.default=function(e){var i={},t=n.defaultKeyBindings,o=n.GenericFullNavigationRules;return i.moveTo=function(i){var t=e.structure.nodes[i];if(t)return t},i.move=function(t,n){if(t){var r=e.structure.nodes[t];if(r.edges){var s=null,a=0,d=o[n];if(!d)return;var l=function(){var i=e.structure.edges[r.edges[a]];if(i.navigationRules.forEach((function(e){s||(s=function(e,i){if(e!==n)return null;var o={target:"string"==typeof i.target?i.target:i.target(r,t),source:"string"==typeof i.source?i.source:i.source(r,t)};return o[d.direction]!==t?o[d.direction]:null}(e,i))})),s)return"break"};for(a=0;ae?n:e}(h,x))}var R="function"==typeof e.idKey?e.idKey(t):e.idKey;w.values[t[R]]=t}}a++}else console.error("Building nodes, parsing dimensions. Each dimension in options.dimensions must contain a dimensionKey. This dimension has no key: ".concat(JSON.stringify(r),"."))}))})),Object.keys(d).forEach((function(e){var t,n,o,r,s,a=d[e],l=a.divisions;if("numerical"===a.type){l[a.nodeId].values=Object.fromEntries(Object.entries(l[a.nodeId].values).sort((function(i,t){var n;return"function"==typeof(null===(n=a.operations)||void 0===n?void 0:n.sortFunction)?a.operations.sortFunction(i[1],t[1],a):i[1][e]-t[1][e]})));var u=l[a.nodeId].values;if(a.numericalExtents[0]!==1/0&&1!==a.subdivisions){var c=Object.keys(u),v="function"==typeof a.subdivisions?a.subdivisions(e,u):a.subdivisions,p=(a.numericalExtents[1]-a.numericalExtents[0])/v,g=a.numericalExtents[0]+p,f=0;for(g=a.numericalExtents[0]+p;g<=a.numericalExtents[1];g+=p){var y="function"==typeof(null===(t=a.divisionOptions)||void 0===t?void 0:t.divisionNodeIds)?a.divisionOptions.divisionNodeIds(e,g,g):a.nodeId+"_"+g;a.divisions[y]={id:y,sortFunction:(null===(n=a.divisionOptions)||void 0===n?void 0:n.sortFunction)||void 0,values:{}};var m="function"==typeof(null===(o=a.divisionOptions)||void 0===o?void 0:o.divisionRenderIds)?a.divisionOptions.divisionRenderIds(e,g,g):y;i[y]={id:y,renderId:m,derivedNode:e,edges:[],data:a.divisions[y],dimensionLevel:2,renderingStrategy:(null===(r=a.divisionOptions)||void 0===r?void 0:r.renderingStrategy)||"singleSquare"};for(var h=!1;!h&&f1&&n.forEach((function(i){var o=t.values[i],s="function"==typeof e.idKey?e.idKey(o):e.idKey,v=1!==c.length?t.id:a.nodeId;if(e.useDirectedEdges?y(o[s],v,a.navigationRules.parent_child,"source"):y(v,o[s],a.navigationRules.parent_child,"target"),"within"===d){if(r===n.length-1&&"circular"===u){var p="function"==typeof e.idKey?e.idKey(t.values[n[0]]):e.idKey;y(o[s],t.values[n[0]][p],a.navigationRules.sibling_sibling)}else r===n.length-1&&"bridgedCousins"===u?f!==c.length-1?(p="function"==typeof e.idKey?e.idKey(a.divisions[c[f+1]].values[n[0]]):e.idKey,y(o[s],a.divisions[c[f+1]].values[n[0]][p],a.navigationRules.sibling_sibling)):(p="function"==typeof e.idKey?e.idKey(a.divisions[c[0]].values[n[0]]):e.idKey,y(o[s],a.divisions[c[0]].values[n[0]][p],a.navigationRules.sibling_sibling)):r===n.length-1&&"bridgedCustom"===u?y(o[s],a.behavior.customBridgePost,a.navigationRules.sibling_sibling):r6&&console.error("Building navigationRules. Dimension count is too high to automatically generate key commands. It is recommend you reduce your dimensions to 6 or fewer for end-user experience. If not, you must provide your own navigation rules in options.navigationRules. Details: Count is ".concat(c.length,". Dimensions counted: ").concat(c.join(", "),"."));var v={},p={},g={},f=o([],r.TypicallyUnreservedKeyPairs,!0),y=o([],r.TypicallyUnreservedKeys,!0),m=function(i,t){var r=i&&t,s=!1,a=!1;if((v[i]||p[i])&&(p[i]=n({},v[i]),s=!0),t&&(v[t]||p[t])&&(p[t]=n({},v[t]),a=!0),!r||s||a){if(!p[i]&&y.length){var d=y.shift(),l=[];f.forEach((function(e){d!==e[0]&&d!==e[1]&&l.push(e)})),f=l,p[i]={direction:e.useDirectedEdges?"target":"source",key:d}}if(t&&!p[t]&&y.length){var u=y.shift(),c=[];f.forEach((function(e){u!==e[0]&&u!==e[1]&&c.push(e)})),f=c,p[t]={direction:"target",key:u}}y.length||(p[i]||(g[i]=i),t&&!p[t]&&(g[t]=t))}else{f.length||console.error("Building navigationRules. Dimension count is too high to automatically generate key commands, we have run out of keyboard key pairs to assign. You must either provide your own navigation rules in options.navigationRules, provide rules when generating dimensions, or reduce dimension count.");var m=o([],f.shift(),!0);y.splice(y.indexOf(m[0]),1),y.splice(y.indexOf(m[1]),1),p[i]={direction:e.useDirectedEdges?"target":"source",key:m[0]},p[t]={direction:"target",key:m[1]}}};if(Object.keys(r.GenericFullNavigationRules).forEach((function(i){var t=n({},r.GenericFullNavigationRules[i]);e.useDirectedEdges&&(t.direction="target"),v[i]=t})),c.length){if(null===(a=null===(s=e.dimensions)||void 0===s?void 0:s.parentOptions)||void 0===a?void 0:a.addLevel0){var h=(null===(l=null===(d=e.dimensions.parentOptions.level1Options)||void 0===d?void 0:d.navigationRules)||void 0===l?void 0:l.parent_child)||["parent","child"];m(h[0],h[1])}c.forEach((function(e){var i=t[e].navigationRules.parent_child,n=t[e].navigationRules.sibling_sibling;m(i[0],i[1]),m(n[0],n[1])}))}if(Object.keys(i).forEach((function(e){i[e].navigationRules.forEach((function(e){p[e]||m(e)}))})),Object.keys(g).length){var b={};Object.keys(p).forEach((function(e){b[p[e].key]=p[e].key})),Object.keys(v).forEach((function(e){b[v[e].key]||r.SemanticKeys[v[e].key]||y.push(v[e].key)}));var E=n({},g);g={},Object.keys(E).forEach((function(e){m(e)})),Object.keys(g).length&&console.error("Building navigationRules. There are no more keys left to assign automatically. Recommended fixes: use fewer dimensions, use fewer GenericEdges, or build your own navigationRules. Rules remaining without keyboard keys: ".concat(Object.keys(g).join(", "),"."))}u=p}return u},i.buildStructure=function(e){e.addIds&&(0,i.addSimpleDataIDs)(e);var t=(0,i.buildNodes)(e),n=(0,i.scaffoldDimensions)(e,t),o=(0,i.buildEdges)(e,t,n);return{nodes:t,edges:o,dimensions:n,navigationRules:(0,i.buildRules)(e,o,n)}}},5:(e,i)=>{Object.defineProperty(i,"__esModule",{value:!0}),i.createValidId=i.describeNode=void 0,i.describeNode=function(e,i){var t=Object.keys(e),n="";return t.forEach((function(t){n+="".concat(i&&i.omitKeyNames?"":t+": ").concat(e[t],". ")})),n+=i&&i.semanticLabel||"Data point."},i.createValidId=function(e){return"_"+e.replace(/[^a-zA-Z0-9_-]+/g,"_")}}},i={};function t(n){var o=i[n];if(void 0!==o)return o.exports;var r=i[n]={exports:{}};return e[n].call(r.exports,r,r.exports,t),r.exports}(()=>{var e=t(607),i=t(5);let n,o,r,s,a,d;const l=()=>a,u=()=>d,c=(e,i,t)=>e.marktype&&!("text"===e.marktype),v=(e,i,t,n)=>!("axis"===t.role||"legend"===t.role),p=(e,i,t,n,o)=>!0,g=(e,t,n)=>{if(Object.keys(e).length)return(0,i.describeNode)(e,{});if(e.role=t.role,"axis"===t.role){const e=t.items[0].items[0].items;return`${"yscale"===t.items[0].datum.scale?"Y ":"xscale"===t.items[0].datum.scale?"X ":""}Axis. Values range from ${e[0].datum.label} to ${e[e.length-1].datum.label}.`}if("mark"===t.role)return`${t.items.length} navigable data elements. Group. Enter using Enter Key.`;if("legend"===t.role){const e=t.items[0].items[0].items[0].items[0].items;return`Legend: ${o.legends[0].title}. Showing values from ${e[0].items[1].items[0].datum.label} to ${e[e.length-1].items[1].items[0].datum.label}.`}return`${n}.`},f=e=>{const i=r.rendering.render({renderId:e.renderId,datum:e});i.addEventListener("keydown",(e=>{const i=r.input.keydownValidator(e);i&&(e.preventDefault(),y(i))})),i.addEventListener("blur",(()=>{s=!1})),r.input.focus(e.renderId),s=!0,d=a,a=e.id,d&&r.rendering.remove(r.structure.nodes[d].renderId)},y=e=>{const i=r.input.move(a,e);i&&f(i)},m=()=>{s=!1,r.rendering.exitElement.style.display="block",r.input.focus(r.rendering.exitElement.id),d=a,a=null,r.rendering.remove(d)};fetch("https://vega.github.io/vega/examples/scatter-plot.vg.json").then((e=>e.json())).then((e=>(o=e,h(e)))).then((i=>{const t=e.Z.structure({dataType:"vega-lite",vegaLiteView:i,vegaLiteSpec:o,groupInclusionCriteria:c,itemInclusionCriteria:v,datumInclusionCriteria:p,keyRenamingHash:{},nodeDescriber:g,getCurrent:l,getPrevious:u,exitFunction:m}),n=e.Z.rendering({elementData:t.elementData,suffixId:"data-navigator-schema",root:{id:"view",cssClass:"",width:"100%",height:0},entryButton:{include:!0,callbacks:{click:()=>{(()=>{const e=r.input.enter();e&&(s=!0,f(e))})()}}},exitElement:{include:!0}});n.initialize();const a=e.Z.input({structure:{nodes:t.nodes,edges:t.edges},navigationRules:t.navigationRules,entryPoint:Object.keys(t.nodes)[0],exitPoint:n.exitElement.id});return r={structure:t,input:a,rendering:n},r})).catch((e=>console.error(e)));const h=e=>(n=new vega.View(vega.parse(e),{renderer:"canvas",container:"#view",hover:!0}),n.runAsync()),b=new Hammer(document.body,{});b.get("pinch").set({enable:!1}),b.get("rotate").set({enable:!1}),b.get("pan").set({enable:!1}),b.get("swipe").set({direction:Hammer.DIRECTION_ALL,velocity:.2}),b.on("press",(e=>{})),b.on("pressup",(e=>{r.enter()})),b.on("swipe",(e=>{const i=Math.abs(e.deltaX)>Math.abs(e.deltaY)?"X":"Y",t=(Math.abs(e["delta"+i])+1e-9)/(Math.abs(e["delta"+("X"===i?"Y":"X")])+1e-9),n=e.deltaX<0,o=e.deltaX>0,s=e.deltaY<0,a=e.deltaY>0,d=t>.99&&t<=2?o&&s?"forward":o&&a?"child":n&&a?"backward":n&&s?"parent":null:o&&"X"===i?"right":a&&"Y"===i?"down":n&&"X"===i?"left":s&&"Y"===i?"up":null;r.getCurrentFocus()&&d&&r.move(d)}))})()})();
--------------------------------------------------------------------------------
/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 | Data Navigator demo
6 |
7 |
8 |
9 |
10 |
11 |
15 |
16 |
17 |
21 |
25 |
26 |
27 |
28 |
29 |
30 |
31 |
32 |
42 |
43 |
113 |
114 |
115 | skip to main content
116 |
117 |
118 | Data Navigator demo
119 |
123 |
124 | This page is a live, interactive application that demos some of the coolest capabilities of our system,
125 | Data Navigator.
126 |
127 |
128 |
129 | What is Data Navigator?
130 |
131 | Data Navigator enables designers and developers to render a semantic, navigable structure on top of any
132 | graphic. This structure can be used by a massive variety of different input modalities.
133 |
134 | If you want to learn more or get started, you can
135 |
149 |
150 |
151 | Below is an introductory video, explaining a basic overview of what Data Navigator does, why we made it,
152 | and what we hope our system is used for:
153 |
154 |
155 |
163 |
164 | Demo
165 |
166 | In this demo, you can use a bunch of different input modalities to navigate the data structure of a png
167 | image of a chart.
168 |
169 | The core 3 subsystems within Data Navigator are Structure, Input, and Rendering:
170 |
171 | The structure is bespoke but mostly follows common patterns.
172 | You can use a lot of different inputs!
173 |
174 | The chart itself is just a png image. The rendered elements (when you navigate) are semantic HTML
175 | with custom visuals.
176 |
177 |
178 | Overall instructions and commands
179 | You will need to "enter" Data Navigator's structure before you can begin navigating.
180 |
181 | The details of every input command are outlined below, however note that each input modality specifies
182 | these commands in different ways (see each section for more details):
183 |
184 |
185 |
186 | View commands:
187 |
188 |
189 |
190 | Command
191 | Result
192 |
193 |
194 |
195 |
196 | Enter
197 |
198 | Enter the interface. (You must enter the interface before using any other command.)
199 |
200 |
201 |
202 | Exit
203 | Exit the interface.
204 |
205 |
206 | Right
207 | Move right (across teams).
208 |
209 |
210 | Left
211 | Move left (across teams).
212 |
213 |
214 | Up
215 | Move up (across legend items or between title/legend/axes).
216 |
217 |
218 | Down
219 | Move down (across legend items or between title/legend/axes).
220 |
221 |
222 | Child
223 | Drill into the x axis, legend, or childmost elements.
224 |
225 |
226 | Parent
227 | Drill out toward the x axis.
228 |
229 |
230 | Legend
231 | Drill out toward the legend.
232 |
233 |
234 | Undo
235 | Move to a previous position.
236 |
237 |
238 |
239 |
240 |
241 | Keyboard, screen reader, touch, and mouse-drag
242 |
243 | Show section
244 |
245 |
246 | View keyboard and desktop screen reader controls:
247 |
248 |
249 |
250 | Command
251 | Expected input
252 |
253 |
254 |
255 |
256 | Enter
257 | Click the "Enter navigation area" button.
258 |
259 |
260 | Exit
261 | ESC key.
262 |
263 |
264 | Right
265 | → (right arrow key).
266 |
267 |
268 | Left
269 | ← (left arrow key).
270 |
271 |
272 | Up
273 | ↑ (up arrow key).
274 |
275 |
276 | Down
277 | ↓ (down arrow key).
278 |
279 |
280 | Child
281 | ENTER key.
282 |
283 |
284 | Parent
285 | BACKSPACE key.
286 |
287 |
288 | Legend
289 | L key.
290 |
291 |
292 | Undo
293 | . (period) key.
294 |
295 |
296 |
297 |
298 |
299 |
300 |
301 | View mobile screen reader, touch, and mouse-drag controls:
302 |
303 |
304 |
305 | Command
306 | Expected input
307 |
308 |
309 |
310 |
311 | Enter
312 |
313 | Long press and release on the chart area or click the "Enter navigation area"
314 | button.
315 |
316 |
317 |
318 | Exit
319 |
320 | Long press and release on the chart area (if you have entered already).
321 |
322 |
323 |
324 | Right
325 | → Press and swipe right.
326 |
327 |
328 | Left
329 | ← Press and swipe left.
330 |
331 |
332 | Up
333 | ↑ Press and swipe up.
334 |
335 |
336 | Down
337 | ↓ Press and swipe down.
338 |
339 |
340 | Child
341 | ↘ Press and swipe down and to the right.
342 |
343 |
344 | Parent
345 | ↖ Press and swipe up and to the left.
346 |
347 |
348 | Legend
349 | ↗ Press and swipe up and to the right.
350 |
351 |
352 | Undo
353 | ↙ Press and swipe up and to the right.
354 |
355 |
356 |
357 |
358 |
359 |
360 | Text and speech
361 |
362 | Show section
363 |
364 |
365 |
366 | View text and speech controls:
367 |
368 | All of the commands from the previous section (Overall Commands) can simply be typed
369 | into the text input or spoken using the button. For example:
370 |
371 |
372 |
373 |
374 | Command
375 | Expected input
376 |
377 |
378 |
379 |
380 | Enter
381 | "Enter" "enter" and "EnTeR" are all valid.
382 |
383 |
384 |
385 |
386 |
387 |
388 |
389 |
390 | Issue a text command:
391 |
395 |
396 |
397 |
398 |
Speak a single-word command:
399 |
Issue voice command
400 |
401 |
402 |
403 |
404 |
405 | Gesture
406 |
407 | Show section
408 |
409 |
410 |
411 | The gesture control model is heavy, so we only load it if you want to try it. Click "Load
412 | Gesture Model" to load it and then activate the model with "Open Webcam" (it will need access to
413 | your camera).
414 |
415 |
416 |
417 | View gesture controls:
418 |
419 | Close your hand to set the gesture center (marked by an 👊 emoji on the camera
420 | space). When ready, you can open your hand or point in a direction to move. Close your
421 | hand again to set the gesture center for a new gesture (and repeat).
422 |
423 |
424 | The model is slow and isn't very smart, so you will need to exaggerate your movement
425 | distances and hold your gesture for about a second. It fails often (apologies).
426 |
427 |
428 |
429 |
430 | Command
431 | Expected input
432 |
433 |
434 |
435 |
436 | Enter
437 | 🖐 Open your hand, facing the camera.
438 |
439 |
440 | Exit
441 |
442 | 🖐 Open your hand, facing the camera (once already entered).
443 |
444 |
445 |
446 | Right
447 | 👉 Point right of your gesture center.
448 |
449 |
450 | Left
451 | 👈 Point left of your gesture center.
452 |
453 |
454 | Up
455 | ☝ Point above your gesture center.
456 |
457 |
458 | Down
459 | 👇 Point below your gesture center.
460 |
461 |
462 | Child
463 |
464 | 👇 👉 Point below and to the right of your gesture center.
465 |
466 |
467 |
468 | Parent
469 |
470 | ☝ 👈 Point above and to the left of your gesture center.
471 |
472 |
473 |
474 | Legend
475 |
476 | ☝ 👉 Point above and to the right of your gesture center.
477 |
478 |
479 |
480 | Undo
481 |
482 | 👇 👈 Point below and to the left of your gesture center.
483 |
484 |
485 |
486 |
487 |
488 |
489 |
Load Gesture Model
490 |
491 |
Open Webcam
492 |
493 |
Close Model
494 |
495 |
496 |
Ready for a command? No. Try closing your hand.
497 |
Command used: (none yet)
498 |
499 |
500 |
501 |
502 |
👊
503 |
504 |
505 |
506 |
507 |
508 |
509 |
514 |
515 |
516 |
517 |
518 | Why make Data Navigator?
519 |
520 | Modern data visualization accessibility faces 3 challenges in design and development that we wanted to
521 | help practitioners and researchers tackle:
522 |
523 |
524 |
525 | Navigable structure is hard to build for data visualizations. Structure is important for
526 | understanding and usability but is often ignored.
527 |
528 |
529 | Only mouse input is treated well (with sporadic support for touch or screen reader input). Many
530 | other input modalities are unaddressed!
531 |
532 |
533 | Visualizations are often rendered as semantic-less SVG or raster (pngs, canvas, etc). If semantics
534 | are added at all they end up using low-level SVG, which is often not appropriate. Semantics help
535 | understanding and add functional interactivity.
536 |
537 |
538 | Advantages of Data Navigator
539 |
540 | Data Navigator uses a graph-based infrastructure, comprised of nodes and edges. This allows us to do two
541 | really interesting things: create almost any other possible structure (list, hierarchy, spatial,
542 | network, etc) and prioritize direct relationships among data points. This underlying infrastructure is
543 | different from HTML's, which prioritizes hierarchies and not direct relationships.
544 |
545 |
546 | It might seem like anarchy to design a low-level building block like this, but this is actually
547 | philosophically more empowering: designers and developers can add the structure and order that make the
548 | most sense for what they are doing, rather than try to make everything fit into a hierarchy.
549 |
550 |
551 | Data Navigator allows designers and developers to express both rich and unique structures in ways that
552 | can handle making an entire library of charts more accessible or even take on unaddressed or bespoke
553 | visualizations.
554 |
555 |
556 | And navigation of a structure can also be built to fit: Data Navigator abstracts navigation rules into
557 | namespaces, like "commands." Commands can be entirely customized to suit the needs of the structure they
558 | support. The advantage of this abstraction is that Data Navigator can be made to work with nearly any
559 | input modality as long as input is validated and converted into an existing command.
560 |
561 |
562 | And lastly, Data Navigator can create an accessible rendering layer (using semantic HTML) on top of any
563 | existing visuals (or no visuals at all). This approach lets designers and developers fully control how
564 | navigation looks and feels for both screen readers and other input modalities without relying on
565 | whatever was used to render the original visualization (SVG, png, canvas, etc). This means that
566 | interactive elements in a visualization can be real "button" elements and not an SVG rectangle made from
567 | scratch to emulate a button.
568 |
569 |
570 |
571 |
572 | Cite our paper
573 |
574 |
575 |
576 |
577 |
580 |
581 |
582 |
583 |
584 |
585 |
586 |
587 | @article{2023-elavsky-data-navigator,
588 | title = {{Data Navigator}: An Accessibility-Centered Data Navigation Toolkit},
589 | publisher = {{IEEE}},
590 | author = {Frank Elavsky and Lucas Nadolskis and Dominik Moritz},
591 | journal = {{IEEE} Transactions on Visualization and Computer Graphics},
592 | year = {2023},
593 | url = {http://dig.cmu.edu/data-navigator/}
594 | }
595 |
596 |
597 |
598 | See a bug in this demo?
599 | Give us feedback on GitHub!
600 |
601 |
602 |
603 |
604 |
605 |
630 |
631 |
632 |
--------------------------------------------------------------------------------