bezel-charts 1.1.1 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,5 @@
1
- import { r2, num, has, list, series, tw, trunc, bool, fmt, text, small, hit, anim, palette, emptyState, goalLine, statOf, legend, SANS, MONO } from '../core/util.js';
1
+ import { newId, r2, num, has, list, series, tw, trunc, bool, fmt, text, small, hit, anim, palette, emptyState, goalLine, statOf, SANS, MONO } from '../core/util.js';
2
+ import { legend, seriesTable, styleOf, pattern, PATTERNS } from '../core/series.js';
2
3
  import { tile, dims } from '../core/tile.js';
3
4
 
4
5
  /** How far angled labels lean, and the longest label they show before shortening it. */
@@ -23,7 +24,16 @@ export default function bars(o, th) {
23
24
  var scale = stacked ? ups.concat(downs) : all;
24
25
  var top = scale.length ? Math.max.apply(null, scale) : 0;
25
26
  var nameOf = function (j) { return names[j] || 'Series ' + (j + 1); };
26
- var colourOf = function (j, i) { return grouped ? pal[j % pal.length] : many ? pal[i % pal.length] : pal[0]; };
27
+ // Patterns tell grouped and stacked series apart without relying on colour alone.
28
+ var patOf = grouped ? styleOf(o.patterns, PATTERNS, 'solid') : null, defs = '', fills = [];
29
+ for (var p = 0; patOf && p < k; p++) {
30
+ if (patOf(p) === 'solid') continue;
31
+ var pid = newId('g');
32
+ defs += pattern(pid, patOf(p), pal[p % pal.length], o.background || th.tile);
33
+ fills[p] = 'url(#' + pid + ')';
34
+ }
35
+ var colourOf = function (j, i) { return grouped ? fills[j] || pal[j % pal.length] : many ? pal[i % pal.length] : pal[0]; };
36
+ var key = patOf && function (j, x, y) { return '<rect x="' + r2(x) + '" y="' + r2(y + 1) + '" width="10" height="10" rx="2" fill="' + colourOf(j) + '"/>'; };
27
37
  var opac = function (val) { return grouped || many || o.highlight === 'none' || val === top ? 1 : 0.5; };
28
38
  var labelOf = function (i) { return labels[i] || '#' + (i + 1); };
29
39
  var tipOf = function (i) {
@@ -34,7 +44,7 @@ export default function bars(o, th) {
34
44
 
35
45
  // Horizontal rows need their height up front, and so does a legend that may wrap.
36
46
  var d0 = dims(o);
37
- var legendH = grouped || names.length ? legend(S.map(function (_, j) { return nameOf(j); }), pal, th, 0, 0, d0.w).height : 0;
47
+ var legendH = grouped || names.length ? legend(S.map(function (_, j) { return nameOf(j); }), pal, th, 0, 0, d0.w, key, key && 15).height : 0;
38
48
  var rows = stacked ? 1 : k, thin = grouped && !stacked;
39
49
  var subH = thin ? 10 : 14, subGap = 3;
40
50
  var rowH = horiz ? rows * subH + (rows - 1) * subGap + (thin ? 14 : 16) : 0;
@@ -43,9 +53,9 @@ export default function bars(o, th) {
43
53
 
44
54
  return tile(o, th, h, function (x0, y0, w, h) {
45
55
  if (!n) return emptyState(th, x0, y0, w, h);
46
- var s = '', top0 = y0;
56
+ var s = defs ? '<defs>' + defs + '</defs>' : '', top0 = y0;
47
57
  if (legendH) {
48
- s += legend(S.map(function (_, j) { return nameOf(j); }), pal, th, x0, y0, w).svg;
58
+ s += legend(S.map(function (_, j) { return nameOf(j); }), pal, th, x0, y0, w, key, key && 15).svg;
49
59
  top0 += legendH;
50
60
  }
51
61
  var lo = Math.min(0, Math.min.apply(null, scale)), hi = Math.max(0, top);
@@ -178,3 +188,5 @@ export default function bars(o, th) {
178
188
  summary: !n ? 'no data' : stacked ? k + ' series stacked across ' + n + ' groups' : grouped ? k + ' series across ' + n + ' groups' : 'highest ' + (labels[best] || '') + ' ' + fmt(v0[best], o),
179
189
  });
180
190
  }
191
+
192
+ bars.table = function (o) { return seriesTable(o, series(o.values).length > 1 && bool(o.stacked)); };
@@ -42,9 +42,17 @@ export default function donut(o, th) {
42
42
  if (len <= 0) return;
43
43
  s += '<circle cx="' + r2(cx) + '" cy="' + r2(cy) + '" r="' + r2(r) + '" fill="none" stroke="#000" stroke-opacity="0" stroke-width="' + r2(sw) +
44
44
  '" pathLength="100" stroke-dasharray="' + r2(len) + ' ' + r2(100 - len) + '" stroke-dashoffset="' + r2(-start) + '" transform="rotate(-90 ' + r2(cx) + ' ' + r2(cy) +
45
- ')" class="bzc-hit" data-tip="' + esc((labels[i] || '#' + (i + 1)) + ': ' + fmt(n, o) + ' (' + Math.round(len) + '%)') + '"/>';
45
+ ')" class="bzc-hit" data-skip="" data-tip="' + esc((labels[i] || '#' + (i + 1)) + ': ' + fmt(n, o) + ' (' + Math.round(len) + '%)') + '"/>';
46
46
  start += len;
47
47
  });
48
48
  return s;
49
49
  }, { stat: st, summary: 'total ' + fmt(total, o) + ' across ' + v.length + ' parts' });
50
50
  }
