Skip to content

Data Text Adventure ​

This example demonstrates the text chat interface — a text adventure-style alternative to keyboard navigation. Instead of pressing arrow keys to navigate, you type commands like left, right, drill-in, or drill-out into a text input and see descriptions of where you moved.

This is especially useful for mobile users, where screen readers use a virtual cursor that doesn't fire the DOM focus events required by data-navigator's keyboard navigation. The text chat keeps focus on a single text input at all times, uses aria-live to announce navigation results to screen readers, and calls back to the developer so they can update chart visuals without moving DOM focus.

Try It ​

Type commands in the text input below. Try help to see what's available, then enter to begin, left/right to navigate, and exit to leave. You can also use up/down arrow keys in the input to recall previous commands.

Stacked Bar Chart

Text Navigation

About This Example ​

The text chat interface is created with dataNavigator.textChat(). The API is minimal — provide a structure, point it at a container, and wire up callbacks:

js
import dataNavigator from 'data-navigator';

const chat = dataNavigator.textChat({
    structure,
    container: 'text-chat-container',
    commandLabels: { 'drill-in': 'drill in', 'drill-out_category': 'back out towards category' },
    onNavigate: node => {
        /* update your chart */
    },
    onExit: () => {
        /* clear highlights */
    }
});

It handles all navigation internally — parsing commands, fuzzy matching, describing nodes, and announcing results via aria-live. The onNavigate callback receives the node that was navigated to, and the onExit callback fires when the user types exit.

Commands support fuzzy prefix matching: typing l matches left, typing r matches right. If a prefix is ambiguous (e.g. d could be drill-in, drill-out, or down), the chat shows the options. Typos are also handled — dwon will match down. You can use up/down arrow keys to recall previous commands.

The aria-live toggle checkbox controls whether navigation results are automatically announced by screen readers. When unchecked, users can still read the chat log manually.

Natural-Language Command Labels ​

Data Navigator's navigation rules use short internal names like left, drill-in, and drill-out. These work well as keyboard shortcuts, but in a text interface they can feel cryptic — especially compound rules like drill-out_category that the dimensions API generates automatically.

The commandLabels option maps rule names to human-readable descriptions. When labels are provided, help output shows the label alongside the command (e.g. drill in (drill-in)) and navigation responses use the label instead of the raw rule name (e.g. drill in: date: Jan... instead of drill-in: date: Jan...).

js
commandLabels: {
    left: 'left across categories',
    right: 'right across categories',
    up: 'up across dates',
    down: 'down across dates',
    'drill-in': 'drill in',
    'drill-out_category': 'back out towards category',
    'drill-out_date': 'back out towards date',
    exit: 'exit navigation'
}

This lets you tailor the interface to your chart's semantics — a map might use pan north / pan south, a tree might use expand / collapse, and a timeline might use earlier / later. The typed commands still use the short rule names (with fuzzy matching), so l still matches left regardless of the label.

The Complete Code ​

This code is designed to work without a bundler. Run npm install data-navigator, copy the files into a src/ directory, and open index.html in your browser.

js
import { structure, data } from './structure.js';
import { createChart, updateChartHighlight, clearChartHighlight } from './chart.js';
import dataNavigator from 'data-navigator';

// Create the Visa stacked bar chart
const stackedBar = createChart('chart-wrapper', data);

// Create the text chat interface
dataNavigator.textChat({
    structure,
    container: 'text-chat-container',
    commandLabels: {
        left: 'left across categories',
        right: 'right across categories',
        up: 'up across dates',
        down: 'down across dates',
        'drill-in': 'drill in',
        'drill-out_category': 'back out towards category',
        'drill-out_date': 'back out towards date',
        exit: 'exit navigation'
    },
    onNavigate: node => {
        updateChartHighlight(stackedBar, node);
    },
    onExit: () => {
        clearChartHighlight(stackedBar);
    }
});
js
import dataNavigator from 'data-navigator';

const createValidId = s => '_' + s.replace(/[^a-zA-Z0-9_-]+/g, '_');

