SeriesKeyboardNavigation.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595
  1. /* *
  2. *
  3. * (c) 2009-2021 Øystein Moseng
  4. *
  5. * Handle keyboard navigation for series.
  6. *
  7. * License: www.highcharts.com/license
  8. *
  9. * !!!!!!! SOURCE GETS TRANSPILED BY TYPESCRIPT. EDIT TS FILE ONLY. !!!!!!!
  10. *
  11. * */
  12. 'use strict';
  13. import Chart from '../../../Core/Chart/Chart.js';
  14. import Point from '../../../Core/Series/Point.js';
  15. import Series from '../../../Core/Series/Series.js';
  16. import SeriesRegistry from '../../../Core/Series/SeriesRegistry.js';
  17. var seriesTypes = SeriesRegistry.seriesTypes;
  18. import H from '../../../Core/Globals.js';
  19. var doc = H.doc;
  20. import U from '../../../Core/Utilities.js';
  21. var defined = U.defined, extend = U.extend, fireEvent = U.fireEvent;
  22. import KeyboardNavigationHandler from '../../KeyboardNavigationHandler.js';
  23. import EventProvider from '../../Utils/EventProvider.js';
  24. import ChartUtilities from '../../Utils/ChartUtilities.js';
  25. var getPointFromXY = ChartUtilities.getPointFromXY, getSeriesFromName = ChartUtilities.getSeriesFromName, scrollToPoint = ChartUtilities.scrollToPoint;
  26. import '../../../Series/Column/ColumnSeries.js';
  27. import '../../../Series/Pie/PieSeries.js';
  28. /* eslint-disable no-invalid-this, valid-jsdoc */
  29. /*
  30. * Set for which series types it makes sense to move to the closest point with
  31. * up/down arrows, and which series types should just move to next series.
  32. */
  33. Series.prototype.keyboardMoveVertical = true;
  34. ['column', 'pie'].forEach(function (type) {
  35. if (seriesTypes[type]) {
  36. seriesTypes[type].prototype.keyboardMoveVertical = false;
  37. }
  38. });
  39. /**
  40. * Get the index of a point in a series. This is needed when using e.g. data
  41. * grouping.
  42. *
  43. * @private
  44. * @function getPointIndex
  45. *
  46. * @param {Highcharts.AccessibilityPoint} point
  47. * The point to find index of.
  48. *
  49. * @return {number|undefined}
  50. * The index in the series.points array of the point.
  51. */
  52. function getPointIndex(point) {
  53. var index = point.index, points = point.series.points, i = points.length;
  54. if (points[index] !== point) {
  55. while (i--) {
  56. if (points[i] === point) {
  57. return i;
  58. }
  59. }
  60. }
  61. else {
  62. return index;
  63. }
  64. }
  65. /**
  66. * Determine if series navigation should be skipped
  67. *
  68. * @private
  69. * @function isSkipSeries
  70. *
  71. * @param {Highcharts.Series} series
  72. *
  73. * @return {boolean|number|undefined}
  74. */
  75. function isSkipSeries(series) {
  76. var a11yOptions = series.chart.options.accessibility, seriesNavOptions = a11yOptions.keyboardNavigation.seriesNavigation, seriesA11yOptions = series.options.accessibility || {}, seriesKbdNavOptions = seriesA11yOptions.keyboardNavigation;
  77. return seriesKbdNavOptions && seriesKbdNavOptions.enabled === false ||
  78. seriesA11yOptions.enabled === false ||
  79. series.options.enableMouseTracking === false || // #8440
  80. !series.visible ||
  81. // Skip all points in a series where pointNavigationEnabledThreshold is
  82. // reached
  83. (seriesNavOptions.pointNavigationEnabledThreshold &&
  84. seriesNavOptions.pointNavigationEnabledThreshold <=
  85. series.points.length);
  86. }
  87. /**
  88. * Determine if navigation for a point should be skipped
  89. *
  90. * @private
  91. * @function isSkipPoint
  92. *
  93. * @param {Highcharts.Point} point
  94. *
  95. * @return {boolean|number|undefined}
  96. */
  97. function isSkipPoint(point) {
  98. var a11yOptions = point.series.chart.options.accessibility;
  99. var pointA11yDisabled = (point.options.accessibility &&
  100. point.options.accessibility.enabled === false);
  101. return point.isNull &&
  102. a11yOptions.keyboardNavigation.seriesNavigation.skipNullPoints ||
  103. point.visible === false ||
  104. point.isInside === false ||
  105. pointA11yDisabled ||
  106. isSkipSeries(point.series);
  107. }
  108. /**
  109. * Get the point in a series that is closest (in pixel distance) to a reference
  110. * point. Optionally supply weight factors for x and y directions.
  111. *
  112. * @private
  113. * @function getClosestPoint
  114. *
  115. * @param {Highcharts.Point} point
  116. * @param {Highcharts.Series} series
  117. * @param {number} [xWeight]
  118. * @param {number} [yWeight]
  119. *
  120. * @return {Highcharts.Point|undefined}
  121. */
  122. function getClosestPoint(point, series, xWeight, yWeight) {
  123. var minDistance = Infinity, dPoint, minIx, distance, i = series.points.length, hasUndefinedPosition = function (point) {
  124. return !(defined(point.plotX) && defined(point.plotY));
  125. };
  126. if (hasUndefinedPosition(point)) {
  127. return;
  128. }
  129. while (i--) {
  130. dPoint = series.points[i];
  131. if (hasUndefinedPosition(dPoint)) {
  132. continue;
  133. }
  134. distance = (point.plotX - dPoint.plotX) *
  135. (point.plotX - dPoint.plotX) *
  136. (xWeight || 1) +
  137. (point.plotY - dPoint.plotY) *
  138. (point.plotY - dPoint.plotY) *
  139. (yWeight || 1);
  140. if (distance < minDistance) {
  141. minDistance = distance;
  142. minIx = i;
  143. }
  144. }
  145. return defined(minIx) ? series.points[minIx] : void 0;
  146. }
  147. /**
  148. * Highlights a point (show tooltip and display hover state).
  149. *
  150. * @private
  151. * @function Highcharts.Point#highlight
  152. *
  153. * @return {Highcharts.Point}
  154. * This highlighted point.
  155. */
  156. Point.prototype.highlight = function () {
  157. var chart = this.series.chart;
  158. if (!this.isNull) {
  159. this.onMouseOver(); // Show the hover marker and tooltip
  160. }
  161. else {
  162. if (chart.tooltip) {
  163. chart.tooltip.hide(0);
  164. }
  165. // Don't call blur on the element, as it messes up the chart div's focus
  166. }
  167. scrollToPoint(this);
  168. // We focus only after calling onMouseOver because the state change can
  169. // change z-index and mess up the element.
  170. if (this.graphic) {
  171. chart.setFocusToElement(this.graphic);
  172. }
  173. chart.highlightedPoint = this;
  174. return this;
  175. };
  176. /**
  177. * Function to highlight next/previous point in chart.
  178. *
  179. * @private
  180. * @function Highcharts.Chart#highlightAdjacentPoint
  181. *
  182. * @param {boolean} next
  183. * Flag for the direction.
  184. *
  185. * @return {Highcharts.Point|boolean}
  186. * Returns highlighted point on success, false on failure (no adjacent
  187. * point to highlight in chosen direction).
  188. */
  189. Chart.prototype.highlightAdjacentPoint = function (next) {
  190. 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 &&
  191. lastSeries.points[lastSeries.points.length - 1], newSeries, newPoint;
  192. // If no points, return false
  193. if (!series[0] || !series[0].points) {
  194. return false;
  195. }
  196. if (!curPoint) {
  197. // No point is highlighted yet. Try first/last point depending on move
  198. // direction
  199. newPoint = next ? series[0].points[0] : lastPoint;
  200. }
  201. else {
  202. // We have a highlighted point.
  203. // Grab next/prev point & series
  204. newSeries = series[curPoint.series.index + (next ? 1 : -1)];
  205. newPoint = curPoints[curPointIndex + (next ? 1 : -1)];
  206. if (!newPoint && newSeries) {
  207. // Done with this series, try next one
  208. newPoint = newSeries.points[next ? 0 : newSeries.points.length - 1];
  209. }
  210. // If there is no adjacent point, we return false
  211. if (!newPoint) {
  212. return false;
  213. }
  214. }
  215. // Recursively skip points
  216. if (isSkipPoint(newPoint)) {
  217. // If we skip this whole series, move to the end of the series before we
  218. // recurse, just to optimize
  219. newSeries = newPoint.series;
  220. if (isSkipSeries(newSeries)) {
  221. chart.highlightedPoint = next ?
  222. newSeries.points[newSeries.points.length - 1] :
  223. newSeries.points[0];
  224. }
  225. else {
  226. // Otherwise, just move one point
  227. chart.highlightedPoint = newPoint;
  228. }
  229. // Retry
  230. return chart.highlightAdjacentPoint(next);
  231. }
  232. // There is an adjacent point, highlight it
  233. return newPoint.highlight();
  234. };
  235. /**
  236. * Highlight first valid point in a series. Returns the point if successfully
  237. * highlighted, otherwise false. If there is a highlighted point in the series,
  238. * use that as starting point.
  239. *
  240. * @private
  241. * @function Highcharts.Series#highlightFirstValidPoint
  242. *
  243. * @return {boolean|Highcharts.Point}
  244. */
  245. Series.prototype.highlightFirstValidPoint = function () {
  246. var curPoint = this.chart.highlightedPoint, start = (curPoint && curPoint.series) === this ?
  247. getPointIndex(curPoint) :
  248. 0, points = this.points, len = points.length;
  249. if (points && len) {
  250. for (var i = start; i < len; ++i) {
  251. if (!isSkipPoint(points[i])) {
  252. return points[i].highlight();
  253. }
  254. }
  255. for (var j = start; j >= 0; --j) {
  256. if (!isSkipPoint(points[j])) {
  257. return points[j].highlight();
  258. }
  259. }
  260. }
  261. return false;
  262. };
  263. /**
  264. * Highlight next/previous series in chart. Returns false if no adjacent series
  265. * in the direction, otherwise returns new highlighted point.
  266. *
  267. * @private
  268. * @function Highcharts.Chart#highlightAdjacentSeries
  269. *
  270. * @param {boolean} down
  271. *
  272. * @return {Highcharts.Point|boolean}
  273. */
  274. Chart.prototype.highlightAdjacentSeries = function (down) {
  275. var chart = this, newSeries, newPoint, adjacentNewPoint, curPoint = chart.highlightedPoint, lastSeries = chart.series && chart.series[chart.series.length - 1], lastPoint = lastSeries && lastSeries.points &&
  276. lastSeries.points[lastSeries.points.length - 1];
  277. // If no point is highlighted, highlight the first/last point
  278. if (!chart.highlightedPoint) {
  279. newSeries = down ? (chart.series && chart.series[0]) : lastSeries;
  280. newPoint = down ?
  281. (newSeries && newSeries.points && newSeries.points[0]) : lastPoint;
  282. return newPoint ? newPoint.highlight() : false;
  283. }
  284. newSeries = chart.series[curPoint.series.index + (down ? -1 : 1)];
  285. if (!newSeries) {
  286. return false;
  287. }
  288. // We have a new series in this direction, find the right point
  289. // Weigh xDistance as counting much higher than Y distance
  290. newPoint = getClosestPoint(curPoint, newSeries, 4);
  291. if (!newPoint) {
  292. return false;
  293. }
  294. // New series and point exists, but we might want to skip it
  295. if (isSkipSeries(newSeries)) {
  296. // Skip the series
  297. newPoint.highlight();
  298. adjacentNewPoint = chart.highlightAdjacentSeries(down); // Try recurse
  299. if (!adjacentNewPoint) {
  300. // Recurse failed
  301. curPoint.highlight();
  302. return false;
  303. }
  304. // Recurse succeeded
  305. return adjacentNewPoint;
  306. }
  307. // Highlight the new point or any first valid point back or forwards from it
  308. newPoint.highlight();
  309. return newPoint.series.highlightFirstValidPoint();
  310. };
  311. /**
  312. * Highlight the closest point vertically.
  313. *
  314. * @private
  315. * @function Highcharts.Chart#highlightAdjacentPointVertical
  316. *
  317. * @param {boolean} down
  318. *
  319. * @return {Highcharts.Point|boolean}
  320. */
  321. Chart.prototype.highlightAdjacentPointVertical = function (down) {
  322. var curPoint = this.highlightedPoint, minDistance = Infinity, bestPoint;
  323. if (!defined(curPoint.plotX) || !defined(curPoint.plotY)) {
  324. return false;
  325. }
  326. this.series.forEach(function (series) {
  327. if (isSkipSeries(series)) {
  328. return;
  329. }
  330. series.points.forEach(function (point) {
  331. if (!defined(point.plotY) || !defined(point.plotX) ||
  332. point === curPoint) {
  333. return;
  334. }
  335. var yDistance = point.plotY - curPoint.plotY, width = Math.abs(point.plotX - curPoint.plotX), distance = Math.abs(yDistance) * Math.abs(yDistance) +
  336. width * width * 4; // Weigh horizontal distance highly
  337. // Reverse distance number if axis is reversed
  338. if (series.yAxis && series.yAxis.reversed) {
  339. yDistance *= -1;
  340. }
  341. if (yDistance <= 0 && down || yDistance >= 0 && !down || // Chk dir
  342. distance < 5 || // Points in same spot => infinite loop
  343. isSkipPoint(point)) {
  344. return;
  345. }
  346. if (distance < minDistance) {
  347. minDistance = distance;
  348. bestPoint = point;
  349. }
  350. });
  351. });
  352. return bestPoint ? bestPoint.highlight() : false;
  353. };
  354. /**
  355. * @private
  356. * @param {Highcharts.Chart} chart
  357. * @return {Highcharts.Point|boolean}
  358. */
  359. function highlightFirstValidPointInChart(chart) {
  360. var res = false;
  361. delete chart.highlightedPoint;
  362. res = chart.series.reduce(function (acc, cur) {
  363. return acc || cur.highlightFirstValidPoint();
  364. }, false);
  365. return res;
  366. }
  367. /**
  368. * @private
  369. * @param {Highcharts.Chart} chart
  370. * @return {Highcharts.Point|boolean}
  371. */
  372. function highlightLastValidPointInChart(chart) {
  373. var numSeries = chart.series.length, i = numSeries, res = false;
  374. while (i--) {
  375. chart.highlightedPoint = chart.series[i].points[chart.series[i].points.length - 1];
  376. // Highlight first valid point in the series will also
  377. // look backwards. It always starts from currently
  378. // highlighted point.
  379. res = chart.series[i].highlightFirstValidPoint();
  380. if (res) {
  381. break;
  382. }
  383. }
  384. return res;
  385. }
  386. /**
  387. * @private
  388. * @param {Highcharts.Chart} chart
  389. */
  390. function updateChartFocusAfterDrilling(chart) {
  391. highlightFirstValidPointInChart(chart);
  392. if (chart.focusElement) {
  393. chart.focusElement.removeFocusBorder();
  394. }
  395. }
  396. /**
  397. * @private
  398. * @class
  399. * @name Highcharts.SeriesKeyboardNavigation
  400. */
  401. function SeriesKeyboardNavigation(chart, keyCodes) {
  402. this.keyCodes = keyCodes;
  403. this.chart = chart;
  404. }
  405. extend(SeriesKeyboardNavigation.prototype, /** @lends Highcharts.SeriesKeyboardNavigation */ {
  406. /**
  407. * Init the keyboard navigation
  408. */
  409. init: function () {
  410. var keyboardNavigation = this, chart = this.chart, e = this.eventProvider = new EventProvider();
  411. e.addEvent(Series, 'destroy', function () {
  412. return keyboardNavigation.onSeriesDestroy(this);
  413. });
  414. e.addEvent(chart, 'afterDrilldown', function () {
  415. updateChartFocusAfterDrilling(this);
  416. });
  417. e.addEvent(chart, 'drilldown', function (e) {
  418. var point = e.point, series = point.series;
  419. keyboardNavigation.lastDrilledDownPoint = {
  420. x: point.x,
  421. y: point.y,
  422. seriesName: series ? series.name : ''
  423. };
  424. });
  425. e.addEvent(chart, 'drillupall', function () {
  426. setTimeout(function () {
  427. keyboardNavigation.onDrillupAll();
  428. }, 10);
  429. });
  430. // Heatmaps et al. alter z-index in setState, causing elements
  431. // to lose focus
  432. e.addEvent(Point, 'afterSetState', function () {
  433. var point = this;
  434. var pointEl = point.graphic && point.graphic.element;
  435. if (chart.highlightedPoint === point &&
  436. doc.activeElement !== pointEl &&
  437. pointEl &&
  438. pointEl.focus) {
  439. pointEl.focus();
  440. }
  441. });
  442. },
  443. onDrillupAll: function () {
  444. // After drillup we want to find the point that was drilled down to and
  445. // highlight it.
  446. var last = this.lastDrilledDownPoint, chart = this.chart, series = last && getSeriesFromName(chart, last.seriesName), point;
  447. if (last && series && defined(last.x) && defined(last.y)) {
  448. point = getPointFromXY(series, last.x, last.y);
  449. }
  450. // Container focus can be lost on drillup due to deleted elements.
  451. if (chart.container) {
  452. chart.container.focus();
  453. }
  454. if (point && point.highlight) {
  455. point.highlight();
  456. }
  457. if (chart.focusElement) {
  458. chart.focusElement.removeFocusBorder();
  459. }
  460. },
  461. /**
  462. * @return {Highcharts.KeyboardNavigationHandler}
  463. */
  464. getKeyboardNavigationHandler: function () {
  465. var keyboardNavigation = this, keys = this.keyCodes, chart = this.chart, inverted = chart.inverted;
  466. return new KeyboardNavigationHandler(chart, {
  467. keyCodeMap: [
  468. [inverted ? [keys.up, keys.down] : [keys.left, keys.right], function (keyCode) {
  469. return keyboardNavigation.onKbdSideways(this, keyCode);
  470. }],
  471. [inverted ? [keys.left, keys.right] : [keys.up, keys.down], function (keyCode) {
  472. return keyboardNavigation.onKbdVertical(this, keyCode);
  473. }],
  474. [[keys.enter, keys.space], function (keyCode, event) {
  475. var point = chart.highlightedPoint;
  476. if (point) {
  477. event.point = point;
  478. fireEvent(point.series, 'click', event);
  479. point.firePointEvent('click');
  480. }
  481. return this.response.success;
  482. }]
  483. ],
  484. init: function (dir) {
  485. return keyboardNavigation.onHandlerInit(this, dir);
  486. },
  487. terminate: function () {
  488. return keyboardNavigation.onHandlerTerminate();
  489. }
  490. });
  491. },
  492. /**
  493. * @private
  494. * @param {Highcharts.KeyboardNavigationHandler} handler
  495. * @param {number} keyCode
  496. * @return {number}
  497. * response
  498. */
  499. onKbdSideways: function (handler, keyCode) {
  500. var keys = this.keyCodes, isNext = keyCode === keys.right || keyCode === keys.down;
  501. return this.attemptHighlightAdjacentPoint(handler, isNext);
  502. },
  503. /**
  504. * @private
  505. * @param {Highcharts.KeyboardNavigationHandler} handler
  506. * @param {number} keyCode
  507. * @return {number}
  508. * response
  509. */
  510. onKbdVertical: function (handler, keyCode) {
  511. var chart = this.chart, keys = this.keyCodes, isNext = keyCode === keys.down || keyCode === keys.right, navOptions = chart.options.accessibility.keyboardNavigation
  512. .seriesNavigation;
  513. // Handle serialized mode, act like left/right
  514. if (navOptions.mode && navOptions.mode === 'serialize') {
  515. return this.attemptHighlightAdjacentPoint(handler, isNext);
  516. }
  517. // Normal mode, move between series
  518. var highlightMethod = (chart.highlightedPoint &&
  519. chart.highlightedPoint.series.keyboardMoveVertical) ?
  520. 'highlightAdjacentPointVertical' :
  521. 'highlightAdjacentSeries';
  522. chart[highlightMethod](isNext);
  523. return handler.response.success;
  524. },
  525. /**
  526. * @private
  527. * @param {Highcharts.KeyboardNavigationHandler} handler
  528. * @param {number} initDirection
  529. * @return {number}
  530. * response
  531. */
  532. onHandlerInit: function (handler, initDirection) {
  533. var chart = this.chart;
  534. if (initDirection > 0) {
  535. highlightFirstValidPointInChart(chart);
  536. }
  537. else {
  538. highlightLastValidPointInChart(chart);
  539. }
  540. return handler.response.success;
  541. },
  542. /**
  543. * @private
  544. */
  545. onHandlerTerminate: function () {
  546. var chart = this.chart;
  547. var curPoint = chart.highlightedPoint;
  548. if (chart.tooltip) {
  549. chart.tooltip.hide(0);
  550. }
  551. if (chart.highlightedPoint && chart.highlightedPoint.onMouseOut) {
  552. chart.highlightedPoint.onMouseOut();
  553. }
  554. delete chart.highlightedPoint;
  555. },
  556. /**
  557. * Function that attempts to highlight next/prev point. Handles wrap around.
  558. * @private
  559. * @param {Highcharts.KeyboardNavigationHandler} handler
  560. * @param {boolean} directionIsNext
  561. * @return {number}
  562. * response
  563. */
  564. attemptHighlightAdjacentPoint: function (handler, directionIsNext) {
  565. var chart = this.chart, wrapAround = chart.options.accessibility.keyboardNavigation
  566. .wrapAround, highlightSuccessful = chart.highlightAdjacentPoint(directionIsNext);
  567. if (!highlightSuccessful) {
  568. if (wrapAround) {
  569. return handler.init(directionIsNext ? 1 : -1);
  570. }
  571. return handler.response[directionIsNext ? 'next' : 'prev'];
  572. }
  573. return handler.response.success;
  574. },
  575. /**
  576. * @private
  577. */
  578. onSeriesDestroy: function (series) {
  579. var chart = this.chart, currentHighlightedPointDestroyed = chart.highlightedPoint &&
  580. chart.highlightedPoint.series === series;
  581. if (currentHighlightedPointDestroyed) {
  582. delete chart.highlightedPoint;
  583. if (chart.focusElement) {
  584. chart.focusElement.removeFocusBorder();
  585. }
  586. }
  587. },
  588. /**
  589. * @private
  590. */
  591. destroy: function () {
  592. this.eventProvider.removeAddedEvents();
  593. }
  594. });
  595. export default SeriesKeyboardNavigation;