51
+
52
+ // Every part is listed, including the small ones the chart groups as "Other".
53
+ donut.table = function (o) {
54
+ var v = nums(o.values).map(function (n) { return Math.max(0, n); }), labels = list(o.labels), total = sum(v);
55
+ return { head: ['Label', 'Value', 'Share'], rows: v.map(function (n, i) {
56
+ return [labels[i] || '#' + (i + 1), fmt(n, o), (total ? Math.round(n / total * 100) : 0) + '%'];
57
+ }) };
58
+ };
@@ -39,3 +39,11 @@ export default function gauge(o, th) {
39
39
  return s;
40
40
  }, { summary: isFinite(val) ? show(val) + ' on a scale of ' + fmt(mn, o) + ' to ' + fmt(mx, o) : 'no data' });
41
41
  }
42
+
43
+ gauge.table = function (o) {
44
+ var v = nums(o.values), val = v.length ? v[0] : num(o.value, NaN), labels = list(o.labels);
45
+ var mn = num(o.min, 0), mx = num(o.max, 100), auto = !has(o.prefix) && !has(o.suffix) && mn === 0 && mx === 100;
46
+ if (!isFinite(val)) return { head: ['Label', 'Value'], rows: [] };
47
+ return { head: ['Label', 'Value'], rows: [[labels[0] || 'Reading', fmt(val, o) + (auto ? '%' : '')], ['Lowest', fmt(mn, o)], ['Highest', fmt(mx, o)]]
48
+ .concat(has(o.goal) ? [['Goal', fmt(+o.goal, o)]] : []) };
49
+ };
@@ -1,11 +1,22 @@
1
1
  import { esc, r2, has, nums, sum, fmt, text, small, palette, emptyState, statOf } from '../core/util.js';
2
2
  import { tile, dims } from '../core/tile.js';
3
3
 
