Skip to content

Commit

Permalink
Merge pull request #4718 from hashicorp/f-ui-a11y-line-chart
Browse files Browse the repository at this point in the history
UI: Add some simple accessibility labels for line charts
  • Loading branch information
DingoEatingFuzz committed Oct 17, 2018
2 parents fd415fe + 35b933a commit f02d99a
Show file tree
Hide file tree
Showing 6 changed files with 95 additions and 15 deletions.
19 changes: 19 additions & 0 deletions ui/app/components/line-chart.js
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ export default Component.extend(WindowResizable, {
timeseries: false,
chartClass: 'is-primary',

title: 'Line Chart',
description: null,

// Private Properties

width: 0,
Expand Down Expand Up @@ -96,6 +99,22 @@ export default Component.extend(WindowResizable, {
return scale;
}),

xRange: computed('data.[]', 'xFormat', 'xProp', 'timeseries', function() {
const { xProp, timeseries, data } = this.getProperties('xProp', 'timeseries', 'data');
const range = d3Array.extent(data, d => d[xProp]);
const formatter = this.xFormat(timeseries);

return range.map(formatter);
}),

yRange: computed('data.[]', 'yFormat', 'yProp', function() {
const yProp = this.get('yProp');
const range = d3Array.extent(this.get('data'), d => d[yProp]);
const formatter = this.yFormat();

return range.map(formatter);
}),

yScale: computed('data.[]', 'yProp', 'xAxisOffset', function() {
const yProp = this.get('yProp');
let max = d3Array.max(this.get('data'), d => d[yProp]) || 1;
Expand Down
15 changes: 15 additions & 0 deletions ui/app/components/stats-time-series.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import d3Format from 'd3-format';
import d3Scale from 'd3-scale';
import d3Array from 'd3-array';
import LineChart from 'nomad-ui/components/line-chart';
import formatDuration from 'nomad-ui/utils/format-duration';

export default LineChart.extend({
xProp: 'timestamp',
Expand All @@ -19,6 +20,20 @@ export default LineChart.extend({
return d3Format.format('.1~%');
},

// Specific a11y descriptors
title: 'Stats Time Series Chart',

description: computed('data.[]', 'xProp', 'yProp', function() {
const { xProp, yProp, data } = this.getProperties('data', 'xProp', 'yProp');
const yRange = d3Array.extent(data, d => d[yProp]);
const xRange = d3Array.extent(data, d => d[xProp]);
const yFormatter = this.yFormat();

const duration = formatDuration(xRange[1] - xRange[0], 'ms', true);

return `Time series data for the last ${duration}, with values ranging from ${yFormatter(yRange[0])} to ${yFormatter(yRange[1])}`;
}),

xScale: computed('data.[]', 'xProp', 'timeseries', 'yAxisOffset', function() {
const xProp = this.get('xProp');
const scale = this.get('timeseries') ? d3Scale.scaleTime() : d3Scale.scaleLinear();
Expand Down
4 changes: 2 additions & 2 deletions ui/app/helpers/format-duration.js
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
import Helper from '@ember/component/helper';
import formatDuration from '../utils/format-duration';

function formatDurationHelper([duration], { units }) {
return formatDuration(duration, units);
function formatDurationHelper([duration], { units, longForm }) {
return formatDuration(duration, units, longForm);
}

export default Helper.helper(formatDurationHelper);
15 changes: 12 additions & 3 deletions ui/app/templates/components/line-chart.hbs
Original file line number Diff line number Diff line change
@@ -1,4 +1,13 @@
<svg data-test-line-chart>
<svg data-test-line-chart role="img" aria-labelledby="{{concat "title-" elementId}} {{concat "desc-" elementId}}">
<title id="{{concat "title-" elementId}}">{{title}}</title>
<description id="{{concat "desc-" elementId}}">
{{#if description}}
{{description}}
{{else}}
X-axis values range from {{xRange.firstObject}} to {{xRange.lastObject}},
and Y-axis values range from {{yRange.firstObject}} to {{yRange.lastObject}}.
{{/if}}
</description>
<defs>
<linearGradient x1="0" x2="0" y1="0" y2="1" class="{{chartClass}}" id="{{fillId}}">
<stop class="start" offset="0%" />
Expand All @@ -14,8 +23,8 @@
<rect class="area" x="0" y="0" width="{{yAxisOffset}}" height="{{xAxisOffset}}" fill="url(#{{fillId}})" clip-path="url(#{{maskId}})" />
<rect class="hover-target" x="0" y="0" width="{{yAxisOffset}}" height="{{xAxisOffset}}" />
</g>
<g class="x-axis axis" transform="translate(0, {{xAxisOffset}})"></g>
<g class="y-axis axis" transform="translate({{yAxisOffset}}, 0)"></g>
<g aria-hidden="true" class="x-axis axis" transform="translate(0, {{xAxisOffset}})"></g>
<g aria-hidden="true" class="y-axis axis" transform="translate({{yAxisOffset}}, 0)"></g>
</svg>
<div class="chart-tooltip is-snappy {{if isActive "active" "inactive"}}" style={{tooltipStyle}}>
<p>
Expand Down
50 changes: 40 additions & 10 deletions ui/app/utils/format-duration.js
Original file line number Diff line number Diff line change
@@ -1,18 +1,52 @@
import moment from 'moment';

/**
* Metadata for all unit types
* name: identifier for the unit. Also maps to moment methods when applicable
* suffix: the preferred suffix for a unit
* inMoment: whether or not moment can be used to compute this unit value
* pluralizable: whether or not this suffix can be pluralized
* longSuffix: the suffix to use instead of suffix when longForm is true
*/
const allUnits = [
{ name: 'years', suffix: 'year', inMoment: true, pluralizable: true },
{ name: 'months', suffix: 'month', inMoment: true, pluralizable: true },
{ name: 'days', suffix: 'day', inMoment: true, pluralizable: true },
{ name: 'hours', suffix: 'h', inMoment: true, pluralizable: false },
{ name: 'minutes', suffix: 'm', inMoment: true, pluralizable: false },
{ name: 'seconds', suffix: 's', inMoment: true, pluralizable: false },
{ name: 'hours', suffix: 'h', longSuffix: 'hour', inMoment: true, pluralizable: false },
{ name: 'minutes', suffix: 'm', longSuffix: 'minute', inMoment: true, pluralizable: false },
{ name: 'seconds', suffix: 's', longSuffix: 'second', inMoment: true, pluralizable: false },
{ name: 'milliseconds', suffix: 'ms', inMoment: true, pluralizable: false },
{ name: 'microseconds', suffix: 'µs', inMoment: false, pluralizable: false },
{ name: 'nanoseconds', suffix: 'ns', inMoment: false, pluralizable: false },
];

export default function formatDuration(duration = 0, units = 'ns') {
const pluralizeUnits = (amount, unit, longForm) => {
let suffix;

if (longForm && unit.longSuffix) {
// Long form means always using full words (seconds insteand of s) which means
// pluralization is necessary.
suffix = amount === 1 ? unit.longSuffix : unit.longSuffix.pluralize();
} else {
// In the normal case, only pluralize based on the pluralizable flag
suffix = amount === 1 || !unit.pluralizable ? unit.suffix : unit.suffix.pluralize();
}

// A space should go between the value and the unit when the unit is a full word
// 300ns vs. 1 hour
const addSpace = unit.pluralizable || (longForm && unit.longSuffix);
return `${amount}${addSpace ? ' ' : ''}${suffix}`;
};

/**
* Format a Duration at a preferred precision
*
* @param {Number} duration The duration to format
* @param {String} units The units for the duration. Default to nanoseconds.
* @param {Boolean} longForm Whether or not to expand single character suffixes,
* used to ensure screen readers correctly read units.
*/
export default function formatDuration(duration = 0, units = 'ns', longForm = false) {
const durationParts = {};

// Moment only handles up to millisecond precision.
Expand Down Expand Up @@ -46,9 +80,7 @@ export default function formatDuration(duration = 0, units = 'ns') {
const displayParts = allUnits.reduce((parts, unitType) => {
if (durationParts[unitType.name]) {
const count = durationParts[unitType.name];
const suffix =
count === 1 || !unitType.pluralizable ? unitType.suffix : unitType.suffix.pluralize();
parts.push(`${count}${unitType.pluralizable ? ' ' : ''}${suffix}`);
parts.push(pluralizeUnits(count, unitType, longForm));
}
return parts;
}, []);
Expand All @@ -58,7 +90,5 @@ export default function formatDuration(duration = 0, units = 'ns') {
}

// When the duration is 0, show 0 in terms of `units`
const unitTypeForUnits = allUnits.findBy('suffix', units);
const suffix = unitTypeForUnits.pluralizable ? units.pluralize() : units;
return `0${unitTypeForUnits.pluralizable ? ' ' : ''}${suffix}`;
return pluralizeUnits(0, allUnits.findBy('suffix', units), longForm);
}
7 changes: 7 additions & 0 deletions ui/tests/unit/utils/format-duration-test.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,3 +26,10 @@ test('When duration is 0, 0 is shown in terms of the units provided to the funct
assert.equal(formatDuration(0), '0ns', 'formatDuration(0) -> 0ns');
assert.equal(formatDuration(0, 'year'), '0 years', 'formatDuration(0, "year") -> 0 years');
});

test('The longForm option expands suffixes to words', function(assert) {
const expectation1 = '3 seconds 20ms';
const expectation2 = '5 hours 59 minutes';
assert.equal(formatDuration(3020, 'ms', true), expectation1, expectation1);
assert.equal(formatDuration(60 * 5 + 59, 'm', true), expectation2, expectation2);
});

0 comments on commit f02d99a

Please sign in to comment.