Stacked Bar Chart
A stacked bar chart showing browser market share by year, adapted from the Bokeh documentation gallery. The wrapper uses type: 'stacked_bar' with xField: 'year' and groupField: 'browser' to build a dual-dimension navigation structure: navigate across years with ← →, navigate between browsers with ↑ ↓. At the deepest level all four arrow keys stay active so users can roam freely without drilling back up.
Live example
| Key | Action |
|---|---|
| Enter navigation area button | Start keyboard navigation |
| ← → | Navigate between years (or browsers at the deepest level) |
| ↑ ↓ | Navigate between browsers (or years at the deepest level) |
| Enter | Drill in |
| W | Go up to year level |
| J | Go up to browser level |
| Backspace | Go back to chart root (from dimension roots) |
| Escape | Exit navigation |
Structure
For type: 'stacked_bar' the navigation hierarchy is a dual-dimension cross-navigable graph. Both the year (x-axis) and browser (group) fields become first-class navigation dimensions that share the same leaf nodes:
Chart root
├─ year dimension
│ ├─ 2015 division
│ │ ├─ Chrome: 62%
│ │ ├─ Firefox: 12%
│ │ ├─ Safari: 11%
│ │ ├─ Edge: 4%
│ │ └─ Other: 11%
│ ├─ 2016 division → ...
│ └─ 2017 division → ...
└─ browser dimension
├─ Chrome division → 2015: 62%, 2016: 63%, 2017: 65%
├─ Firefox division → ...
├─ Safari division → ...
├─ Edge division → ...
└─ Other division → ...2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
← →navigates between year divisions (at year dimension level)↑ ↓navigates between browser divisions (at browser dimension level)Enterdrills into the current dimension's divisions, or from a division to its data pointsWreturns to the year dimensionJreturns to the browser dimension- At the leaf (data point) level, all four arrow keys remain active for free 2D roaming
Full code
Create three files in the same directory and serve them with a local server (e.g. npx serve . or python -m http.server). Bokeh is loaded from CDN; the wrapper is loaded via import map. The wrapper tab is the integration layer; chart is the Bokeh rendering code.
import { addDataNavigator } from '@data-navigator/bokeh-wrapper';
import { data, drawChart } from './chart.js';
let wrapper = null;
let focusedYear = null;
let focusedBrowser = null;
// 'root' = chart root, 'year' = year dimension/division, 'browser' = browser dimension/division
let focusedDimension = null;
function initWrapper(mode) {
wrapper?.destroy();
focusedYear = null;
focusedBrowser = null;
focusedDimension = null;
drawChart({ focusedYear, focusedBrowser, focusedDimension });
wrapper = addDataNavigator({
plotContainer: 'stacked-plot',
chatContainer: 'stacked-chat',
mode,
data,
type: 'stacked_bar',
xField: 'year',
yField: 'share',
groupField: 'browser',
title: 'Browser market share (%)',
onNavigate(node) {
if (!node.derivedNode) {
// Chart root has no year/browser; leaf nodes have both.
const isChartRoot = node.data?.year == null && node.data?.browser == null;
if (isChartRoot) {
focusedDimension = 'root';
focusedYear = '__all__';
focusedBrowser = null;
} else {
// Leaf node — specific (year, browser) segment
focusedDimension = null;
focusedYear = node.data?.year ?? null;
focusedBrowser = node.data?.browser ?? null;
}
} else if (node.derivedNode === 'year') {
// Year dimension root (null year) or a specific year division
focusedDimension = 'year';
focusedYear = node.data?.year ?? null;
focusedBrowser = null;
} else if (node.derivedNode === 'browser') {
// Browser dimension root (null browser) or a specific browser division
focusedDimension = 'browser';
focusedYear = '__all__';
focusedBrowser = node.data?.browser ?? null;
}
drawChart({ focusedYear, focusedBrowser, focusedDimension });
},
onExit() {
focusedYear = null;
focusedBrowser = null;
focusedDimension = null;
drawChart({ focusedYear, focusedBrowser, focusedDimension });
}
});
}
initWrapper('text');
document.getElementById('stacked-keyboard')?.addEventListener('change', e => {
initWrapper(e.target.checked ? 'keyboard' : 'text');
});2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
const years = ['2015', '2016', '2017'];
const browsers = ['Chrome', 'Firefox', 'Safari', 'Edge', 'Other'];
// Shares as integer percentages, indexed by browser then year
const shares = {
Chrome: [62, 63, 65],
Firefox: [12, 12, 11],
Safari: [11, 12, 12],
Edge: [4, 4, 4],
Other: [11, 9, 8]
};
const palette = ['#4e79a7', '#f28e2b', '#e15759', '#76b7b2', '#59a14f'];
// Flatten for data-navigator: one row per (browser, year) combination.
export const data = [];
browsers.forEach((browser, bi) => {
years.forEach((year, yi) => {
data.push({ year, browser, share: shares[browser][yi] });
});
});
export function drawChart({ focusedYear = null, focusedBrowser = null, focusedDimension = null } = {}) {
const container = document.getElementById('stacked-chart-inner');
container.innerHTML = '';
const plt = Bokeh.Plotting;
const p = plt.figure({
x_range: years,
y_range: [0, 110],
height: 300,
width: 400,
title: 'Browser market share (%)',
y_axis_label: 'Market share (%)',
toolbar_location: null,
output_backend: 'svg'
});
let bottoms = { 2015: 0, 2016: 0, 2017: 0 };
browsers.forEach((browser, bi) => {
const tops = years.map((y, yi) => bottoms[y] + shares[browser][yi]);
const bots = years.map(y => bottoms[y]);
years.forEach((y, yi) => {
bottoms[y] = tops[yi];
});
const isFocusedBrowser = focusedBrowser === browser;
// Segment borders are used for browser-level navigation only.
// Browser dim root highlights all segments; division/leaf highlights just the focused browser.
const shouldHighlight = y => {
if (focusedDimension === 'root' || (focusedDimension === 'browser' && focusedBrowser == null)) return true;
if (focusedBrowser == null) return false;
return isFocusedBrowser && (focusedYear === '__all__' || y === focusedYear);
};
const lineColor = years.map(y => (shouldHighlight(y) ? '#000' : 'white'));
const lineWidth = years.map(y => (shouldHighlight(y) ? 2 : 0.5));
const fillAlpha = focusedBrowser != null && !isFocusedBrowser ? 0.3 : 1.0;
// Real bars — accurate per-bar borders, no legend_label so the legend
// square is driven independently below.
p.vbar({
x: years,
top: tops,
bottom: bots,
width: 0.8,
color: palette[bi],
line_color: lineColor,
line_width: lineWidth,
fill_alpha: fillAlpha
});
// Zero-size legend proxy: owns legend_label and carries a scalar line_color
// so Bokeh uses it for the legend square styling rather than line_color[0]
// of the per-bar array above. Invisible (zero width and height) but always
// present so the legend entry is stable across redraws.
p.vbar({
x: [years[0]],
top: [0],
bottom: [0],
width: 0,
color: palette[bi],
line_color: isFocusedBrowser && focusedBrowser != null ? '#000' : 'white',
line_width: isFocusedBrowser && focusedBrowser != null ? 2 : 0.5,
fill_alpha: fillAlpha,
legend_label: browser
});
});
// Year-level nav: draw a single outline rect spanning the full stack.
// Only active when navigating the year dimension (root or division), not at browser/leaf level.
const drawYearRect = y => {
const yi = years.indexOf(y);
const totalHeight = browsers.reduce((sum, b) => sum + shares[b][yi], 0);
p.rect({
x: [y],
y: [totalHeight / 2],
width: 0.85,
height: totalHeight,
fill_alpha: 0,
line_color: '#000',
line_width: 2
});
};
if (focusedDimension === 'root' || (focusedDimension === 'year' && focusedYear == null)) {
// Chart root or year dimension root — outline all year columns
years.forEach(drawYearRect);
} else if (focusedDimension === 'year' && focusedYear != null) {
// Specific year division — outline just that column
drawYearRect(focusedYear);
}
p.legend.location = 'top_right';
plt.show(p, '#stacked-chart-inner');
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stacked Bar Chart — Data Navigator Bokeh Wrapper</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/data-navigator/text-chat.css" />
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-3.7.3.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-gl-3.7.3.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-widgets-3.7.3.min.js" crossorigin="anonymous"></script>
<script src="https://cdn.bokeh.org/bokeh/release/bokeh-api-3.7.3.min.js" crossorigin="anonymous"></script>
<script type="importmap">
{
"imports": {
"@data-navigator/bokeh-wrapper": "https://esm.sh/@data-navigator/bokeh-wrapper",
"data-navigator": "https://esm.sh/data-navigator"
}
}
</script>
</head>
<body>
<div id="stacked-plot">
<div id="stacked-chart-inner"></div>
</div>
<label>
<input type="checkbox" id="stacked-keyboard" />
Use keyboard navigation
</label>
<div id="stacked-chat" style="max-width:500px;"></div>
<script type="module" src="./wrapper.js"></script>
</body>
</html>2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32