4
+ var FULL = { weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' };
5
+
6
+ /** The first day shown: the start option, or far enough back that the last value is today. */
7
+ function startOf(o, len) {
8
+ if (has(o.start) && /^\d{4}-\d{2}-\d{2}$/.test(String(o.start))) return new Date(String(o.start) + 'T00:00:00Z');
9
+ var t = new Date();
10
+ return new Date(Date.UTC(t.getFullYear(), t.getMonth(), t.getDate()) - Math.max(0, len - 1) * 864e5);
11
+ }
12
+
13
+ function dates(lang, opt) {
14
+ try { return new Intl.DateTimeFormat(lang, Object.assign({ timeZone: 'UTC' }, opt)); } catch (e) { return new Intl.DateTimeFormat('en-GB', Object.assign({ timeZone: 'UTC' }, opt)); }
15
+ }
16
+
4
17
  export default function heatmap(o, th) {
5
18
  var v = nums(o.values), c = palette(o, th)[0], lang = o.lang || 'en-GB';
6
- var start;
7
- if (has(o.start) && /^\d{4}-\d{2}-\d{2}$/.test(String(o.start))) start = new Date(String(o.start) + 'T00:00:00Z');
8
- else { var t = new Date(); start = new Date(Date.UTC(t.getFullYear(), t.getMonth(), t.getDate()) - Math.max(0, v.length - 1) * 864e5); }
19
+ var start = startOf(o, v.length);
9
20
  var offset = (start.getUTCDay() + 6) % 7, cols = Math.max(1, Math.ceil((offset + v.length) / 7));
10
21
  var d0 = dims(o), leftW = 28, maxCols = Math.max(1, Math.floor((d0.w - leftW) / 10));
11
22
  // On narrow screens keep the most recent weeks that fit, rather than shrinking cells until they are unreadable.
@@ -19,8 +30,8 @@ export default function heatmap(o, th) {
19
30
  var pitch = Math.min(18, (d0.w - leftW) / cols), gap = pitch < 8 ? 1 : 3, cell = pitch - gap;
20
31
  var h = 16 + 7 * pitch + 26;
21
32
  var max = v.length ? Math.max.apply(null, v) : 0;
22
- var dfm = function (opt) { try { return new Intl.DateTimeFormat(lang, Object.assign({ timeZone: 'UTC' }, opt)); } catch (e) { return new Intl.DateTimeFormat('en-GB', Object.assign({ timeZone: 'UTC' }, opt)); } };
23
- var monthF = dfm({ month: 'short' }), dayF = dfm({ weekday: 'short' }), fullF = dfm({ weekday: 'short', day: 'numeric', month: 'short', year: 'numeric' });
33
+ var dfm = function (opt) { return dates(lang, opt); };
34
+ var monthF = dfm({ month: 'short' }), dayF = dfm({ weekday: 'short' }), fullF = dfm(FULL);
24
35
  var st = statOf(v, o, '');
25
36
  return tile(o, th, h, function (x0, y0, w, h) {
26
37
  if (!v.length) return emptyState(th, x0, y0, w, h);
@@ -51,3 +62,9 @@ export default function heatmap(o, th) {
51
62
  return s;
52
63
  }, { stat: st, summary: fmt(sum(v), o) + ' across ' + v.length + ' days' });
53
64
  }
65
+
66
+ // Every day, including the early weeks a narrow chart leaves out.
67
+ heatmap.table = function (o) {
68
+ var v = nums(o.values), start = startOf(o, v.length), f = dates(o.lang || 'en-GB', FULL);
69
+ return { head: ['Date', 'Value'], rows: v.map(function (n, i) { return [f.format(new Date(start.getTime() + i * 864e5)), fmt(n, o)]; }) };
70
+ };
@@ -1,4 +1,5 @@
1
- import { newId, r2, clamp, num, has, list, series, tw, bool, fmt, niceScale, pathOf, text, small, hit, anim, palette, emptyState, parseNotes, goalLine, statOf, legend, SANS } from '../core/util.js';
1
+ import { newId, r2, clamp, num, has, list, series, tw, bool, fmt, pathOf, text, small, hit, anim, palette, emptyState, parseNotes, goalLine, statOf } from '../core/util.js';
2
+ import { niceScale, legend, seriesTable, styleOf, marker, DASHES, SHAPES } from '../core/series.js';
2
3
  import { tile } from '../core/tile.js';
3
4
 
4
5
  export default function line(o, th) {
@@ -6,11 +7,19 @@ export default function line(o, th) {
6
7
  var h = num(o.height, 170), n = Math.max.apply(null, [0].concat(S.map(function (s) { return s.length; })));
7
8
  var notes = parseNotes(o, labels), multi = S.length > 1 || names.length > 0;
8
9
  var st = statOf(S[0] || [], o, '');
10
+ var dashOf = styleOf(o.dash, Object.keys(DASHES), 'solid'), markOf = styleOf(o.markers, SHAPES, 'none');
11
+ var bg = o.background || th.tile;
12
+ // With dashes or markers, the legend shows a short sample of each line instead of a dot.
13
+ var key = (dashOf || markOf) && function (k, x, y) {
14
+ var c = pal[k % pal.length], dk = dashOf ? DASHES[dashOf(k)] : '', mk = markOf ? markOf(k) : 'none';
15
+ return '<line x1="' + r2(x) + '" x2="' + r2(x + 18) + '" y1="' + r2(y + 6) + '" y2="' + r2(y + 6) + '" stroke="' + c + '" stroke-width="2.5" stroke-linecap="round"' +
16
+ (dk ? ' stroke-dasharray="' + dk + '"' : '') + '/>' + (mk !== 'none' ? marker(mk, x + 9, y + 6, 3.5, ' fill="' + c + '" stroke="' + bg + '" stroke-width="1.5"') : '');
17
+ };
9
18
  return tile(o, th, h, function (x0, y0, w, h) {
10
19
  if (!n) return emptyState(th, x0, y0, w, h);
11
20
  var s = '', top = y0;
12
21
  if (multi) {
13
- var lg = legend(S.map(function (_, k) { return names[k] || 'Series ' + (k + 1); }), pal, th, x0, top, w);
22
+ var lg = legend(S.map(function (_, k) { return names[k] || 'Series ' + (k + 1); }), pal, th, x0, top, w, key, key && 24);
14
23
  s += lg.svg;
15
24
  top += lg.height;
16
25
  }
@@ -54,9 +63,21 @@ export default function line(o, th) {
54
63
  s += '<defs><linearGradient id="' + g + '" x1="0" y1="0" x2="0" y2="1"><stop offset="0" stop-color="' + c + '" stop-opacity=".26"/><stop offset="1" stop-color="' + c + '" stop-opacity="0"/></linearGradient></defs>' +
55
64
  '<path d="' + d + 'L' + r2(last[0]) + ',' + r2(top + ph) + 'L' + r2(P[0][0]) + ',' + r2(top + ph) + 'Z" fill="url(#' + g + ')"' + anim(o, 'bzc-a') + '/>';
56
65
  }
57
- s += '<path d="' + d + '" fill="none" stroke="' + c + '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" pathLength="1" style="stroke-dasharray:1' + (o.animate ? ';animation-delay:' + k * 0.15 + 's' : '') + '"' + (o.animate ? ' class="bzc-d"' : '') + '/>';
58
- var e = P[P.length - 1];
59
- s += '<circle cx="' + r2(e[0]) + '" cy="' + r2(e[1]) + '" r="4" fill="' + c + '" stroke="' + (o.background || th.tile) + '" stroke-width="2"/>';
66
+ var dk = dashOf ? DASHES[dashOf(k)] : '', mk = markOf ? markOf(k) : 'none';
67
+ // The draw-in animation is itself a dash, so dashed lines fade in instead.
68
+ s += dk
69
+ ? '<path d="' + d + '" fill="none" stroke="' + c + '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" stroke-dasharray="' + dk + '"' + anim(o, 'bzc-a', k * 0.15) + '/>'
70
+ : '<path d="' + d + '" fill="none" stroke="' + c + '" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" pathLength="1" style="stroke-dasharray:1' + (o.animate ? ';animation-delay:' + k * 0.15 + 's' : '') + '"' + (o.animate ? ' class="bzc-d"' : '') + '/>';
71
+ if (mk !== 'none') {
72
+ // Long series get a marker on every few points, and always on the last.
73
+ var every = Math.ceil(v.length / 40);
74
+ v.forEach(function (y, i) {
75
+ if (i % every === 0 || i === v.length - 1) s += marker(mk, X(i), Y(y), 3.5, ' fill="' + c + '" stroke="' + bg + '" stroke-width="1.5"' + anim(o, 'bzc-a', 0.6 + k * 0.15));
76
+ });
77
+ } else {
78
+ var e = P[P.length - 1];
79
+ s += '<circle cx="' + r2(e[0]) + '" cy="' + r2(e[1]) + '" r="4" fill="' + c + '" stroke="' + bg + '" stroke-width="2"/>';
80
+ }
60
81
  if (bool(o.showValues)) v.forEach(function (y, i) { s += text(X(i), Y(y) - 8, fmt(y, o, true), small(th, 9, 'text-anchor="middle"')); });
61
82
  });
62
83
  if (has(o.goal)) s += goalLine(o, th, px, px + pw, Y(+o.goal));
@@ -69,3 +90,5 @@ export default function line(o, th) {
69
90
  return s;
70
91
  }, { stat: st, summary: S.length + ' series over ' + n + ' points' });
71
92
  }
93
+
94
+ line.table = function (o) { return seriesTable(o); };
@@ -55,3 +55,12 @@ export default function progress(o, th) {
55
55
  return s;
56
56
  }, { stat: st, summary: single ? show(v[0]) + (auto ? '' : ' of ' + fmt(max, o)) : fmt(total, o) + ' of ' + fmt(max, o) });
57
57
  }
58
+
59
+ progress.table = function (o) {
60
+ var v = nums(o.values).map(function (n) { return Math.max(0, n); }), labels = list(o.labels), single = v.length === 1;
61
+ var max = has(o.max) ? num(o.max, 100) : single ? 100 : sum(v);
62
+ if (!(max > 0)) max = 1;
63
+ var auto = !has(o.prefix) && !has(o.suffix) && max === 100 && single;
64
+ if (single) return { head: ['Label', 'Value'], rows: [[labels[0] || 'Progress', fmt(v[0], o) + (auto ? '%' : ' of ' + fmt(max, o))]] };
65
+ return { head: ['Label', 'Value', 'Share'], rows: v.map(function (n, i) { return [labels[i] || '#' + (i + 1), fmt(n, o), Math.round(n / max * 100) + '%']; }) };
66
+ };
@@ -38,3 +38,10 @@ export default function ring(o, th) {
38
38
  return s;
39
39
  }, { stat: st, summary: v.map(function (n, i) { return (labels[i] ? labels[i] + ' ' : '') + show(n); }).join(', ') });
