1154 lines
34 KiB
JavaScript
1154 lines
34 KiB
JavaScript
/* *
|
|
*
|
|
* (c) 2009-2019 Øystein Moseng
|
|
*
|
|
* Accessibility component for series and points.
|
|
*
|
|
* License: www.highcharts.com/license
|
|
*
|
|
* */
|
|
|
|
'use strict';
|
|
|
|
import H from '../../../parts/Globals.js';
|
|
import AccessibilityComponent from '../AccessibilityComponent.js';
|
|
import KeyboardNavigationHandler from '../KeyboardNavigationHandler.js';
|
|
|
|
var merge = H.merge,
|
|
pick = H.pick;
|
|
|
|
|
|
/*
|
|
* Set for which series types it makes sense to move to the closest point with
|
|
* up/down arrows, and which series types should just move to next series.
|
|
*/
|
|
H.Series.prototype.keyboardMoveVertical = true;
|
|
['column', 'pie'].forEach(function (type) {
|
|
if (H.seriesTypes[type]) {
|
|
H.seriesTypes[type].prototype.keyboardMoveVertical = false;
|
|
}
|
|
});
|
|
|
|
|
|
/**
|
|
* Get the index of a point in a series. This is needed when using e.g. data
|
|
* grouping.
|
|
*
|
|
* @private
|
|
* @function getPointIndex
|
|
*
|
|
* @param {Highcharts.Point} point
|
|
* The point to find index of.
|
|
*
|
|
* @return {number}
|
|
* The index in the series.points array of the point.
|
|
*/
|
|
function getPointIndex(point) {
|
|
var index = point.index,
|
|
points = point.series.points,
|
|
i = points.length;
|
|
|
|
if (points[index] !== point) {
|
|
while (i--) {
|
|
if (points[i] === point) {
|
|
return i;
|
|
}
|
|
}
|
|
} else {
|
|
return index;
|
|
}
|
|
}
|
|
|
|
|
|
/**
|
|
* Determine if a series should be skipped
|
|
*
|
|
* @private
|
|
* @function isSkipSeries
|
|
*
|
|
* @param {Highcharts.Series} series
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
function isSkipSeries(series) {
|
|
var a11yOptions = series.chart.options.accessibility,
|
|
seriesA11yOptions = series.options.accessibility || {},
|
|
seriesKbdNavOptions = seriesA11yOptions.keyboardNavigation;
|
|
|
|
return seriesKbdNavOptions && seriesKbdNavOptions.enabled === false ||
|
|
seriesA11yOptions.enabled === false ||
|
|
series.options.enableMouseTracking === false || // #8440
|
|
!series.visible ||
|
|
// Skip all points in a series where pointDescriptionThreshold is
|
|
// reached
|
|
(a11yOptions.pointDescriptionThreshold &&
|
|
a11yOptions.pointDescriptionThreshold <= series.points.length);
|
|
}
|
|
|
|
|
|
/**
|
|
* Determine if a point should be skipped
|
|
*
|
|
* @private
|
|
* @function isSkipPoint
|
|
*
|
|
* @param {Highcharts.Point} point
|
|
*
|
|
* @return {boolean}
|
|
*/
|
|
function isSkipPoint(point) {
|
|
var a11yOptions = point.series.chart.options.accessibility;
|
|
|
|
return point.isNull && a11yOptions.keyboardNavigation.skipNullPoints ||
|
|
point.visible === false ||
|
|
isSkipSeries(point.series);
|
|
}
|
|
|
|
|
|
/**
|
|
* Get the point in a series that is closest (in distance) to a reference point.
|
|
* Optionally supply weight factors for x and y directions.
|
|
*
|
|
* @private
|
|
* @function getClosestPoint
|
|
*
|
|
* @param {Highcharts.Point} point
|
|
* @param {Highcharts.Series} series
|
|
* @param {number} [xWeight]
|
|
* @param {number} [yWeight]
|
|
*
|
|
* @return {Highcharts.Point|undefined}
|
|
*/
|
|
function getClosestPoint(point, series, xWeight, yWeight) {
|
|
var minDistance = Infinity,
|
|
dPoint,
|
|
minIx,
|
|
distance,
|
|
i = series.points.length;
|
|
|
|
if (point.plotX === undefined || point.plotY === undefined) {
|
|
return;
|
|
}
|
|
while (i--) {
|
|
dPoint = series.points[i];
|
|
if (dPoint.plotX === undefined || dPoint.plotY === undefined) {
|
|
continue;
|
|
}
|
|
distance = (point.plotX - dPoint.plotX) *
|
|
(point.plotX - dPoint.plotX) * (xWeight || 1) +
|
|
(point.plotY - dPoint.plotY) *
|
|
(point.plotY - dPoint.plotY) * (yWeight || 1);
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
minIx = i;
|
|
}
|
|
}
|
|
return minIx !== undefined && series.points[minIx];
|
|
}
|
|
|
|
|
|
/**
|
|
* Highlights a point (show tooltip and display hover state).
|
|
*
|
|
* @private
|
|
* @function Highcharts.Point#highlight
|
|
*
|
|
* @return {Highcharts.Point}
|
|
* This highlighted point.
|
|
*/
|
|
H.Point.prototype.highlight = function () {
|
|
var chart = this.series.chart;
|
|
|
|
if (!this.isNull) {
|
|
this.onMouseOver(); // Show the hover marker and tooltip
|
|
} else {
|
|
if (chart.tooltip) {
|
|
chart.tooltip.hide(0);
|
|
}
|
|
// Don't call blur on the element, as it messes up the chart div's focus
|
|
}
|
|
|
|
// We focus only after calling onMouseOver because the state change can
|
|
// change z-index and mess up the element.
|
|
if (this.graphic) {
|
|
chart.setFocusToElement(this.graphic);
|
|
}
|
|
|
|
chart.highlightedPoint = this;
|
|
return this;
|
|
};
|
|
|
|
|
|
/**
|
|
* Function to highlight next/previous point in chart.
|
|
*
|
|
* @private
|
|
* @function Highcharts.Chart#highlightAdjacentPoint
|
|
*
|
|
* @param {boolean} next
|
|
* Flag for the direction.
|
|
*
|
|
* @return {Highcharts.Point|boolean}
|
|
* Returns highlighted point on success, false on failure (no adjacent
|
|
* point to highlight in chosen direction).
|
|
*/
|
|
H.Chart.prototype.highlightAdjacentPoint = function (next) {
|
|
var chart = this,
|
|
series = chart.series,
|
|
curPoint = chart.highlightedPoint,
|
|
curPointIndex = curPoint && getPointIndex(curPoint) || 0,
|
|
curPoints = curPoint && curPoint.series.points,
|
|
lastSeries = chart.series && chart.series[chart.series.length - 1],
|
|
lastPoint = lastSeries && lastSeries.points &&
|
|
lastSeries.points[lastSeries.points.length - 1],
|
|
newSeries,
|
|
newPoint;
|
|
|
|
// If no points, return false
|
|
if (!series[0] || !series[0].points) {
|
|
return false;
|
|
}
|
|
|
|
if (!curPoint) {
|
|
// No point is highlighted yet. Try first/last point depending on move
|
|
// direction
|
|
newPoint = next ? series[0].points[0] : lastPoint;
|
|
} else {
|
|
// We have a highlighted point.
|
|
// Grab next/prev point & series
|
|
newSeries = series[curPoint.series.index + (next ? 1 : -1)];
|
|
newPoint = curPoints[curPointIndex + (next ? 1 : -1)];
|
|
if (!newPoint && newSeries) {
|
|
// Done with this series, try next one
|
|
newPoint = newSeries.points[next ? 0 : newSeries.points.length - 1];
|
|
}
|
|
|
|
// If there is no adjacent point, we return false
|
|
if (!newPoint) {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Recursively skip points
|
|
if (isSkipPoint(newPoint)) {
|
|
// If we skip this whole series, move to the end of the series before we
|
|
// recurse, just to optimize
|
|
newSeries = newPoint.series;
|
|
if (isSkipSeries(newSeries)) {
|
|
chart.highlightedPoint = next ?
|
|
newSeries.points[newSeries.points.length - 1] :
|
|
newSeries.points[0];
|
|
} else {
|
|
// Otherwise, just move one point
|
|
chart.highlightedPoint = newPoint;
|
|
}
|
|
// Retry
|
|
return chart.highlightAdjacentPoint(next);
|
|
}
|
|
|
|
// There is an adjacent point, highlight it
|
|
return newPoint.highlight();
|
|
};
|
|
|
|
|
|
/**
|
|
* Highlight first valid point in a series. Returns the point if successfully
|
|
* highlighted, otherwise false. If there is a highlighted point in the series,
|
|
* use that as starting point.
|
|
*
|
|
* @private
|
|
* @function Highcharts.Series#highlightFirstValidPoint
|
|
*
|
|
* @return {Highcharts.Point|boolean}
|
|
*/
|
|
H.Series.prototype.highlightFirstValidPoint = function () {
|
|
var curPoint = this.chart.highlightedPoint,
|
|
start = (curPoint && curPoint.series) === this ?
|
|
getPointIndex(curPoint) :
|
|
0,
|
|
points = this.points,
|
|
len = points.length;
|
|
|
|
if (points && len) {
|
|
for (var i = start; i < len; ++i) {
|
|
if (!isSkipPoint(points[i])) {
|
|
return points[i].highlight();
|
|
}
|
|
}
|
|
for (var j = start; j >= 0; --j) {
|
|
if (!isSkipPoint(points[j])) {
|
|
return points[j].highlight();
|
|
}
|
|
}
|
|
}
|
|
return false;
|
|
};
|
|
|
|
|
|
/**
|
|
* Highlight next/previous series in chart. Returns false if no adjacent series
|
|
* in the direction, otherwise returns new highlighted point.
|
|
*
|
|
* @private
|
|
* @function Highcharts.Chart#highlightAdjacentSeries
|
|
*
|
|
* @param {boolean} down
|
|
*
|
|
* @return {Highcharts.Point|boolean}
|
|
*/
|
|
H.Chart.prototype.highlightAdjacentSeries = function (down) {
|
|
var chart = this,
|
|
newSeries,
|
|
newPoint,
|
|
adjacentNewPoint,
|
|
curPoint = chart.highlightedPoint,
|
|
lastSeries = chart.series && chart.series[chart.series.length - 1],
|
|
lastPoint = lastSeries && lastSeries.points &&
|
|
lastSeries.points[lastSeries.points.length - 1];
|
|
|
|
// If no point is highlighted, highlight the first/last point
|
|
if (!chart.highlightedPoint) {
|
|
newSeries = down ? (chart.series && chart.series[0]) : lastSeries;
|
|
newPoint = down ?
|
|
(newSeries && newSeries.points && newSeries.points[0]) : lastPoint;
|
|
return newPoint ? newPoint.highlight() : false;
|
|
}
|
|
|
|
newSeries = chart.series[curPoint.series.index + (down ? -1 : 1)];
|
|
|
|
if (!newSeries) {
|
|
return false;
|
|
}
|
|
|
|
// We have a new series in this direction, find the right point
|
|
// Weigh xDistance as counting much higher than Y distance
|
|
newPoint = getClosestPoint(curPoint, newSeries, 4);
|
|
|
|
if (!newPoint) {
|
|
return false;
|
|
}
|
|
|
|
// New series and point exists, but we might want to skip it
|
|
if (isSkipSeries(newSeries)) {
|
|
// Skip the series
|
|
newPoint.highlight();
|
|
adjacentNewPoint = chart.highlightAdjacentSeries(down); // Try recurse
|
|
if (!adjacentNewPoint) {
|
|
// Recurse failed
|
|
curPoint.highlight();
|
|
return false;
|
|
}
|
|
// Recurse succeeded
|
|
return adjacentNewPoint;
|
|
}
|
|
|
|
// Highlight the new point or any first valid point back or forwards from it
|
|
newPoint.highlight();
|
|
return newPoint.series.highlightFirstValidPoint();
|
|
};
|
|
|
|
|
|
/**
|
|
* Highlight the closest point vertically.
|
|
*
|
|
* @private
|
|
* @function Highcharts.Chart#highlightAdjacentPointVertical
|
|
*
|
|
* @param {boolean} down
|
|
*
|
|
* @return {Highcharts.Point|boolean}
|
|
*/
|
|
H.Chart.prototype.highlightAdjacentPointVertical = function (down) {
|
|
var curPoint = this.highlightedPoint,
|
|
minDistance = Infinity,
|
|
bestPoint;
|
|
|
|
if (curPoint.plotX === undefined || curPoint.plotY === undefined) {
|
|
return false;
|
|
}
|
|
this.series.forEach(function (series) {
|
|
if (series === curPoint.series || isSkipSeries(series)) {
|
|
return;
|
|
}
|
|
series.points.forEach(function (point) {
|
|
if (point.plotY === undefined || point.plotX === undefined ||
|
|
point === curPoint) {
|
|
return;
|
|
}
|
|
var yDistance = point.plotY - curPoint.plotY,
|
|
width = Math.abs(point.plotX - curPoint.plotX),
|
|
distance = Math.abs(yDistance) * Math.abs(yDistance) +
|
|
width * width * 4; // Weigh horizontal distance highly
|
|
|
|
// Reverse distance number if axis is reversed
|
|
if (series.yAxis.reversed) {
|
|
yDistance *= -1;
|
|
}
|
|
|
|
if (
|
|
yDistance <= 0 && down || yDistance >= 0 && !down || // Chk dir
|
|
distance < 5 || // Points in same spot => infinite loop
|
|
isSkipPoint(point)
|
|
) {
|
|
return;
|
|
}
|
|
|
|
if (distance < minDistance) {
|
|
minDistance = distance;
|
|
bestPoint = point;
|
|
}
|
|
});
|
|
});
|
|
|
|
return bestPoint ? bestPoint.highlight() : false;
|
|
};
|
|
|
|
|
|
/**
|
|
* Get accessible time description for a point on a datetime axis.
|
|
*
|
|
* @private
|
|
* @function Highcharts.Point#getTimeDescription
|
|
*
|
|
* @return {string}
|
|
* The description as string.
|
|
*/
|
|
H.Point.prototype.getA11yTimeDescription = function () {
|
|
var point = this,
|
|
series = point.series,
|
|
chart = series.chart,
|
|
a11yOptions = chart.options.accessibility;
|
|
if (series.xAxis && series.xAxis.isDatetimeAxis) {
|
|
return chart.time.dateFormat(
|
|
a11yOptions.pointDateFormatter &&
|
|
a11yOptions.pointDateFormatter(point) ||
|
|
a11yOptions.pointDateFormat ||
|
|
H.Tooltip.prototype.getXDateFormat.call(
|
|
{
|
|
getDateFormat: H.Tooltip.prototype.getDateFormat,
|
|
chart: chart
|
|
},
|
|
point,
|
|
chart.options.tooltip,
|
|
series.xAxis
|
|
),
|
|
point.x
|
|
);
|
|
}
|
|
};
|
|
|
|
|
|
/**
|
|
* The SeriesComponent class
|
|
*
|
|
* @private
|
|
* @class
|
|
* @name Highcharts.SeriesComponent
|
|
* @param {Highcharts.Chart} chart
|
|
* Chart object
|
|
*/
|
|
var SeriesComponent = function (chart) {
|
|
this.initBase(chart);
|
|
this.init();
|
|
};
|
|
SeriesComponent.prototype = new AccessibilityComponent();
|
|
H.extend(SeriesComponent.prototype, /** @lends Highcharts.SeriesComponent */ {
|
|
|
|
/**
|
|
* Init the component.
|
|
*/
|
|
init: function () {
|
|
var component = this;
|
|
|
|
// On destroy, we need to clean up the focus border and the state.
|
|
this.addEvent(H.Series, 'destroy', function () {
|
|
var chart = this.chart;
|
|
if (
|
|
chart === component.chart &&
|
|
chart.highlightedPoint &&
|
|
chart.highlightedPoint.series === this
|
|
) {
|
|
delete chart.highlightedPoint;
|
|
if (chart.focusElement) {
|
|
chart.focusElement.removeFocusBorder();
|
|
}
|
|
}
|
|
});
|
|
|
|
// Hide tooltip from screen readers when it is shown
|
|
this.addEvent(H.Tooltip, 'refresh', function () {
|
|
if (
|
|
this.chart === component.chart &&
|
|
this.label &&
|
|
this.label.element
|
|
) {
|
|
this.label.element.setAttribute('aria-hidden', true);
|
|
}
|
|
});
|
|
|
|
// Hide series labels
|
|
this.addEvent(this.chart, 'afterDrawSeriesLabels', function () {
|
|
this.series.forEach(function (series) {
|
|
if (series.labelBySeries) {
|
|
series.labelBySeries.attr('aria-hidden', true);
|
|
}
|
|
});
|
|
});
|
|
|
|
// Set up announcing of new data
|
|
this.initAnnouncer();
|
|
},
|
|
|
|
|
|
/**
|
|
* Called on first render/updates to the chart, including options changes.
|
|
*/
|
|
onChartUpdate: function () {
|
|
var component = this,
|
|
chart = this.chart;
|
|
chart.series.forEach(function (series) {
|
|
component[
|
|
(series.options.accessibility &&
|
|
series.options.accessibility.enabled) !== false ?
|
|
'addSeriesDescription' : 'hideSeriesFromScreenReader'
|
|
](series);
|
|
});
|
|
},
|
|
|
|
|
|
/**
|
|
* Get keyboard navigation handler for this component.
|
|
* @return {Highcharts.KeyboardNavigationHandler}
|
|
*/
|
|
getKeyboardNavigation: function () {
|
|
var keys = this.keyCodes,
|
|
chart = this.chart,
|
|
a11yOptions = chart.options.accessibility,
|
|
// Function that attempts to highlight next/prev point, returns
|
|
// the response number. Handles wrap around.
|
|
attemptNextPoint = function (directionIsNext) {
|
|
if (!chart.highlightAdjacentPoint(directionIsNext)) {
|
|
// Failed to highlight next, wrap to last/first if we
|
|
// have wrapAround
|
|
if (a11yOptions.keyboardNavigation.wrapAround) {
|
|
return this.init(directionIsNext ? 1 : -1);
|
|
}
|
|
return this.response[directionIsNext ? 'next' : 'prev'];
|
|
}
|
|
return this.response.success;
|
|
};
|
|
|
|
return new KeyboardNavigationHandler(chart, {
|
|
keyCodeMap: [
|
|
// Arrow sideways
|
|
[[
|
|
keys.left, keys.right
|
|
], function (keyCode) {
|
|
return attemptNextPoint.call(this, keyCode === keys.right);
|
|
}],
|
|
|
|
// Arrow vertical
|
|
[[
|
|
keys.up, keys.down
|
|
], function (keyCode) {
|
|
var down = keyCode === keys.down,
|
|
navOptions = a11yOptions.keyboardNavigation;
|
|
|
|
// Handle serialized mode, act like left/right
|
|
if (navOptions.mode && navOptions.mode === 'serialize') {
|
|
return attemptNextPoint.call(
|
|
this, keyCode === keys.down
|
|
);
|
|
}
|
|
|
|
// Normal mode, move between series
|
|
var highlightMethod = chart.highlightedPoint &&
|
|
chart.highlightedPoint.series.keyboardMoveVertical ?
|
|
'highlightAdjacentPointVertical' :
|
|
'highlightAdjacentSeries';
|
|
|
|
chart[highlightMethod](down);
|
|
return this.response.success;
|
|
}],
|
|
|
|
// Enter/Spacebar
|
|
[[
|
|
keys.enter, keys.space
|
|
], function () {
|
|
if (chart.highlightedPoint) {
|
|
chart.highlightedPoint.firePointEvent('click');
|
|
}
|
|
}]
|
|
],
|
|
|
|
// Always start highlighting from scratch when entering this module
|
|
init: function (dir) {
|
|
var numSeries = chart.series.length,
|
|
i = dir > 0 ? 0 : numSeries,
|
|
res;
|
|
|
|
if (dir > 0) {
|
|
delete chart.highlightedPoint;
|
|
// Find first valid point to highlight
|
|
while (i < numSeries) {
|
|
res = chart.series[i].highlightFirstValidPoint();
|
|
if (res) {
|
|
break;
|
|
}
|
|
++i;
|
|
}
|
|
} else {
|
|
// Find last valid point to highlight
|
|
while (i--) {
|
|
chart.highlightedPoint = chart.series[i].points[
|
|
chart.series[i].points.length - 1
|
|
];
|
|
// Highlight first valid point in the series will also
|
|
// look backwards. It always starts from currently
|
|
// highlighted point.
|
|
res = chart.series[i].highlightFirstValidPoint();
|
|
if (res) {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Nothing to highlight
|
|
return this.response.success;
|
|
},
|
|
|
|
// If leaving points, don't show tooltip anymore
|
|
terminate: function () {
|
|
if (chart.tooltip) {
|
|
chart.tooltip.hide(0);
|
|
}
|
|
delete chart.highlightedPoint;
|
|
}
|
|
});
|
|
},
|
|
|
|
|
|
/**
|
|
* Returns true if a point should be clickable.
|
|
* @private
|
|
* @param {Highcharts.Point} point The point to test.
|
|
* @return {boolean} True if the point can be clicked.
|
|
*/
|
|
isPointClickable: function (point) {
|
|
var seriesOpts = point.series.options || {},
|
|
seriesPointEvents = seriesOpts.point && seriesOpts.point.events;
|
|
return point && point.graphic && point.graphic.element &&
|
|
(
|
|
point.hcEvents && point.hcEvents.click ||
|
|
seriesPointEvents && seriesPointEvents.click ||
|
|
(
|
|
point.options &&
|
|
point.options.events &&
|
|
point.options.events.click
|
|
)
|
|
);
|
|
},
|
|
|
|
|
|
/**
|
|
* Initialize the new data announcer.
|
|
* @private
|
|
*/
|
|
initAnnouncer: function () {
|
|
var chart = this.chart,
|
|
a11yOptions = chart.options.accessibility,
|
|
component = this;
|
|
this.lastAnnouncementTime = 0;
|
|
this.dirty = {
|
|
allSeries: {}
|
|
};
|
|
|
|
// Add the live region
|
|
this.announceRegion = this.createElement('div');
|
|
this.announceRegion.setAttribute('aria-hidden', false);
|
|
this.announceRegion.setAttribute(
|
|
'aria-live', a11yOptions.announceNewData.interruptUser ?
|
|
'assertive' : 'polite'
|
|
);
|
|
merge(true, this.announceRegion.style, this.hiddenStyle);
|
|
chart.renderTo.insertBefore(
|
|
this.announceRegion, chart.renderTo.firstChild
|
|
);
|
|
|
|
// After drilldown, make sure we reset time counter, and also that we
|
|
// highlight the first series.
|
|
this.addEvent(this.chart, 'afterDrilldown', function () {
|
|
chart.highlightedPoint = null;
|
|
if (chart.options.accessibility.announceNewData.enabled) {
|
|
if (this.series && this.series.length) {
|
|
var el = component.getSeriesElement(this.series[0]);
|
|
if (el.focus && el.getAttribute('aria-label')) {
|
|
el.focus();
|
|
} else {
|
|
this.series[0].highlightFirstValidPoint();
|
|
}
|
|
}
|
|
component.lastAnnouncementTime = 0;
|
|
if (chart.focusElement) {
|
|
chart.focusElement.removeFocusBorder();
|
|
}
|
|
}
|
|
});
|
|
// On new data in the series, make sure we add it to the dirty list
|
|
this.addEvent(H.Series, 'updatedData', function () {
|
|
if (
|
|
this.chart === chart &&
|
|
this.chart.options.accessibility.announceNewData.enabled
|
|
) {
|
|
component.dirty.hasDirty = true;
|
|
component.dirty.allSeries[this.name + this.index] = this;
|
|
}
|
|
});
|
|
// New series
|
|
this.addEvent(chart, 'afterAddSeries', function (e) {
|
|
if (this.options.accessibility.announceNewData.enabled) {
|
|
var series = e.series;
|
|
component.dirty.hasDirty = true;
|
|
component.dirty.allSeries[series.name + series.index] = series;
|
|
// Add it to newSeries storage unless we already have one
|
|
component.dirty.newSeries = component.dirty.newSeries ===
|
|
undefined ? series : null;
|
|
}
|
|
});
|
|
// New point
|
|
this.addEvent(H.Series, 'addPoint', function (e) {
|
|
if (this.chart === chart &&
|
|
this.chart.options.accessibility.announceNewData.enabled) {
|
|
// Add it to newPoint storage unless we already have one
|
|
component.dirty.newPoint = component.dirty.newPoint ===
|
|
undefined ? e.point : null;
|
|
}
|
|
});
|
|
// On redraw: compile what we know about new data, and build
|
|
// announcement
|
|
this.addEvent(chart, 'redraw', function () {
|
|
if (
|
|
this.options.accessibility.announceNewData &&
|
|
component.dirty.hasDirty
|
|
) {
|
|
var newPoint = component.dirty.newPoint,
|
|
newPoints;
|
|
// If we have a single new point, see if we can find it in the
|
|
// data array. Otherwise we can only pass through options to
|
|
// the description builder, and it is a bit sparse in info.
|
|
if (newPoint) {
|
|
newPoints = newPoint.series.data.filter(function (point) {
|
|
return point.x === newPoint.x && point.y === newPoint.y;
|
|
});
|
|
// We have list of points with the same x and y values. If
|
|
// this list is one point long, we have our new point.
|
|
newPoint = newPoints.length === 1 ? newPoints[0] : newPoint;
|
|
}
|
|
// Queue the announcement
|
|
component.announceNewData(
|
|
Object.keys(component.dirty.allSeries).map(function (ix) {
|
|
return component.dirty.allSeries[ix];
|
|
}),
|
|
component.dirty.newSeries,
|
|
newPoint
|
|
);
|
|
// Reset
|
|
component.dirty = {
|
|
allSeries: {}
|
|
};
|
|
}
|
|
});
|
|
},
|
|
|
|
|
|
/**
|
|
* Handle announcement to user that there is new data.
|
|
* @private
|
|
* @param {Array<Highcharts.Series>} dirtySeries
|
|
* Array of series with new data.
|
|
* @param {Highcharts.Series} [newSeries]
|
|
* If a single new series was added, a reference to this series.
|
|
* @param {Highcharts.Point} [newPoint]
|
|
* If a single point was added, a reference to this point.
|
|
*/
|
|
announceNewData: function (dirtySeries, newSeries, newPoint) {
|
|
var chart = this.chart,
|
|
annOptions = chart.options.accessibility.announceNewData;
|
|
if (annOptions.enabled) {
|
|
var component = this,
|
|
now = +new Date(),
|
|
dTime = now - this.lastAnnouncementTime,
|
|
time = Math.max(0, annOptions.minAnnounceInterval - dTime),
|
|
allSeries;
|
|
|
|
// Add affected series from existing queued announcement
|
|
if (this.queuedAnnouncement) {
|
|
var uniqueSeries = (this.queuedAnnouncement.series || [])
|
|
.concat(dirtySeries)
|
|
.reduce(function (acc, cur) {
|
|
acc[cur.name + cur.index] = cur;
|
|
return acc;
|
|
}, {});
|
|
allSeries = Object.keys(uniqueSeries).map(function (ix) {
|
|
return uniqueSeries[ix];
|
|
});
|
|
} else {
|
|
allSeries = [].concat(dirtySeries);
|
|
}
|
|
|
|
// Build message and announce
|
|
var message = this.buildAnnouncementMessage(
|
|
allSeries, newSeries, newPoint
|
|
);
|
|
if (message) {
|
|
// Is there already one queued?
|
|
if (this.queuedAnnouncement) {
|
|
clearTimeout(this.queuedAnnouncementTimer);
|
|
}
|
|
|
|
// Build the announcement
|
|
this.queuedAnnouncement = {
|
|
time: now,
|
|
message: message,
|
|
series: allSeries
|
|
};
|
|
|
|
// Queue the announcement
|
|
component.queuedAnnouncementTimer = setTimeout(function () {
|
|
if (component && component.announceRegion) {
|
|
component.lastAnnouncementTime = +new Date();
|
|
component.announceRegion.innerHTML = component
|
|
.queuedAnnouncement.message;
|
|
|
|
// Delete contents after a second to avoid user
|
|
// finding the live region in the DOM.
|
|
if (component.clearAnnouncementContainerTimer) {
|
|
clearTimeout(
|
|
component.clearAnnouncementContainerTimer
|
|
);
|
|
}
|
|
component.clearAnnouncementContainerTimer = setTimeout(
|
|
function () {
|
|
component.announceRegion.innerHTML = '';
|
|
delete
|
|
component.clearAnnouncementContainerTimer;
|
|
}, 1000
|
|
);
|
|
delete component.queuedAnnouncement;
|
|
delete component.queuedAnnouncementTimer;
|
|
}
|
|
}, time);
|
|
}
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Handle announcement to user that there is new data.
|
|
* @private
|
|
* @param {Array<Highcharts.Series>} dirtySeries
|
|
* Array of series with new data.
|
|
* @param {Highcharts.Series} [newSeries]
|
|
* If a single new series was added, a reference to this series.
|
|
* @param {Highcharts.Point} [newPoint]
|
|
* If a single point was added, a reference to this point.
|
|
*
|
|
* @return {string} The announcement message to give to user.
|
|
*/
|
|
buildAnnouncementMessage: function (dirtySeries, newSeries, newPoint) {
|
|
var chart = this.chart,
|
|
annOptions = chart.options.accessibility.announceNewData;
|
|
|
|
// User supplied formatter?
|
|
if (annOptions.announcementFormatter) {
|
|
var formatterRes = annOptions.announcementFormatter(
|
|
dirtySeries, newSeries, newPoint
|
|
);
|
|
if (formatterRes !== false) {
|
|
return formatterRes.length ? formatterRes : null;
|
|
}
|
|
}
|
|
|
|
// Default formatter - use lang options
|
|
var multiple = H.charts && H.charts.length > 1 ? 'Multiple' : 'Single',
|
|
langKey = newSeries ? 'newSeriesAnnounce' + multiple :
|
|
newPoint ? 'newPointAnnounce' + multiple : 'newDataAnnounce';
|
|
return chart.langFormat(
|
|
'accessibility.announceNewData.' + langKey, {
|
|
chartTitle: this.stripTags(
|
|
chart.options.title.text || chart.langFormat(
|
|
'accessibility.defaultChartTitle', { chart: chart }
|
|
)
|
|
),
|
|
seriesDesc: newSeries ?
|
|
this.defaultSeriesDescriptionFormatter(newSeries) : null,
|
|
pointDesc: newPoint ?
|
|
this.defaultPointDescriptionFormatter(newPoint) : null,
|
|
point: newPoint,
|
|
series: newSeries
|
|
}
|
|
);
|
|
},
|
|
|
|
|
|
/**
|
|
* Utility function. Reverses child nodes of a DOM element.
|
|
* @private
|
|
* @param {Highcharts.HTMLDOMElement|Highcharts.SVGDOMElement} node
|
|
*/
|
|
reverseChildNodes: function (node) {
|
|
var i = node.childNodes.length;
|
|
while (i--) {
|
|
node.appendChild(node.childNodes[i]);
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Get the DOM element for the first point in the series.
|
|
* @private
|
|
* @param {Highcharts.Series} series The series to get element for.
|
|
* @return {Highcharts.SVGDOMElement} The DOM element for the point.
|
|
*/
|
|
getSeriesFirstPointElement: function (series) {
|
|
return (
|
|
series.points &&
|
|
series.points.length &&
|
|
series.points[0].graphic &&
|
|
series.points[0].graphic.element
|
|
);
|
|
},
|
|
|
|
|
|
/**
|
|
* Get the DOM element for the series that we put accessibility info on.
|
|
* @private
|
|
* @param {Highcharts.Series} series The series to get element for.
|
|
* @return {Highcharts.SVGDOMElement} The DOM element for the series
|
|
*/
|
|
getSeriesElement: function (series) {
|
|
var firstPointEl = this.getSeriesFirstPointElement(series);
|
|
return (
|
|
firstPointEl &&
|
|
firstPointEl.parentNode || series.graph &&
|
|
series.graph.element || series.group &&
|
|
series.group.element
|
|
); // Could be tracker series depending on series type
|
|
},
|
|
|
|
|
|
/**
|
|
* Hide series from screen readers.
|
|
* @private
|
|
* @param {Highcharts.Series} series The series to hide
|
|
*/
|
|
hideSeriesFromScreenReader: function (series) {
|
|
var seriesEl = this.getSeriesElement(series);
|
|
if (seriesEl) {
|
|
seriesEl.setAttribute('aria-hidden', true);
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Put accessible info on series and points of a series.
|
|
* @private
|
|
* @param {Highcharts.Series} series The series to add info on.
|
|
*/
|
|
addSeriesDescription: function (series) {
|
|
var component = this,
|
|
chart = series.chart,
|
|
a11yOptions = chart.options.accessibility,
|
|
seriesA11yOptions = series.options.accessibility || {},
|
|
firstPointEl = component.getSeriesFirstPointElement(series),
|
|
seriesEl = component.getSeriesElement(series);
|
|
|
|
if (seriesEl) {
|
|
// Unhide series
|
|
this.unhideElementFromScreenReaders(seriesEl);
|
|
|
|
// For some series types the order of elements do not match the
|
|
// order of points in series. In that case we have to reverse them
|
|
// in order for AT to read them out in an understandable order
|
|
if (seriesEl.lastChild === firstPointEl) {
|
|
component.reverseChildNodes(seriesEl);
|
|
}
|
|
|
|
// Make individual point elements accessible if possible. Note: If
|
|
// markers are disabled there might not be any elements there to
|
|
// make accessible.
|
|
if (
|
|
series.points && (
|
|
series.points.length <
|
|
a11yOptions.pointDescriptionThreshold ||
|
|
a11yOptions.pointDescriptionThreshold === false
|
|
) &&
|
|
!seriesA11yOptions.exposeAsGroupOnly
|
|
) {
|
|
series.points.forEach(function (point) {
|
|
var pointEl = point.graphic && point.graphic.element;
|
|
if (pointEl) {
|
|
pointEl.setAttribute('role', 'img');
|
|
pointEl.setAttribute('tabindex', '-1');
|
|
pointEl.setAttribute('aria-label',
|
|
component.stripTags(
|
|
seriesA11yOptions.pointDescriptionFormatter &&
|
|
seriesA11yOptions
|
|
.pointDescriptionFormatter(point) ||
|
|
a11yOptions.pointDescriptionFormatter &&
|
|
a11yOptions.pointDescriptionFormatter(point) ||
|
|
component
|
|
.defaultPointDescriptionFormatter(point)
|
|
));
|
|
}
|
|
});
|
|
}
|
|
|
|
// Make series element accessible
|
|
if (chart.series.length > 1 || a11yOptions.describeSingleSeries) {
|
|
// Handle role attribute
|
|
if (seriesA11yOptions.exposeAsGroupOnly) {
|
|
seriesEl.setAttribute('role', 'img');
|
|
} else if (a11yOptions.landmarkVerbosity === 'all') {
|
|
seriesEl.setAttribute('role', 'region');
|
|
} /* else do not add role */
|
|
|
|
seriesEl.setAttribute('tabindex', '-1');
|
|
seriesEl.setAttribute(
|
|
'aria-label',
|
|
component.stripTags(
|
|
a11yOptions.seriesDescriptionFormatter &&
|
|
a11yOptions.seriesDescriptionFormatter(series) ||
|
|
component.defaultSeriesDescriptionFormatter(series)
|
|
)
|
|
);
|
|
}
|
|
}
|
|
},
|
|
|
|
|
|
/**
|
|
* Return string with information about series.
|
|
* @private
|
|
* @return {string}
|
|
*/
|
|
defaultSeriesDescriptionFormatter: function (series) {
|
|
var chart = series.chart,
|
|
seriesA11yOptions = series.options.accessibility || {},
|
|
desc = seriesA11yOptions.description,
|
|
description = desc && chart.langFormat(
|
|
'accessibility.series.description', {
|
|
description: desc,
|
|
series: series
|
|
}
|
|
),
|
|
xAxisInfo = chart.langFormat(
|
|
'accessibility.series.xAxisDescription',
|
|
{
|
|
name: series.xAxis && series.xAxis.getDescription(),
|
|
series: series
|
|
}
|
|
),
|
|
yAxisInfo = chart.langFormat(
|
|
'accessibility.series.yAxisDescription',
|
|
{
|
|
name: series.yAxis && series.yAxis.getDescription(),
|
|
series: series
|
|
}
|
|
),
|
|
summaryContext = {
|
|
name: series.name || '',
|
|
ix: series.index + 1,
|
|
numSeries: chart.series && chart.series.length,
|
|
numPoints: series.points && series.points.length,
|
|
series: series
|
|
},
|
|
combination = chart.types && chart.types.length > 1 ?
|
|
'Combination' : '',
|
|
summary = chart.langFormat(
|
|
'accessibility.series.summary.' + series.type + combination,
|
|
summaryContext
|
|
) || chart.langFormat(
|
|
'accessibility.series.summary.default' + combination,
|
|
summaryContext
|
|
);
|
|
|
|
return summary + (description ? ' ' + description : '') + (
|
|
chart.yAxis && chart.yAxis.length > 1 && this.yAxis ?
|
|
' ' + yAxisInfo : ''
|
|
) + (
|
|
chart.xAxis && chart.xAxis.length > 1 && this.xAxis ?
|
|
' ' + xAxisInfo : ''
|
|
);
|
|
},
|
|
|
|
|
|
/**
|
|
* Return string with information about point.
|
|
* @private
|
|
* @return {string}
|
|
*/
|
|
defaultPointDescriptionFormatter: function (point) {
|
|
var series = point.series,
|
|
chart = series.chart,
|
|
a11yOptions = chart.options.accessibility,
|
|
tooltipOptions = point.series.tooltipOptions || {},
|
|
valuePrefix = a11yOptions.pointValuePrefix ||
|
|
tooltipOptions.valuePrefix || '',
|
|
valueSuffix = a11yOptions.pointValueSuffix ||
|
|
tooltipOptions.valueSuffix || '',
|
|
description = point.options && point.options.accessibility &&
|
|
point.options.accessibility.description,
|
|
timeDesc = point.getA11yTimeDescription(),
|
|
numberFormat = function (value) {
|
|
if (H.isNumber(value)) {
|
|
var lang = H.defaultOptions.lang;
|
|
return H.numberFormat(
|
|
value,
|
|
a11yOptions.pointValueDecimals ||
|
|
tooltipOptions.valueDecimals || -1,
|
|
lang.decimalPoint,
|
|
lang.accessibility.thousandsSep ||
|
|
lang.thousandsSep
|
|
);
|
|
}
|
|
return value;
|
|
},
|
|
showXDescription = pick(
|
|
series.xAxis &&
|
|
series.xAxis.options.accessibility &&
|
|
series.xAxis.options.accessibility.enabled,
|
|
!chart.angular
|
|
),
|
|
pointCategory = series.xAxis && series.xAxis.categories &&
|
|
point.category !== undefined && '' + point.category;
|
|
|
|
// Pick and choose properties for a succint label
|
|
var xDesc = point.name || timeDesc ||
|
|
pointCategory && pointCategory.replace('<br/>', ' ') || (
|
|
point.id && point.id.indexOf('highcharts-') < 0 ?
|
|
point.id : ('x, ' + point.x)
|
|
),
|
|
valueDesc = point.series.pointArrayMap ?
|
|
point.series.pointArrayMap.reduce(function (desc, key) {
|
|
return desc + (desc.length ? ', ' : '') + key + ': ' +
|
|
valuePrefix + numberFormat(
|
|
pick(point[key], point.options[key])
|
|
) + valueSuffix;
|
|
}, '') :
|
|
(
|
|
point.value !== undefined ?
|
|
valuePrefix + numberFormat(point.value) + valueSuffix :
|
|
valuePrefix + numberFormat(point.y) + valueSuffix
|
|
);
|
|
|
|
return (point.index !== undefined ? (point.index + 1) + '. ' : '') +
|
|
(showXDescription ? xDesc + ', ' : '') + valueDesc + '.' +
|
|
(description ? ' ' + description : '') +
|
|
(chart.series.length > 1 && series.name ? ' ' + series.name : '');
|
|
}
|
|
|
|
});
|
|
|
|
export default SeriesComponent;
|