SeriesKeyboardNavigation.js 20 KB

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