export const data = [
    { date: 'Jan', category: 'Group A', value: 120, count: 420, selectAll: 'yes' },
    { date: 'Feb', category: 'Group A', value: 121, count: 439, selectAll: 'yes' },
    { date: 'Mar', category: 'Group A', value: 119, count: 402, selectAll: 'yes' },
    { date: 'Apr', category: 'Group A', value: 114, count: 434, selectAll: 'yes' },
    { date: 'May', category: 'Group A', value: 102, count: 395, selectAll: 'yes' },
    { date: 'Jun', category: 'Group A', value: 112, count: 393, selectAll: 'yes' },
    { date: 'Jul', category: 'Group A', value: 130, count: 445, selectAll: 'yes' },
    { date: 'Aug', category: 'Group A', value: 124, count: 456, selectAll: 'yes' },
    { date: 'Sep', category: 'Group A', value: 119, count: 355, selectAll: 'yes' },
    { date: 'Oct', category: 'Group A', value: 106, count: 464, selectAll: 'yes' },
    { date: 'Nov', category: 'Group A', value: 123, count: 486, selectAll: 'yes' },
    { date: 'Dec', category: 'Group A', value: 133, count: 491, selectAll: 'yes' },
    { date: 'Jan', category: 'Group B', value: 89, count: 342, selectAll: 'yes' },
    { date: 'Feb', category: 'Group B', value: 93, count: 434, selectAll: 'yes' },
    { date: 'Mar', category: 'Group B', value: 82, count: 378, selectAll: 'yes' },
    { date: 'Apr', category: 'Group B', value: 92, count: 323, selectAll: 'yes' },
    { date: 'May', category: 'Group B', value: 90, count: 434, selectAll: 'yes' },
    { date: 'Jun', category: 'Group B', value: 85, count: 376, selectAll: 'yes' },
    { date: 'Jul', category: 'Group B', value: 88, count: 404, selectAll: 'yes' },
    { date: 'Aug', category: 'Group B', value: 84, count: 355, selectAll: 'yes' },
    { date: 'Sep', category: 'Group B', value: 90, count: 432, selectAll: 'yes' },
    { date: 'Oct', category: 'Group B', value: 80, count: 455, selectAll: 'yes' },
    { date: 'Nov', category: 'Group B', value: 92, count: 445, selectAll: 'yes' },
    { date: 'Dec', category: 'Group B', value: 97, count: 321, selectAll: 'yes' },
    { date: 'Jan', category: 'Group C', value: 73, count: 456, selectAll: 'yes' },
    { date: 'Feb', category: 'Group C', value: 74, count: 372, selectAll: 'yes' },
    { date: 'Mar', category: 'Group C', value: 68, count: 323, selectAll: 'yes' },
    { date: 'Apr', category: 'Group C', value: 66, count: 383, selectAll: 'yes' },
    { date: 'May', category: 'Group C', value: 72, count: 382, selectAll: 'yes' },
    { date: 'Jun', category: 'Group C', value: 70, count: 365, selectAll: 'yes' },
    { date: 'Jul', category: 'Group C', value: 74, count: 296, selectAll: 'yes' },
    { date: 'Aug', category: 'Group C', value: 68, count: 312, selectAll: 'yes' },
    { date: 'Sep', category: 'Group C', value: 75, count: 334, selectAll: 'yes' },
    { date: 'Oct', category: 'Group C', value: 66, count: 386, selectAll: 'yes' },
    { date: 'Nov', category: 'Group C', value: 85, count: 487, selectAll: 'yes' },
    { date: 'Dec', category: 'Group C', value: 89, count: 512, selectAll: 'yes' },
    { date: 'Jan', category: 'Other', value: 83, count: 432, selectAll: 'yes' },
    { date: 'Feb', category: 'Other', value: 87, count: 364, selectAll: 'yes' },
    { date: 'Mar', category: 'Other', value: 76, count: 334, selectAll: 'yes' },
    { date: 'Apr', category: 'Other', value: 86, count: 395, selectAll: 'yes' },
    { date: 'May', category: 'Other', value: 87, count: 354, selectAll: 'yes' },
    { date: 'Jun', category: 'Other', value: 77, count: 386, selectAll: 'yes' },
    { date: 'Jul', category: 'Other', value: 79, count: 353, selectAll: 'yes' },
    { date: 'Aug', category: 'Other', value: 85, count: 288, selectAll: 'yes' },
    { date: 'Sep', category: 'Other', value: 87, count: 353, selectAll: 'yes' },
    { date: 'Oct', category: 'Other', value: 76, count: 322, selectAll: 'yes' },
    { date: 'Nov', category: 'Other', value: 96, count: 412, selectAll: 'yes' },
    { date: 'Dec', category: 'Other', value: 104, count: 495, selectAll: 'yes' }
];

