Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

UI: Add some simple accessibility labels for line charts #4718

Merged
merged 5 commits into from
Oct 17, 2018
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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);
});