40
40
  }
41
+
42
+ ring.table = function (o) {
43
+ var labels = list(o.labels), max = num(o.max, 100) || 100, auto = !has(o.prefix) && !has(o.suffix) && max === 100;
44
+ return { head: ['Label', 'Value'], rows: nums(o.values).map(function (n, i) {
45
+ return [labels[i] || '#' + (i + 1), fmt(n, o) + (auto ? '%' : ' of ' + fmt(max, o))];
46
+ }) };
47
+ };
@@ -1,4 +1,5 @@
1
- import { r2, clamp, num, has, list, series, bool, fmt, niceScale, text, small, hit, palette, emptyState, parseNotes, goalLine, legend, axisWidth, SANS } from '../core/util.js';
1
+ import { r2, clamp, num, has, list, series, bool, fmt, text, small, hit, palette, emptyState, parseNotes, goalLine } from '../core/util.js';
2
+ import { niceScale, legend, axisWidth, styleOf, marker, SHAPES } from '../core/series.js';
2
3
  import { tile } from '../core/tile.js';
3
4
 
4
5
  /** Least-squares fit. Returns slope, intercept and Pearson's r, or null when x has no spread. */
@@ -32,12 +33,16 @@ export default function scatter(o, th) {
32
33
  var nameOf = function (j) { return names[j] || 'Group ' + (j + 1); };
33
34
  var xo = { decimals: o.decimals, compact: o.compact, lang: o.lang };
34
35
  var notes = parseNotes(o, labels);
36
+ // Each group can have its own point shape; "none" in the list keeps that group's circles.
37
+ var markOf = styleOf(o.markers, SHAPES, 'circle'), bg = o.background || th.tile;
38
+ var shapeOf = function (j) { var m = markOf ? markOf(j) : 'circle'; return m === 'none' ? 'circle' : m; };
39
+ var key = markOf && function (j, x, y) { return marker(shapeOf(j), x + 4, y + 6, 4, ' fill="' + pal[j % pal.length] + '"'); };
35
40
 
36
41
  return tile(o, th, h, function (x0, y0, w, h) {
37
42
  if (!count) return emptyState(th, x0, y0, w, h);
38
43
  var s = '', top = y0;
39
44
  if (multi) {
40
- var lg = legend(groups.map(function (_, j) { return nameOf(j); }), pal, th, x0, top, w);
45
+ var lg = legend(groups.map(function (_, j) { return nameOf(j); }), pal, th, x0, top, w, key);
41
46
  s += lg.svg;
42
47
  top += lg.height;
43
48
  }
@@ -94,8 +99,8 @@ export default function scatter(o, th) {
94
99
  var cx = PX(xv), cy = PY(gr.ys[i]);
95
100
  var tip = (labels[idx] ? labels[idx] + '\n' : multi ? nameOf(j) + '\n' : '') +
96
101
  (has(o.xLabel) ? o.xLabel : 'x') + ': ' + fmt(xv, xo) + '\n' + (has(o.yLabel) ? o.yLabel : 'y') + ': ' + fmt(gr.ys[i], o);
97
- s += '<circle cx="' + r2(cx) + '" cy="' + r2(cy) + '" r="' + r + '" fill="' + c + '" fill-opacity=".85" stroke="' + (o.background || th.tile) + '" stroke-width="1.5"' +
98
- (o.animate ? ' class="bzc-a" style="animation-delay:' + r2(Math.min(0.8, idx * 0.012)) + 's"' : '') + '/>';
102
+ s += marker(shapeOf(j), cx, cy, r, ' fill="' + c + '" fill-opacity=".85" stroke="' + bg + '" stroke-width="1.5"' +
103
+ (o.animate ? ' class="bzc-a" style="animation-delay:' + r2(Math.min(0.8, idx * 0.012)) + 's"' : ''));
99
104
  s += hit(cx - 8, cy - 8, 16, 16, tip);
100
105
  idx++;
101
106
  });
@@ -113,3 +118,15 @@ export default function scatter(o, th) {
113
118
  return s;
114
119
  }, { summary: count + ' points' + (multi ? ' in ' + groups.length + ' groups' : '') });
115
120
  }
121
+
122
+ scatter.table = function (o) {
123
+ var X = series(o.x), Y = series(o.y), names = list(o.series), labels = list(o.labels), multi = X.length > 1 || names.length > 0;
124
+ var xo = { decimals: o.decimals, compact: o.compact, lang: o.lang }, rows = [], idx = 0;
125
+ X.forEach(function (xs, g) {
126
+ var ys = Y[g] || [];
127
+ for (var i = 0; i < Math.min(xs.length, ys.length); i++, idx++) {
128
+ rows.push([labels[idx] || '#' + (idx + 1)].concat(multi ? [names[g] || 'Group ' + (g + 1)] : [], [fmt(xs[i], xo), fmt(ys[i], o)]));
129
+ }
130
+ });
131
+ return { head: ['Point'].concat(multi ? ['Group'] : [], [has(o.xLabel) ? o.xLabel : 'x', has(o.yLabel) ? o.yLabel : 'y']), rows: rows };
132
+ };
@@ -39,3 +39,8 @@ export default function spark(o, th) {
39
39
  return s;
40
40
  }, { stat: st, summary: v.length ? 'latest ' + fmt(v[v.length - 1], o) : 'no data' });
