SeriesKeyboardNavigation.js 20 KB

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