// Two categorical dimensions create a dual hierarchy.
// Category is first (left/right), date is second (up/down).
export const structure = dataNavigator.structure({
    data,
    idKey: 'id',
    addIds: true,
    dimensions: {
        values: [
            {
                dimensionKey: 'category',
                type: 'categorical',
                divisionOptions: {
                    divisionNodeIds: (dimensionKey, keyValue, i) => {
                        return createValidId(dimensionKey + keyValue + i);
                    }
                },
                behavior: {
                    extents: 'circular',
                    childmostNavigation: 'across'
                }
            },
            {
                dimensionKey: 'date',
                type: 'categorical',
                behavior: {
                    extents: 'circular',
                    childmostNavigation: 'across'
                },
                operations: {
                    sortFunction: (a, b) => {
                        if (a.values) {
                            const months = [
                                'Jan',
                                'Feb',
                                'Mar',
                                'Apr',
                                'May',
                                'Jun',
                                'Jul',
                                'Aug',
                                'Sep',
                                'Oct',
                                'Nov',
                                'Dec'
                            ];
                            let aMonth =
                                a.values[Object.keys(a.values)[0]].date ||
                                a.values[Object.keys(a.values)[0]].data?.date;
                            let bMonth =
                                b.values[Object.keys(b.values)[0]].date ||
                                b.values[Object.keys(b.values)[0]].data?.date;
                            return months.indexOf(aMonth) - months.indexOf(bMonth);
                        }
                    }
                }
            }
        ]
    }
});
js
// Creates a Visa stacked bar chart web component inside the given container.
export function createChart(containerId, data) {
    const wrapper = document.getElementById(containerId);
    const stackedBar = document.createElement('stacked-bar-chart');
    const props = {
        mainTitle: '',
        subTitle: '',
        data,
        height: 200,
        width: 250,
        padding: { top: 10, bottom: 10, right: 10, left: 30 },
        colors: ['#FFFFFF', '#DDDDDD', '#BBBBBB', '#999999'],
        ordinalAccessor: 'category',
        valueAccessor: 'value',
        groupAccessor: 'date',
        uniqueID: 'text-adventure-stacked-bar',
        legend: { labels: ['A', 'B', 'C', 'Other'] },
        dataLabel: { visible: false },
        yAxis: { visible: true, gridVisible: false },
        xAxis: { label: '', visible: false },
        showTotalValue: false,
        suppressEvents: true,
        layout: 'horizontal',
        clickHighlight: [],
        clickStyle: { color: '#222', strokeWidth: 1 },
        interactionKeys: [],
        accessibility: {
            elementsAreInterface: false,
            disableValidation: true,
            hideDataTableButton: true,
            keyboardNavConfig: { disabled: true },
            hideTextures: true,
            hideStrokes: false
        }
    };
    Object.keys(props).forEach(prop => {
        stackedBar[prop] = props[prop];
    });
    wrapper.appendChild(stackedBar);
    return stackedBar;
}

// Highlights bars on the chart based on the current node type.
export function updateChartHighlight(stackedBar, node) {
    if (!node.derivedNode) {
        // Leaf node — highlight specific bar segment
        stackedBar.clickHighlight = [{ category: node.data.category, date: node.data.date }];
        stackedBar.interactionKeys = ['category', 'date'];
    } else if (node.data?.dimensionKey) {
        // Dimension node — highlight all bars
        stackedBar.clickHighlight = [{ selectAll: 'yes' }];
        stackedBar.interactionKeys = ['selectAll'];
    } else {
        // Division node — highlight group
        const key = node.derivedNode;
        const value = node.data?.[key];
        stackedBar.clickHighlight = [{ [key]: value }];
        stackedBar.interactionKeys = [key];
    }
}

export function clearChartHighlight(stackedBar) {
    stackedBar.clickHighlight = [];
}
html
<html>
    <head>
        <link rel="stylesheet" href="./node_modules/data-navigator/text-chat.css" />
        <link rel="stylesheet" href="./src/style.css" />
        <script type="importmap">
            {
                "imports": {
                    "data-navigator": "./node_modules/data-navigator/dist/index.mjs"
                }
            }
        </script>
    </head>
    <body>
        <div style="display: flex; gap: 2em; flex-wrap: wrap; align-items: flex-start;">
            <div>
                <h3>Stacked Bar Chart</h3>
                <div id="chart-wrapper" style="position: relative;"></div>
            </div>
            <div style="flex: 1; min-width: 300px;">
                <h3>Text Navigation</h3>
                <div id="text-chat-container"></div>
            </div>
        </div>
    </body>
    <script
        src="https://unpkg.com/@visa/stacked-bar-chart@7/dist/stacked-bar-chart/stacked-bar-chart.esm.js"
        type="module"
    ></script>
    <script type="module" src="./src/coordinator.js"></script>
</html>
css
body {
    font-family: sans-serif;
    padding: 1em;
}

Inspiration: Text Adventures ​

This interaction approach was inspired by text adventure games — a genre where players explore worlds, solve puzzles, and interact with characters entirely through typed commands. Text adventures have a long history of being accessible to screen reader users, since the entire experience is already text-based. If you've never played one, textadventures.co.uk hosts a large collection of free games worth exploring.

Released under the MIT License.