41
41
  }
42
+
43
+ spark.table = function (o) {
44
+ var labels = list(o.labels);
45
+ return { head: ['Label', 'Value'], rows: nums(o.values).map(function (n, i) { return [labels[i] || '#' + (i + 1), fmt(n, o)]; }) };
46
+ };
@@ -0,0 +1,85 @@
1
+ /** Helpers for the charts with axes and several series: line, bars and scatter. Kept apart from util.js so the other charts don't load them. */
2
+ import { r2, list, series, fmt, tw, text, SANS } from './util.js';
3
+
4
+ export function niceScale(min, max, count) {
5
+ if (min === max) {
6
+ if (min === 0) max = 1;
7
+ else { var p = Math.abs(min) * 0.1; min -= p; max += p; }
8
+ }
9
+ var step0 = (max - min) / (count || 4);
10
+ var mag = Math.pow(10, Math.floor(Math.log10(step0)));
11
+ var err = step0 / mag;
12
+ var step = (err <= 1 ? 1 : err <= 2 ? 2 : err <= 5 ? 5 : 10) * mag;
13
+ var lo = Math.floor(min / step) * step, hi = Math.ceil(max / step) * step, ticks = [];
14
+ for (var t = lo; t <= hi + step / 2; t += step) ticks.push(+t.toFixed(10));
15
+ return { lo: lo, hi: hi, ticks: ticks };
16
+ }
17
+
18
+ /**
19
+ * A wrapping row of coloured dots and series names. Returns the SVG and the height it used.
20
+ * key(k, x, y) draws a different swatch, such as a dashed line, and kw is the room it takes before the name.
21
+ */
22
+ export function legend(names, pal, th, x0, y0, w, key, kw) {
23
+ var x = x0, y = y0, s = '', off = kw || 13;
24
+ names.forEach(function (nm, k) {
25
+ var wd = off + tw(nm, 12) + 16;
26
+ if (x > x0 && x + wd - 16 > x0 + w) { x = x0; y += 20; }
27
+ s += (key ? key(k, x, y) : '<circle cx="' + r2(x + 4) + '" cy="' + r2(y + 6) + '" r="4" fill="' + pal[k % pal.length] + '"/>') +
28
+ text(x + off, y + 10, nm, 'fill="' + th.fg + '" font-family="' + SANS + '" font-size="12"');
29
+ x += wd;
30
+ });
31
+ return { svg: s, height: y - y0 + 24 };
32
+ }
33
+
34
+ /** Line dashes, marker shapes and bar patterns, so series differ by more than colour. "auto" hands them out in this order. */
35
+ export var DASHES = { solid: '', dashed: '7 5', dotted: '1 5', dashdot: '9 4 1 4' };
36
+ export var SHAPES = ['circle', 'square', 'triangle', 'diamond'];
37
+ export var PATTERNS = ['solid', 'stripes', 'dots', 'grid'];
38
+
39
+ /**
40
+ * Reads dash, markers or patterns. The bare attribute or "auto" hands out every style in turn; a list sets one per series,
41
+ * repeating like colours do. Returns a function from series index to style name, or null when the option is off.
42
+ */
43
+ export function styleOf(v, names, off) {
44
+ if (v == null || v === false || v === 'none' || v === 'false') return null;
45
+ var l = v === true || v === '' || v === 'auto' ? names : list(v).map(function (s) { return names.indexOf(s) < 0 ? off : s; });
46
+ if (!l.length) return null;
47
+ return function (k) { return l[k % l.length]; };
48
+ }
49
+
50
+ /** A point marker centred on x, y. attrs carries the fill and stroke. */
51
+ export function marker(kind, x, y, r, attrs) {
52
+ var a = r * 1.15, b = r * 0.85;
53
+ if (kind === 'square') return '<rect x="' + r2(x - b) + '" y="' + r2(y - b) + '" width="' + r2(2 * b) + '" height="' + r2(2 * b) + '" rx="1"' + attrs + '/>';
54
+ if (kind === 'triangle') return '<path d="M' + r2(x) + ',' + r2(y - a) + 'L' + r2(x + a) + ',' + r2(y + b) + 'L' + r2(x - a) + ',' + r2(y + b) + 'Z"' + attrs + '/>';
55
+ if (kind === 'diamond') return '<path d="M' + r2(x) + ',' + r2(y - a * 1.1) + 'L' + r2(x + a) + ',' + r2(y) + 'L' + r2(x) + ',' + r2(y + a * 1.1) + 'L' + r2(x - a) + ',' + r2(y) + 'Z"' + attrs + '/>';
56
+ return '<circle cx="' + r2(x) + '" cy="' + r2(y) + '" r="' + r2(r) + '"' + attrs + '/>';
57
+ }
58
+
59
+ /** A fill pattern in colour c, with stripes, dots or a grid drawn in the tile colour so they show on any colour. */
60
+ export function pattern(id, kind, c, bg) {
61
+ var ink = ' stroke="' + bg + '" stroke-opacity=".55" stroke-width="1.5"';
62
+ return '<pattern id="' + id + '" width="6" height="6" patternUnits="userSpaceOnUse"' + (kind === 'dots' ? '' : ' patternTransform="rotate(45)"') +
63
+ '><rect width="6" height="6" fill="' + c + '"/>' +
64
+ (kind === 'dots' ? '<circle cx="3" cy="3" r="1.3" fill="' + bg + '" fill-opacity=".55"/>' : '<path d="M3,0V6' + (kind === 'grid' ? 'M0,3H6' : '') + '"' + ink + '/>') +
65
+ '</pattern>';
66
+ }
67
+
68
+ /** Value axis: gridlines plus tick labels on the left. Returns the axis width it reserved. */
69
+ export function axisWidth(ticks) {
70
+ return Math.max.apply(null, ticks.map(function (t) { return tw(t, 10, true); })) + 10;
71
+ }
72
+
73
+ /** The data table for charts with labels and one or more series. total adds a column adding each row up. */
74
+ export function seriesTable(o, total) {
75
+ var S = series(o.values), names = list(o.series), labels = list(o.labels), rows = [];
76
+ var n = Math.max.apply(null, [0].concat(S.map(function (s) { return s.length; })));
77
+ for (var i = 0; i < n; i++) {
78
+ var row = [labels[i] || '#' + (i + 1)], t = 0;
79
+ S.forEach(function (s) { row.push(i < s.length ? fmt(s[i], o) : ''); t += i < s.length ? s[i] : 0; });
80
+ if (total) row.push(fmt(t, o));
81
+ rows.push(row);
82
+ }
83
+ var head = S.length > 1 || names.length ? S.map(function (_, k) { return names[k] || 'Series ' + (k + 1); }) : ['Value'];
84
+ return { head: ['Label'].concat(head, total ? ['Total'] : []), rows: rows };
85
+ }
package/src/core/util.js CHANGED
@@ -46,20 +46,6 @@ export function fmt(n, o, short) {
46
46
  return (n < 0 ? '−' : '') + (o.prefix || '') + s + (o.suffix || '');
47
47
  }
