RadialAxis.js 21 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685
  1. /* *
  2. * (c) 2010-2019 Torstein Honsi
  3. *
  4. * License: www.highcharts.com/license
  5. */
  6. 'use strict';
  7. import H from '../parts/Globals.js';
  8. import '../parts/Utilities.js';
  9. import '../parts/Axis.js';
  10. import '../parts/Tick.js';
  11. import './Pane.js';
  12. var addEvent = H.addEvent,
  13. Axis = H.Axis,
  14. extend = H.extend,
  15. merge = H.merge,
  16. noop = H.noop,
  17. pick = H.pick,
  18. pInt = H.pInt,
  19. Tick = H.Tick,
  20. wrap = H.wrap,
  21. correctFloat = H.correctFloat,
  22. hiddenAxisMixin, // @todo Extract this to a new file
  23. radialAxisMixin, // @todo Extract this to a new file
  24. axisProto = Axis.prototype,
  25. tickProto = Tick.prototype;
  26. if (!H.radialAxisExtended) {
  27. H.radialAxisExtended = true;
  28. // Augmented methods for the x axis in order to hide it completely, used for
  29. // the X axis in gauges
  30. hiddenAxisMixin = {
  31. getOffset: noop,
  32. redraw: function () {
  33. this.isDirty = false; // prevent setting Y axis dirty
  34. },
  35. render: function () {
  36. this.isDirty = false; // prevent setting Y axis dirty
  37. },
  38. setScale: noop,
  39. setCategories: noop,
  40. setTitle: noop
  41. };
  42. // Augmented methods for the value axis
  43. radialAxisMixin = {
  44. // The default options extend defaultYAxisOptions
  45. defaultRadialGaugeOptions: {
  46. labels: {
  47. align: 'center',
  48. x: 0,
  49. y: null // auto
  50. },
  51. minorGridLineWidth: 0,
  52. minorTickInterval: 'auto',
  53. minorTickLength: 10,
  54. minorTickPosition: 'inside',
  55. minorTickWidth: 1,
  56. tickLength: 10,
  57. tickPosition: 'inside',
  58. tickWidth: 2,
  59. title: {
  60. rotation: 0
  61. },
  62. zIndex: 2 // behind dials, points in the series group
  63. },
  64. // Circular axis around the perimeter of a polar chart
  65. defaultRadialXOptions: {
  66. gridLineWidth: 1, // spokes
  67. labels: {
  68. align: null, // auto
  69. distance: 15,
  70. x: 0,
  71. y: null, // auto
  72. style: {
  73. textOverflow: 'none' // wrap lines by default (#7248)
  74. }
  75. },
  76. maxPadding: 0,
  77. minPadding: 0,
  78. showLastLabel: false,
  79. tickLength: 0
  80. },
  81. // Radial axis, like a spoke in a polar chart
  82. defaultRadialYOptions: {
  83. gridLineInterpolation: 'circle',
  84. labels: {
  85. align: 'right',
  86. x: -3,
  87. y: -2
  88. },
  89. showLastLabel: false,
  90. title: {
  91. x: 4,
  92. text: null,
  93. rotation: 90
  94. }
  95. },
  96. // Merge and set options
  97. setOptions: function (userOptions) {
  98. var options = this.options = merge(
  99. this.defaultOptions,
  100. this.defaultRadialOptions,
  101. userOptions
  102. );
  103. // Make sure the plotBands array is instanciated for each Axis
  104. // (#2649)
  105. if (!options.plotBands) {
  106. options.plotBands = [];
  107. }
  108. H.fireEvent(this, 'afterSetOptions');
  109. },
  110. // Wrap the getOffset method to return zero offset for title or labels
  111. // in a radial axis
  112. getOffset: function () {
  113. // Call the Axis prototype method (the method we're in now is on the
  114. // instance)
  115. axisProto.getOffset.call(this);
  116. // Title or label offsets are not counted
  117. this.chart.axisOffset[this.side] = 0;
  118. },
  119. // Get the path for the axis line. This method is also referenced in the
  120. // getPlotLinePath method.
  121. getLinePath: function (lineWidth, radius) {
  122. var center = this.center,
  123. end,
  124. chart = this.chart,
  125. r = pick(radius, center[2] / 2 - this.offset),
  126. path;
  127. if (this.isCircular || radius !== undefined) {
  128. path = this.chart.renderer.symbols.arc(
  129. this.left + center[0],
  130. this.top + center[1],
  131. r,
  132. r,
  133. {
  134. start: this.startAngleRad,
  135. end: this.endAngleRad,
  136. open: true,
  137. innerR: 0
  138. }
  139. );
  140. // Bounds used to position the plotLine label next to the line
  141. // (#7117)
  142. path.xBounds = [this.left + center[0]];
  143. path.yBounds = [this.top + center[1] - r];
  144. } else {
  145. end = this.postTranslate(this.angleRad, r);
  146. path = [
  147. 'M',
  148. center[0] + chart.plotLeft,
  149. center[1] + chart.plotTop,
  150. 'L',
  151. end.x,
  152. end.y
  153. ];
  154. }
  155. return path;
  156. },
  157. /* *
  158. * Override setAxisTranslation by setting the translation to the
  159. * difference in rotation. This allows the translate method to return
  160. * angle for any given value.
  161. */
  162. setAxisTranslation: function () {
  163. // Call uber method
  164. axisProto.setAxisTranslation.call(this);
  165. // Set transA and minPixelPadding
  166. if (this.center) { // it's not defined the first time
  167. if (this.isCircular) {
  168. this.transA = (this.endAngleRad - this.startAngleRad) /
  169. ((this.max - this.min) || 1);
  170. } else {
  171. this.transA = (
  172. (this.center[2] / 2) /
  173. ((this.max - this.min) || 1)
  174. );
  175. }
  176. if (this.isXAxis) {
  177. this.minPixelPadding = this.transA * this.minPointOffset;
  178. } else {
  179. // This is a workaround for regression #2593, but categories
  180. // still don't position correctly.
  181. this.minPixelPadding = 0;
  182. }
  183. }
  184. },
  185. /* *
  186. * In case of auto connect, add one closestPointRange to the max value
  187. * right before tickPositions are computed, so that ticks will extend
  188. * passed the real max.
  189. */
  190. beforeSetTickPositions: function () {
  191. // If autoConnect is true, polygonal grid lines are connected, and
  192. // one closestPointRange is added to the X axis to prevent the last
  193. // point from overlapping the first.
  194. this.autoConnect = (
  195. this.isCircular &&
  196. pick(this.userMax, this.options.max) === undefined &&
  197. correctFloat(this.endAngleRad - this.startAngleRad) ===
  198. correctFloat(2 * Math.PI)
  199. );
  200. if (this.autoConnect) {
  201. this.max += (
  202. (this.categories && 1) ||
  203. this.pointRange ||
  204. this.closestPointRange ||
  205. 0
  206. ); // #1197, #2260
  207. }
  208. },
  209. /* *
  210. * Override the setAxisSize method to use the arc's circumference as
  211. * length. This allows tickPixelInterval to apply to pixel lengths along
  212. * the perimeter
  213. */
  214. setAxisSize: function () {
  215. axisProto.setAxisSize.call(this);
  216. if (this.isRadial) {
  217. // Set the center array
  218. this.pane.updateCenter(this);
  219. // The sector is used in Axis.translate to compute the
  220. // translation of reversed axis points (#2570)
  221. if (this.isCircular) {
  222. this.sector = this.endAngleRad - this.startAngleRad;
  223. }
  224. // Axis len is used to lay out the ticks
  225. this.len = this.width = this.height =
  226. this.center[2] * pick(this.sector, 1) / 2;
  227. }
  228. },
  229. /* *
  230. * Returns the x, y coordinate of a point given by a value and a pixel
  231. * distance from center
  232. */
  233. getPosition: function (value, length) {
  234. return this.postTranslate(
  235. this.isCircular ?
  236. this.translate(value) :
  237. this.angleRad, // #2848
  238. pick(
  239. this.isCircular ? length : this.translate(value),
  240. this.center[2] / 2
  241. ) - this.offset
  242. );
  243. },
  244. /* *
  245. * Translate from intermediate plotX (angle), plotY (axis.len - radius)
  246. * to final chart coordinates.
  247. */
  248. postTranslate: function (angle, radius) {
  249. var chart = this.chart,
  250. center = this.center;
  251. angle = this.startAngleRad + angle;
  252. return {
  253. x: chart.plotLeft + center[0] + Math.cos(angle) * radius,
  254. y: chart.plotTop + center[1] + Math.sin(angle) * radius
  255. };
  256. },
  257. /* *
  258. * Find the path for plot bands along the radial axis
  259. */
  260. getPlotBandPath: function (from, to, options) {
  261. var center = this.center,
  262. startAngleRad = this.startAngleRad,
  263. fullRadius = center[2] / 2,
  264. radii = [
  265. pick(options.outerRadius, '100%'),
  266. options.innerRadius,
  267. pick(options.thickness, 10)
  268. ],
  269. offset = Math.min(this.offset, 0),
  270. percentRegex = /%$/,
  271. start,
  272. end,
  273. open,
  274. isCircular = this.isCircular, // X axis in a polar chart
  275. ret;
  276. // Polygonal plot bands
  277. if (this.options.gridLineInterpolation === 'polygon') {
  278. ret = this.getPlotLinePath(from).concat(
  279. this.getPlotLinePath(to, true)
  280. );
  281. // Circular grid bands
  282. } else {
  283. // Keep within bounds
  284. from = Math.max(from, this.min);
  285. to = Math.min(to, this.max);
  286. // Plot bands on Y axis (radial axis) - inner and outer radius
  287. // depend on to and from
  288. if (!isCircular) {
  289. radii[0] = this.translate(from);
  290. radii[1] = this.translate(to);
  291. }
  292. // Convert percentages to pixel values
  293. radii = radii.map(function (radius) {
  294. if (percentRegex.test(radius)) {
  295. radius = (pInt(radius, 10) * fullRadius) / 100;
  296. }
  297. return radius;
  298. });
  299. // Handle full circle
  300. if (options.shape === 'circle' || !isCircular) {
  301. start = -Math.PI / 2;
  302. end = Math.PI * 1.5;
  303. open = true;
  304. } else {
  305. start = startAngleRad + this.translate(from);
  306. end = startAngleRad + this.translate(to);
  307. }
  308. radii[0] -= offset; // #5283
  309. radii[2] -= offset; // #5283
  310. ret = this.chart.renderer.symbols.arc(
  311. this.left + center[0],
  312. this.top + center[1],
  313. radii[0],
  314. radii[0],
  315. {
  316. // Math is for reversed yAxis (#3606)
  317. start: Math.min(start, end),
  318. end: Math.max(start, end),
  319. innerR: pick(radii[1], radii[0] - radii[2]),
  320. open: open
  321. }
  322. );
  323. }
  324. return ret;
  325. },
  326. /* *
  327. * Find the path for plot lines perpendicular to the radial axis.
  328. */
  329. getPlotLinePath: function (value, reverse) {
  330. var axis = this,
  331. center = axis.center,
  332. chart = axis.chart,
  333. end = axis.getPosition(value),
  334. xAxis,
  335. xy,
  336. tickPositions,
  337. ret;
  338. // Spokes
  339. if (axis.isCircular) {
  340. ret = [
  341. 'M',
  342. center[0] + chart.plotLeft,
  343. center[1] + chart.plotTop,
  344. 'L',
  345. end.x,
  346. end.y
  347. ];
  348. // Concentric circles
  349. } else if (axis.options.gridLineInterpolation === 'circle') {
  350. value = axis.translate(value);
  351. // a value of 0 is in the center, so it won't be visible,
  352. // but draw it anyway for update and animation (#2366)
  353. ret = axis.getLinePath(0, value);
  354. // Concentric polygons
  355. } else {
  356. // Find the X axis in the same pane
  357. chart.xAxis.forEach(function (a) {
  358. if (a.pane === axis.pane) {
  359. xAxis = a;
  360. }
  361. });
  362. ret = [];
  363. value = axis.translate(value);
  364. tickPositions = xAxis.tickPositions;
  365. if (xAxis.autoConnect) {
  366. tickPositions = tickPositions.concat([tickPositions[0]]);
  367. }
  368. // Reverse the positions for concatenation of polygonal plot
  369. // bands
  370. if (reverse) {
  371. tickPositions = [].concat(tickPositions).reverse();
  372. }
  373. tickPositions.forEach(function (pos, i) {
  374. xy = xAxis.getPosition(pos, value);
  375. ret.push(i ? 'L' : 'M', xy.x, xy.y);
  376. });
  377. }
  378. return ret;
  379. },
  380. /* *
  381. * Find the position for the axis title, by default inside the gauge
  382. */
  383. getTitlePosition: function () {
  384. var center = this.center,
  385. chart = this.chart,
  386. titleOptions = this.options.title;
  387. return {
  388. x: chart.plotLeft + center[0] + (titleOptions.x || 0),
  389. y: (
  390. chart.plotTop +
  391. center[1] -
  392. (
  393. {
  394. high: 0.5,
  395. middle: 0.25,
  396. low: 0
  397. }[titleOptions.align] * center[2]
  398. ) +
  399. (titleOptions.y || 0)
  400. )
  401. };
  402. }
  403. };
  404. // Actions before axis init.
  405. addEvent(Axis, 'init', function (e) {
  406. var axis = this,
  407. chart = this.chart,
  408. angular = chart.angular,
  409. polar = chart.polar,
  410. isX = this.isXAxis,
  411. isHidden = angular && isX,
  412. isCircular,
  413. chartOptions = chart.options,
  414. paneIndex = e.userOptions.pane || 0,
  415. pane = this.pane = chart.pane && chart.pane[paneIndex];
  416. // Before prototype.init
  417. if (angular) {
  418. extend(this, isHidden ? hiddenAxisMixin : radialAxisMixin);
  419. isCircular = !isX;
  420. if (isCircular) {
  421. this.defaultRadialOptions = this.defaultRadialGaugeOptions;
  422. }
  423. } else if (polar) {
  424. extend(this, radialAxisMixin);
  425. isCircular = isX;
  426. this.defaultRadialOptions = isX ?
  427. this.defaultRadialXOptions :
  428. merge(this.defaultYAxisOptions, this.defaultRadialYOptions);
  429. }
  430. // Disable certain features on angular and polar axes
  431. if (angular || polar) {
  432. this.isRadial = true;
  433. chart.inverted = false;
  434. chartOptions.chart.zoomType = null;
  435. // Prevent overlapping axis labels (#9761)
  436. chart.labelCollectors.push(function () {
  437. if (
  438. axis.isRadial &&
  439. axis.tickPositions &&
  440. // undocumented option for now, but working
  441. axis.options.labels.allowOverlap !== true
  442. ) {
  443. return axis.tickPositions
  444. .map(function (pos) {
  445. return axis.ticks[pos] && axis.ticks[pos].label;
  446. })
  447. .filter(function (label) {
  448. return Boolean(label);
  449. });
  450. }
  451. });
  452. } else {
  453. this.isRadial = false;
  454. }
  455. // A pointer back to this axis to borrow geometry
  456. if (pane && isCircular) {
  457. pane.axis = this;
  458. }
  459. this.isCircular = isCircular;
  460. });
  461. addEvent(Axis, 'afterInit', function () {
  462. var chart = this.chart,
  463. options = this.options,
  464. isHidden = chart.angular && this.isXAxis,
  465. pane = this.pane,
  466. paneOptions = pane && pane.options;
  467. if (!isHidden && pane && (chart.angular || chart.polar)) {
  468. // Start and end angle options are
  469. // given in degrees relative to top, while internal computations are
  470. // in radians relative to right (like SVG).
  471. // Y axis in polar charts
  472. this.angleRad = (options.angle || 0) * Math.PI / 180;
  473. // Gauges
  474. this.startAngleRad = (paneOptions.startAngle - 90) * Math.PI / 180;
  475. this.endAngleRad = (
  476. pick(paneOptions.endAngle, paneOptions.startAngle + 360) - 90
  477. ) * Math.PI / 180; // Gauges
  478. this.offset = options.offset || 0;
  479. }
  480. });
  481. // Wrap auto label align to avoid setting axis-wide rotation on radial axes
  482. // (#4920)
  483. addEvent(Axis, 'autoLabelAlign', function (e) {
  484. if (this.isRadial) {
  485. e.align = undefined;
  486. e.preventDefault();
  487. }
  488. });
  489. // Add special cases within the Tick class' methods for radial axes.
  490. addEvent(Tick, 'afterGetPosition', function (e) {
  491. if (this.axis.getPosition) {
  492. extend(e.pos, this.axis.getPosition(this.pos));
  493. }
  494. });
  495. // Find the center position of the label based on the distance option.
  496. addEvent(Tick, 'afterGetLabelPosition', function (e) {
  497. var axis = this.axis,
  498. label = this.label,
  499. labelOptions = axis.options.labels,
  500. optionsY = labelOptions.y,
  501. ret,
  502. centerSlot = 20, // 20 degrees to each side at the top and bottom
  503. align = labelOptions.align,
  504. angle = (
  505. (axis.translate(this.pos) + axis.startAngleRad + Math.PI / 2) /
  506. Math.PI * 180
  507. ) % 360;
  508. if (axis.isRadial) { // Both X and Y axes in a polar chart
  509. ret = axis.getPosition(this.pos, (axis.center[2] / 2) +
  510. pick(labelOptions.distance, -25));
  511. // Automatically rotated
  512. if (labelOptions.rotation === 'auto') {
  513. label.attr({
  514. rotation: angle
  515. });
  516. // Vertically centered
  517. } else if (optionsY === null) {
  518. optionsY = (
  519. axis.chart.renderer
  520. .fontMetrics(label.styles && label.styles.fontSize).b -
  521. label.getBBox().height / 2
  522. );
  523. }
  524. // Automatic alignment
  525. if (align === null) {
  526. if (axis.isCircular) { // Y axis
  527. if (
  528. this.label.getBBox().width >
  529. axis.len * axis.tickInterval / (axis.max - axis.min)
  530. ) { // #3506
  531. centerSlot = 0;
  532. }
  533. if (angle > centerSlot && angle < 180 - centerSlot) {
  534. align = 'left'; // right hemisphere
  535. } else if (
  536. angle > 180 + centerSlot &&
  537. angle < 360 - centerSlot
  538. ) {
  539. align = 'right'; // left hemisphere
  540. } else {
  541. align = 'center'; // top or bottom
  542. }
  543. } else {
  544. align = 'center';
  545. }
  546. label.attr({
  547. align: align
  548. });
  549. }
  550. e.pos.x = ret.x + labelOptions.x;
  551. e.pos.y = ret.y + optionsY;
  552. }
  553. });
  554. // Wrap the getMarkPath function to return the path of the radial marker
  555. wrap(tickProto, 'getMarkPath', function (
  556. proceed,
  557. x,
  558. y,
  559. tickLength,
  560. tickWidth,
  561. horiz,
  562. renderer
  563. ) {
  564. var axis = this.axis,
  565. endPoint,
  566. ret;
  567. if (axis.isRadial) {
  568. endPoint = axis.getPosition(
  569. this.pos,
  570. axis.center[2] / 2 + tickLength
  571. );
  572. ret = [
  573. 'M',
  574. x,
  575. y,
  576. 'L',
  577. endPoint.x,
  578. endPoint.y
  579. ];
  580. } else {
  581. ret = proceed.call(
  582. this,
  583. x,
  584. y,
  585. tickLength,
  586. tickWidth,
  587. horiz,
  588. renderer
  589. );
  590. }
  591. return ret;
  592. });
  593. }