48
48
 
49
- export function niceScale(min, max, count) {
50
- if (min === max) {
51
- if (min === 0) max = 1;
52
- else { var p = Math.abs(min) * 0.1; min -= p; max += p; }
53
- }
54
- var step0 = (max - min) / (count || 4);
55
- var mag = Math.pow(10, Math.floor(Math.log10(step0)));
56
- var err = step0 / mag;
57
- var step = (err <= 1 ? 1 : err <= 2 ? 2 : err <= 5 ? 5 : 10) * mag;
58
- var lo = Math.floor(min / step) * step, hi = Math.ceil(max / step) * step, ticks = [];
59
- for (var t = lo; t <= hi + step / 2; t += step) ticks.push(+t.toFixed(10));
60
- return { lo: lo, hi: hi, ticks: ticks };
61
- }
62
-
63
49
  export function pathOf(pts, straight) {
64
50
  if (!pts.length) return '';
65
51
  var d = 'M' + r2(pts[0][0]) + ',' + r2(pts[0][1]);
@@ -122,21 +108,3 @@ export function statOf(v, o, fallback) {
122
108
  if (k === 'last' && v.length > 1 && o.delta !== 'none') delta = v[v.length - 1] - v[0];
123
109
  return { val: map[k], delta: delta };
124
110
  }
125
-
126
- /** A wrapping row of coloured dots and series names. Returns the SVG and the height it used. */
127
- export function legend(names, pal, th, x0, y0, w) {
128
- var x = x0, y = y0, s = '';
129
- names.forEach(function (nm, k) {
130
- var wd = 13 + tw(nm, 12) + 16;
131
- if (x > x0 && x + wd - 16 > x0 + w) { x = x0; y += 20; }
132
- s += '<circle cx="' + r2(x + 4) + '" cy="' + r2(y + 6) + '" r="4" fill="' + pal[k % pal.length] + '"/>' +
133
- text(x + 13, y + 10, nm, 'fill="' + th.fg + '" font-family="' + SANS + '" font-size="12"');
134
- x += wd;
135
- });
136
- return { svg: s, height: y - y0 + 24 };
137
- }
138
-
139
- /** Value axis: gridlines plus tick labels on the left. Returns the axis width it reserved. */
140
- export function axisWidth(ticks) {
141
- return Math.max.apply(null, ticks.map(function (t) { return tw(t, 10, true); })) + 10;
142
- }
package/src/element.js CHANGED
@@ -1,5 +1,5 @@
1
1
  /** The custom element shared by every chart tag. */
2
- import { render, toStatic } from './render.js';
2
+ import { render, table, toStatic } from './render.js';
3
3
  import { MONO, SANS, esc, clamp } from './core/util.js';
4
4
  import { THEMES } from './core/themes.js';
5
5
 
@@ -7,7 +7,7 @@ export const ATTRIBUTES = [
7
7
  'type', 'values', 'labels', 'series', 'heading', 'prefix', 'suffix', 'decimals', 'compact', 'color', 'theme', 'frame',
8
8
  'width', 'height', 'goal', 'goal-label', 'notes', 'show-values', 'stat', 'delta', 'delta-label', 'max', 'min', 'value',
9
9
  'zones', 'horizontal', 'straight', 'zero', 'start', 'lang', 'corners', 'background', 'center-label', 'highlight',
10
- 'x', 'y', 'x-label', 'y-label', 'trend', 'static', 'no-tooltips', 'stacked', 'angle-labels',
10
+ 'x', 'y', 'x-label', 'y-label', 'trend', 'static', 'no-tooltips', 'stacked', 'angle-labels', 'dash', 'markers', 'patterns',
11
11
  ];
12
12
 
13
13
  const HOST_CSS =
@@ -15,7 +15,15 @@ const HOST_CSS =
15
15
  'svg{display:block;max-width:100%;height:auto;overflow:visible}' +
16
16
  `.tip{position:absolute;left:0;top:0;pointer-events:none;background:#0b0b10;color:#f2f1f7;font:600 12px/1.5 ${MONO};` +
17
17
  'padding:7px 10px;border-radius:10px;white-space:pre;opacity:0;transform:translate(-50%,calc(-100% - 12px));transition:opacity .12s;z-index:3;' +
18
- 'border:1px solid #2a2935;box-shadow:0 8px 24px rgba(0,0,0,.35)}.tip.on{opacity:1}';
18
+ 'border:1px solid #2a2935;box-shadow:0 8px 24px rgba(0,0,0,.35)}.tip.on{opacity:1}' +
19
+ // The focus ring is white with a black edge, so it shows on light and dark pages alike. Then the outline on the
20
+ // value the arrow keys are on, and text only screen readers get.
21
+ '.c{outline:none}.c:focus-visible{outline:2px solid #fff;outline-offset:3px;box-shadow:0 0 0 7px #000}.k{stroke:currentColor;stroke-width:2;stroke-opacity:1}' +
22
+ '.sr{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset(50%);white-space:nowrap}' +
23
+ '@media (forced-colors:active){.c:focus-visible{outline-color:Highlight}}';
24
+
25
+ // Arrow keys move one value along; on a heatmap, left and right move a week (one column) and up and down a day.
26
+ const STEPS = { ArrowLeft: -1, ArrowUp: -1, ArrowRight: 1, ArrowDown: 1 };
19
27
 
20
28
  const darkQuery = () => (typeof matchMedia === 'function' ? matchMedia('(prefers-color-scheme: dark)') : null);
21
29
 
@@ -55,10 +63,16 @@ export class BezelChart extends Base {
55
63
 
56
64
  connectedCallback() {
57
65
  if (!this.shadowRoot) {
58
- this.attachShadow({ mode: 'open' });
66
+ // The chart, the tooltip, the screen reader announcements and the data table stay in place between redraws,
67
+ // so a focused chart keeps its focus when it resizes or changes theme.
68
+ this.attachShadow({ mode: 'open' }).innerHTML = `<style>${HOST_CSS}</style><div class="c" role="group" aria-roledescription="chart"></div>` +
69
+ '<p class="sr" id="h">Use the arrow keys to step through the values.</p><div class="tip" aria-hidden="true"></div><div class="sr" aria-live="polite"></div><div class="sr"></div>';
70
+ [, this.#box, , this.#tipEl, this.#live, this.#table] = this.shadowRoot.children;
59
71
  this.shadowRoot.addEventListener('pointermove', (e) => this.#tip(e));
60
72
  this.shadowRoot.addEventListener('pointerdown', (e) => this.#tip(e));
61
- this.addEventListener('pointerleave', () => this.shadowRoot.querySelector('.tip')?.classList.remove('on'));
73
+ this.addEventListener('pointerleave', () => this.#tipEl.classList.remove('on'));
74
+ this.#box.addEventListener('keydown', (e) => this.#key(e));
75
+ this.#box.addEventListener('blur', () => this.#clear());
62
76
  }
63
77
  live.add(this);
64
78
  this.#dark = darkQuery();
@@ -122,6 +136,11 @@ export class BezelChart extends Base {
122
136
  }
123
137
 
124
138
  #ro = null;
139
+ #box = null;
140
+ #tipEl = null;
141
+ #live = null;
142
+ #table = null;
143
+ #at = -1;
125
144
  #dark = null;
126
145
  #width = 0;
127
146
  #rendered = false;
@@ -137,33 +156,125 @@ export class BezelChart extends Base {
137
156
  this.#width = w > 0 ? w : 360;
138
157
  const o = this.options;
139
158
  o.animate = !this.#rendered && !this.hasAttribute('static');
140
- let svg;
159
+ let svg, data = null;
141
160
  try {
142
161
  svg = render(this.chartType, o);
162
+ data = table(this.chartType, o);
143
163
  } catch (err) {
144
164
  svg = `<p style="font:13px ${SANS};color:#d63f7c">${esc(err.message)}</p>`;
145
165
  }
146
166
  // The live chart has its own tooltips, so drop the <title> browsers would show as a second one.
147
- // aria-label still names it for screen readers, and toSVG() keeps the <title> for static images.
148
- svg = svg.replace(/<title>[\s\S]*?<\/title>/, '');
149
- this.shadowRoot.innerHTML = `<style>${HOST_CSS}</style>${svg}<div class="tip" role="status"></div>`;
167
+ // toSVG() keeps the <title> for static images.
168
+ const box = this.#box;
169
+ box.innerHTML = svg.replace(/<title>[\s\S]*?<\/title>/, '');
170
+ // The focusable wrapper carries the chart's name, so screen readers hear it once, not twice.
171
+ const img = box.querySelector('svg');
172
+ if (img) {
173
+ box.setAttribute('aria-label', img.getAttribute('aria-label'));
174
+ img.removeAttribute('role');
175
+ img.removeAttribute('aria-label');
176
+ img.setAttribute('aria-hidden', 'true');
177
+ } else {
178
+ box.removeAttribute('aria-label');
179
+ }
180
+ // Charts with tooltips take keyboard focus too, so keyboard users get the same exact values as mouse users.
181
+ const keys = !!img && !this.hasAttribute('no-tooltips');
182
+ if (keys) {
183
+ box.setAttribute('tabindex', '0');
184
+ box.setAttribute('aria-describedby', 'h');
185
+ } else {
186
+ box.removeAttribute('tabindex');
187
+ box.removeAttribute('aria-describedby');
188
+ }
189
+ box.style.color = THEMES[o.theme === 'light' || o.theme === 'dark' ? o.theme : o.autoTheme].fg;
190
+ box.style.borderRadius = `${o.frame === 'none' ? 4 : clamp(+o.corners || 22, 0, 60)}px`;
191
+ this.#table.innerHTML = data && data.rows.length ? tableHTML(o.heading, data) : '';
192
+ if (this.#at >= 0 && keys && this.shadowRoot.activeElement === box) this.#show(this.#at);
193
+ else this.#at = -1;
150
194
  this.#rendered = true;
151
195
  this.dispatchEvent(new CustomEvent('bezel-render', { bubbles: true }));
152
196
  }
153
197
 
154
198
  #tip(e) {
155
- const tip = this.shadowRoot.querySelector('.tip');
156
- if (!tip || this.hasAttribute('no-tooltips')) return;
199
+ if (this.hasAttribute('no-tooltips')) return;
157
200
  const target = e.target?.closest?.('[data-tip]');
158
201
  if (!target) {
159
- tip.classList.remove('on');
202
+ this.#tipEl.classList.remove('on');
160
203
  return;
161
204
  }
162
- tip.textContent = target.getAttribute('data-tip');
163
- const box = this.getBoundingClientRect();
205
+ this.#place(target, e.clientX, e.clientY);
206
+ }
207
+
208
+ /** Shows the tooltip for a mark at a point on screen. */
209
+ #place(mark, x, y) {
210
+ const tip = this.#tipEl;
211
+ tip.textContent = mark.getAttribute('data-tip');
212
+ const box = this.getBoundingClientRect?.() || { left: 0, top: 0, width: 0 };
164
213
  const half = tip.offsetWidth / 2;
165
- tip.style.left = `${clamp(e.clientX - box.left, half, Math.max(half, box.width - half))}px`;
166
- tip.style.top = `${e.clientY - box.top}px`;
214
+ tip.style.left = `${clamp(x - box.left, half, Math.max(half, box.width - half))}px`;
215
+ tip.style.top = `${y - box.top}px`;
167
216
  tip.classList.add('on');
168
217
  }
218
+
219
+ /** Every value the arrow keys visit, in order. Donuts mark their slices data-skip, as the legend rows repeat them. */
220
+ #marks() {
221
+ return [...this.#box.querySelectorAll('[data-tip]:not([data-skip])')];
222
+ }
223
+
224
+ #key(e) {
225
+ const marks = this.#marks(), n = marks.length;
226
+ if (!n) return;
227
+ let i = this.#at, step = STEPS[e.key];
228
+ if (step) {
229
+ if (this.chartType === 'heatmap' && /Left|Right/.test(e.key)) step *= 7;
230
+ i = i < 0 ? (step > 0 ? 0 : n - 1) : clamp(i + step, 0, n - 1);
231
+ } else if (e.key === 'Home' || e.key === 'End') {
232
+ i = e.key === 'Home' ? 0 : n - 1;
233
+ } else {
234
+ if (e.key === 'Escape') this.#clear();
235
+ return;
236
+ }
237
+ e.preventDefault();
238
+ this.#show(i, true);
239
+ }
240
+
241
+ /** Outlines value i, shows its tooltip and, when say is set, reads it out. */
242
+ #show(i, say) {
243
+ const marks = this.#marks();
244
+ if (!marks.length) return;
245
+ i = clamp(i, 0, marks.length - 1);
246
+ this.#box.querySelector('.k')?.classList.remove('k');
247
+ const mark = marks[i], r = mark.getBoundingClientRect?.() || { left: 0, top: 0, width: 0 };
248
+ mark.classList.add('k');
249
+ this.#at = i;
250
+ this.#place(mark, r.left + r.width / 2, r.top);
251
+ if (say) this.#live.textContent = `${mark.getAttribute('data-tip').replace(/\n/g, ', ')}. ${i + 1} of ${marks.length}`;
252
+ }
253
+
254
+ #clear() {
255
+ this.#box.querySelector('.k')?.classList.remove('k');
256
+ this.#tipEl.classList.remove('on');
257
+ this.#live.textContent = '';
258
+ this.#at = -1;
259
+ }
260
+ }
261
+
262
+ /** The visually hidden table screen readers can browse: the chart's numbers in rows and columns. */
263
+ function tableHTML(caption, { head, rows }) {
264
+ const cells = (row) => row.map((c, i) => (i ? `<td>${esc(c)}</td>` : `<th scope="row">${esc(c)}</th>`)).join('');
265
+ return `<table><caption>${esc(caption || 'Chart data')}</caption><thead><tr>${head.map((h) => `<th scope="col">${esc(h)}</th>`).join('')}</tr></thead>` +
266
+ `<tbody>${rows.map((r) => `<tr>${cells(r)}</tr>`).join('')}</tbody></table>`;
267
+ }
268
+
269
+ /** Defines <bezel-type>, plus <bezel-chart> if no chart has yet. Safe to call twice, and does nothing outside a browser. */
270
+ export function define(type) {
271
+ if (typeof customElements === 'undefined' || typeof HTMLElement === 'undefined') return;
272
+ if (!customElements.get(`bezel-${type}`)) {
273
+ customElements.define(`bezel-${type}`, class extends BezelChart {
274
+ get chartType() {
275
+ return type;
276
+ }
277
+ });
278
+ }
279
+ if (!customElements.get('bezel-chart')) customElements.define('bezel-chart', class extends BezelChart {});
169
280
  }
@@ -0,0 +1,10 @@
1
+ /** <bezel-bars> on its own: import 'bezel-charts/bars', or load dist/charts/bars.js. */
2
+ import bars from '../charts/bars.js';
3
+ import { register } from '../render.js';
4
+ import { define } from '../element.js';
5
+
6
+ register('bars', bars);
7
+ define('bars');
8
+
9
+ export { render, toSVG } from '../render.js';
10
+ export { BezelChart } from '../element.js';
@@ -0,0 +1,10 @@
1
+ /** <bezel-donut> on its own: import 'bezel-charts/donut', or load dist/charts/donut.js. */
2
+ import donut from '../charts/donut.js';
3
+ import { register } from '../render.js';
4
+ import { define } from '../element.js';
5
+
6
+ register('donut', donut);
7
+ define('donut');
8
+
9
+ export { render, toSVG } from '../render.js';
10
+ export { BezelChart } from '../element.js';