apexcharts 7.3.0 → 7.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (177) hide show
  1. package/LICENSE +39 -11
  2. package/dist/apexcharts.common.js +2 -2
  3. package/dist/apexcharts.core.js +579 -467
  4. package/dist/apexcharts.core.min.js +2 -2
  5. package/dist/apexcharts.esm.js +1347 -810
  6. package/dist/apexcharts.js +896 -267
  7. package/dist/apexcharts.min.js +2 -2
  8. package/dist/apexcharts.ssr.common.js +2 -2
  9. package/dist/apexcharts.ssr.esm.js +896 -267
  10. package/dist/area.common.js +1 -1
  11. package/dist/area.esm.js +1 -1
  12. package/dist/bar.common.js +2 -2
  13. package/dist/bar.esm.js +20 -10
  14. package/dist/bar.js +2 -2
  15. package/dist/boxPlot.common.js +2 -2
  16. package/dist/boxPlot.esm.js +20 -10
  17. package/dist/bubble.common.js +1 -1
  18. package/dist/bubble.esm.js +1 -1
  19. package/dist/candlestick.common.js +2 -2
  20. package/dist/candlestick.esm.js +20 -10
  21. package/dist/candlestick.js +2 -2
  22. package/dist/column.common.js +2 -2
  23. package/dist/column.esm.js +20 -10
  24. package/dist/core.common.js +2 -2
  25. package/dist/core.esm.js +579 -467
  26. package/dist/donut.common.js +1 -1
  27. package/dist/donut.esm.js +1 -1
  28. package/dist/dumbbell.common.js +2 -2
  29. package/dist/dumbbell.esm.js +20 -10
  30. package/dist/features/all.common.js +2 -2
  31. package/dist/features/all.esm.js +536 -34
  32. package/dist/features/annotations.common.js +1 -1
  33. package/dist/features/annotations.esm.js +1 -1
  34. package/dist/features/annotations.js +1 -1
  35. package/dist/features/context-menu.common.js +1 -1
  36. package/dist/features/context-menu.esm.js +1 -1
  37. package/dist/features/context-menu.js +1 -1
  38. package/dist/features/drilldown.common.js +1 -1
  39. package/dist/features/drilldown.esm.js +1 -1
  40. package/dist/features/drilldown.js +1 -1
  41. package/dist/features/dumbbell.common.js +1 -1
  42. package/dist/features/dumbbell.esm.js +1 -1
  43. package/dist/features/dumbbell.js +1 -1
  44. package/dist/features/exports.common.js +2 -2
  45. package/dist/features/exports.esm.js +12 -14
  46. package/dist/features/exports.js +2 -2
  47. package/dist/features/facet.common.js +1 -1
  48. package/dist/features/facet.esm.js +1 -1
  49. package/dist/features/facet.js +1 -1
  50. package/dist/features/history.common.js +1 -1
  51. package/dist/features/history.esm.js +1 -1
  52. package/dist/features/history.js +1 -1
  53. package/dist/features/ink.common.js +1 -1
  54. package/dist/features/ink.esm.js +1 -1
  55. package/dist/features/ink.js +1 -1
  56. package/dist/features/keyboard.common.js +2 -2
  57. package/dist/features/keyboard.esm.js +14 -1
  58. package/dist/features/keyboard.js +2 -2
  59. package/dist/features/legend.common.js +1 -1
  60. package/dist/features/legend.esm.js +1 -1
  61. package/dist/features/legend.js +1 -1
  62. package/dist/features/link.common.js +1 -1
  63. package/dist/features/link.esm.js +38 -38
  64. package/dist/features/link.js +1 -1
  65. package/dist/features/marks.common.js +1 -1
  66. package/dist/features/marks.esm.js +1 -1
  67. package/dist/features/marks.js +1 -1
  68. package/dist/features/measure.common.js +1 -1
  69. package/dist/features/measure.esm.js +1 -1
  70. package/dist/features/measure.js +1 -1
  71. package/dist/features/morph.common.js +1 -1
  72. package/dist/features/morph.esm.js +1 -1
  73. package/dist/features/morph.js +1 -1
  74. package/dist/features/perspectives.common.js +1 -1
  75. package/dist/features/perspectives.esm.js +11 -11
  76. package/dist/features/perspectives.js +1 -1
  77. package/dist/features/raincloud.common.js +1 -1
  78. package/dist/features/raincloud.esm.js +1 -1
  79. package/dist/features/raincloud.js +1 -1
  80. package/dist/features/renderer-canvas.common.js +1 -1
  81. package/dist/features/renderer-canvas.esm.js +1 -1
  82. package/dist/features/renderer-canvas.js +1 -1
  83. package/dist/features/stats.common.js +1 -1
  84. package/dist/features/stats.esm.js +1 -1
  85. package/dist/features/stats.js +1 -1
  86. package/dist/features/storyboard.common.js +1 -1
  87. package/dist/features/storyboard.esm.js +11 -11
  88. package/dist/features/storyboard.js +1 -1
  89. package/dist/features/streamgraph.common.js +1 -1
  90. package/dist/features/streamgraph.esm.js +1 -1
  91. package/dist/features/streamgraph.js +1 -1
  92. package/dist/features/toolbar.common.js +2 -2
  93. package/dist/features/toolbar.esm.js +172 -25
  94. package/dist/features/toolbar.js +2 -2
  95. package/dist/features/trellis.common.js +1 -1
  96. package/dist/features/trellis.esm.js +1 -1
  97. package/dist/features/trellis.js +1 -1
  98. package/dist/features/waterfall.common.js +1 -1
  99. package/dist/features/waterfall.esm.js +1 -1
  100. package/dist/features/waterfall.js +1 -1
  101. package/dist/features/weave.common.js +2 -2
  102. package/dist/features/weave.esm.js +352 -10
  103. package/dist/features/weave.js +2 -2
  104. package/dist/heatmap.common.js +1 -1
  105. package/dist/heatmap.esm.js +1 -1
  106. package/dist/heatmap.js +1 -1
  107. package/dist/histogram.common.js +2 -2
  108. package/dist/histogram.esm.js +20 -10
  109. package/dist/line.common.js +1 -1
  110. package/dist/line.esm.js +1 -1
  111. package/dist/line.js +1 -1
  112. package/dist/pictograms.common.js +1 -1
  113. package/dist/pictograms.esm.js +1 -1
  114. package/dist/pictograms.js +1 -1
  115. package/dist/pie.common.js +1 -1
  116. package/dist/pie.esm.js +1 -1
  117. package/dist/pie.js +1 -1
  118. package/dist/polarArea.common.js +1 -1
  119. package/dist/polarArea.esm.js +1 -1
  120. package/dist/radar.common.js +1 -1
  121. package/dist/radar.esm.js +1 -1
  122. package/dist/radar.js +1 -1
  123. package/dist/radialBar.common.js +2 -2
  124. package/dist/radialBar.esm.js +27 -2
  125. package/dist/radialBar.js +2 -2
  126. package/dist/raincloud.common.js +2 -2
  127. package/dist/raincloud.esm.js +20 -10
  128. package/dist/rangeArea.common.js +1 -1
  129. package/dist/rangeArea.esm.js +1 -1
  130. package/dist/rangeBar.common.js +2 -2
  131. package/dist/rangeBar.esm.js +20 -10
  132. package/dist/scatter.common.js +1 -1
  133. package/dist/scatter.esm.js +1 -1
  134. package/dist/streamgraph.common.js +1 -1
  135. package/dist/streamgraph.esm.js +1 -1
  136. package/dist/sunburst.common.js +1 -1
  137. package/dist/sunburst.esm.js +1 -1
  138. package/dist/sunburst.js +1 -1
  139. package/dist/treemap.common.js +1 -1
  140. package/dist/treemap.esm.js +1 -1
  141. package/dist/treemap.js +1 -1
  142. package/dist/unit-shapes.common.js +1 -1
  143. package/dist/unit-shapes.esm.js +1 -1
  144. package/dist/unit-shapes.js +1 -1
  145. package/dist/unit.common.js +1 -1
  146. package/dist/unit.esm.js +79 -79
  147. package/dist/unit.js +1 -1
  148. package/dist/violin.common.js +2 -2
  149. package/dist/violin.esm.js +20 -10
  150. package/dist/violin.js +2 -2
  151. package/dist/waterfall.common.js +2 -2
  152. package/dist/waterfall.esm.js +20 -10
  153. package/package.json +2 -2
  154. package/src/apexcharts.js +39 -15
  155. package/src/charts/Radial.js +12 -3
  156. package/src/charts/common/bar/Helpers.js +31 -12
  157. package/src/modules/Data.js +8 -2
  158. package/src/modules/DataLabels.js +14 -4
  159. package/src/modules/Exports.js +14 -12
  160. package/src/modules/Formatters.js +24 -1
  161. package/src/modules/Graphics.js +10 -0
  162. package/src/modules/Range.js +28 -1
  163. package/src/modules/Toolbar.js +112 -2
  164. package/src/modules/ZoomPanSelection.js +85 -7
  165. package/src/modules/accessibility/KeyboardNavigation.js +14 -0
  166. package/src/modules/axes/YAxis.js +12 -15
  167. package/src/modules/helpers/UpdateHelpers.js +25 -8
  168. package/src/modules/settings/Globals.js +5 -10
  169. package/src/modules/settings/Options.js +15 -0
  170. package/src/modules/weave/Claims.js +231 -0
  171. package/src/modules/weave/Drawn.js +169 -0
  172. package/src/modules/weave/PluginAPI.js +195 -4
  173. package/src/modules/weave/WeaveHost.js +76 -3
  174. package/src/ssr/DOMShim.js +50 -0
  175. package/src/types/internal.d.ts +1 -1
  176. package/src/utils/Utils.js +6 -9
  177. package/types/apexcharts.d.ts +92 -0
@@ -560,6 +560,14 @@ export default class Helpers {
560
560
  * second at the top, and nothing else touched. Stacked totals already resolve
561
561
  * per group (see drawsStackedTotal, #4173); corners never got the same fix.
562
562
  *
563
+ * plotOptions.bar.borderRadiusWhenStacked picks which ends round: 'all'
564
+ * caps both the baseline and the far end, 'last' only the far end (#4845).
565
+ *
566
+ * A single-data-point chart used to hand its baseline segment 'top' (and a
567
+ * solo segment 'top' instead of 'both'), which on a horizontal stack rounded
568
+ * the first segment on its INNER edge and left a pill in the middle of the
569
+ * bar (#4845). One data point is resolved like any other now.
570
+ *
563
571
  * @param {any[]} series
564
572
  * @returns {string[][]}
565
573
  */
@@ -577,12 +585,17 @@ export default class Helpers {
577
585
 
578
586
  if (alwaysApplyRadius) return output
579
587
 
580
- // A lone horizontal bar in a single-category chart keeps 'top' where a
581
- // column would take the full 'both'. Preserved from the original.
582
- const isSoloHorizontal =
583
- this.w.config.chart.type === 'bar' && numColumns === 1
584
- const soloCorner = isSoloHorizontal ? 'top' : 'both'
585
- const baseCorner = isSoloHorizontal ? 'top' : 'bottom'
588
+ // 'last' rounds only the far end of a stack and leaves the baseline end
589
+ // square (#4845); 'all' caps both ends. Corner names are screen-side:
590
+ // for a positive segment the far end is 'top', for a negative one it is
591
+ // 'bottom', so a lone positive drops from 'both' to 'top' and a lone
592
+ // negative from 'both' to 'bottom'.
593
+ const lastOnly =
594
+ w.config.plotOptions.bar.borderRadiusWhenStacked === 'last'
595
+ const soloPositive = lastOnly ? 'top' : 'both'
596
+ const soloNegative = lastOnly ? 'bottom' : 'both'
597
+ const positiveBase = lastOnly ? 'none' : 'bottom'
598
+ const negativeBase = lastOnly ? 'none' : 'top'
586
599
 
587
600
  for (const stack of this.getStackedSeriesIndices(numSeries)) {
588
601
  for (let j = 0; j < numColumns; j++) {
@@ -599,24 +612,30 @@ export default class Helpers {
599
612
 
600
613
  if (positiveIndices.length > 0 && negativeIndices.length === 0) {
601
614
  if (positiveIndices.length === 1) {
602
- output[positiveIndices[0]][j] = soloCorner
615
+ output[positiveIndices[0]][j] = soloPositive
603
616
  } else {
604
617
  const first = positiveIndices[0]
605
618
  const last = positiveIndices[positiveIndices.length - 1]
606
619
  for (const i of positiveIndices) {
607
620
  output[i][j] =
608
- i === first ? baseCorner : i === last ? 'top' : 'none'
621
+ i === first ? positiveBase : i === last ? 'top' : 'none'
609
622
  }
610
623
  }
611
624
  } else if (negativeIndices.length > 0 && positiveIndices.length === 0) {
612
625
  if (negativeIndices.length === 1) {
613
- output[negativeIndices[0]][j] = 'both'
626
+ output[negativeIndices[0]][j] = soloNegative
614
627
  } else {
615
- const highest = Math.max(...negativeIndices) // closest to the axis
616
- const lowest = Math.min(...negativeIndices) // farthest from it
628
+ // Negatives stack away from the axis in series order, so the
629
+ // first is at the baseline and the last is the far end.
630
+ const nearest = Math.min(...negativeIndices)
631
+ const farthest = Math.max(...negativeIndices)
617
632
  for (const i of negativeIndices) {
618
633
  output[i][j] =
619
- i === highest ? 'bottom' : i === lowest ? 'top' : 'none'
634
+ i === nearest
635
+ ? negativeBase
636
+ : i === farthest
637
+ ? 'bottom'
638
+ : 'none'
620
639
  }
621
640
  }
622
641
  } else if (positiveIndices.length > 0 && negativeIndices.length > 0) {
@@ -1919,8 +1919,11 @@ export default class Data {
1919
1919
  // Segregate user provided data into appropriate vars
1920
1920
  /**
1921
1921
  * @param {any[]} ser
1922
+ * @param {boolean} [overwriteInitialSeries=true] false when the caller hands
1923
+ * back the series the chart already has (an internal re-render), so the
1924
+ * baseline resetSeries() restores must not move.
1922
1925
  */
1923
- parseData(ser) {
1926
+ parseData(ser, overwriteInitialSeries = true) {
1924
1927
  const w = this.w
1925
1928
  const cnf = w.config
1926
1929
  const gl = w.globals
@@ -1993,7 +1996,10 @@ export default class Data {
1993
1996
  // Re-cloning from cnf.series each parse would corrupt initialSeries and
1994
1997
  // break resetZoom (it would only restore one zoom step). Instead, snapshot
1995
1998
  // from the raw stash so initialSeries always represents the true input.
1996
- if (gl.dataReducerRawSeries && cnf.chart.dataReducer?.enabled) {
1999
+ if (!overwriteInitialSeries) {
2000
+ // An internal re-render replays the series the chart already has, so the
2001
+ // baseline it was given stays put (#5283).
2002
+ } else if (gl.dataReducerRawSeries && cnf.chart.dataReducer?.enabled) {
1997
2003
  const stash = gl.dataReducerRawSeries
1998
2004
  gl.initialSeries = ser.map((s, i) => ({
1999
2005
  ...s,
@@ -4,6 +4,7 @@ import Graphics from './Graphics'
4
4
  import Filters from './Filters'
5
5
  import { applyProgressiveReveal } from './Animations'
6
6
  import { resolveDataLabelOffset } from './helpers/DataLabelOffset'
7
+ import { resolveClaimed } from './weave/Claims'
7
8
 
8
9
  /**
9
10
  * ApexCharts DataLabels Class for drawing dataLabels on Axes based Charts.
@@ -247,10 +248,19 @@ class DataLabels {
247
248
  } = opts
248
249
 
249
250
  let dataLabelText = null
250
- if (Array.isArray(w.config.dataLabels.enabledOnSeries)) {
251
- if (w.config.dataLabels.enabledOnSeries.indexOf(i) < 0) {
252
- return dataLabelText
253
- }
251
+ // The caller's answer for this series, then the plugin's if one claimed it.
252
+ // A claim is a boolean rather than a restatement of the caller's list
253
+ // shape, because "does THIS series print labels" is the question here.
254
+ const labelsOn = resolveClaimed(
255
+ w,
256
+ 'dataLabels.enabledOnSeries',
257
+ i,
258
+ Array.isArray(w.config.dataLabels.enabledOnSeries)
259
+ ? w.config.dataLabels.enabledOnSeries.indexOf(i) >= 0
260
+ : true,
261
+ )
262
+ if (!labelsOn) {
263
+ return dataLabelText
254
264
  }
255
265
 
256
266
  let correctedLabels = {
@@ -998,8 +998,9 @@ class Exports {
998
998
  }
999
999
 
1000
1000
  const handleUnequalXValues = () => {
1001
- const categories = new Set()
1002
- const data = {}
1001
+ // Map stops a plain object category named `__proto__` from writing
1002
+ /** @type {Map<string, {cat: any, values: string[]}>} */
1003
+ const byCategory = new Map()
1003
1004
 
1004
1005
  /**
1005
1006
  * @param {Record<string, any>} s
@@ -1020,14 +1021,13 @@ class Exports {
1020
1021
  } else {
1021
1022
  return
1022
1023
  }
1023
- if (!(/** @type {Record<string,any>} */ (data)[cat])) {
1024
- ;/** @type {Record<string,any>} */ (data)[cat] = Array(
1025
- series.length,
1026
- ).fill('')
1024
+ const key = String(cat)
1025
+ let row = byCategory.get(key)
1026
+ if (!row) {
1027
+ row = { cat, values: Array(series.length).fill('') }
1028
+ byCategory.set(key, row)
1027
1029
  }
1028
- ;/** @type {Record<string,any>} */ (data)[cat][sI] =
1029
- getFormattedValue(value)
1030
- categories.add(cat)
1030
+ row.values[sI] = getFormattedValue(value)
1031
1031
  })
1032
1032
  })
1033
1033
 
@@ -1035,12 +1035,14 @@ class Exports {
1035
1035
  rows.push(columns.join(columnDelimiter))
1036
1036
  }
1037
1037
 
1038
- Array.from(categories)
1038
+ Array.from(byCategory.keys())
1039
1039
  .sort()
1040
- .forEach((cat) => {
1040
+ .forEach((key) => {
1041
+ const { cat, values } = /** @type {{cat: any, values: string[]}} */ (
1042
+ byCategory.get(key)
1043
+ )
1041
1044
  // Join here: pushing the array would leave rows.join() to stringify
1042
1045
  // it, which always uses a comma between category and values.
1043
- const values = /** @type {Record<string,any>} */ (data)[cat]
1044
1046
  rows.push([getFormattedCategory(cat), ...values].join(columnDelimiter))
1045
1047
  })
1046
1048
  }
@@ -84,6 +84,12 @@ class Formatters {
84
84
  setLabelFormatters() {
85
85
  const w = this.w
86
86
  const fmt = w.formatters
87
+ const inferredNumericX =
88
+ w.axisFlags.isXNumeric &&
89
+ w.axisFlags.dataFormatXNumeric &&
90
+ w.config.xaxis.type !== 'datetime' &&
91
+ !w.config.xaxis.convertedCatToNumeric &&
92
+ !w.globals.isBarHorizontal
87
93
 
88
94
  fmt.xaxisTooltipFormatter = (/** @type {any} */ val) => {
89
95
  return this.defaultGeneralFormatter(val)
@@ -109,8 +115,16 @@ class Formatters {
109
115
  if (Utils.isNumber(val)) {
110
116
  if (
111
117
  !w.config.xaxis.convertedCatToNumeric &&
112
- w.config.xaxis.type === 'numeric'
118
+ (w.config.xaxis.type === 'numeric' || inferredNumericX)
113
119
  ) {
120
+ const ticks = w.globals.xAxisScale?.result
121
+ if (
122
+ inferredNumericX &&
123
+ Array.isArray(ticks) &&
124
+ ticks.every(Number.isInteger)
125
+ ) {
126
+ return val.toFixed(0)
127
+ }
114
128
  if (Utils.isNumber(w.config.xaxis.decimalsInFloat)) {
115
129
  return val.toFixed(w.config.xaxis.decimalsInFloat)
116
130
  } else {
@@ -145,6 +159,15 @@ class Formatters {
145
159
 
146
160
  if (typeof w.config.tooltip.x.formatter === 'function') {
147
161
  fmt.ttKeyFormatter = w.config.tooltip.x.formatter
162
+ } else if (
163
+ w.config.xaxis.labels.formatter === undefined &&
164
+ inferredNumericX
165
+ ) {
166
+ const xLabelFormatter = /** @type {(val: any) => any} */ (
167
+ fmt.xLabelFormatter
168
+ )
169
+ fmt.ttKeyFormatter = (/** @type {any} */ val) =>
170
+ Number.isInteger(val) ? val.toFixed(0) : xLabelFormatter(val)
148
171
  } else {
149
172
  fmt.ttKeyFormatter = fmt.xLabelFormatter
150
173
  }
@@ -2,6 +2,7 @@
2
2
  import Animations from './Animations'
3
3
  import Filters from './Filters'
4
4
  import Utils from '../utils/Utils'
5
+ import { resolveClaimed } from './weave/Claims'
5
6
 
6
7
  /**
7
8
  * ApexCharts Graphics Class for all drawing operations.
@@ -609,6 +610,15 @@ class Graphics {
609
610
  } else {
610
611
  strokeDashArray = w.config.stroke.dashArray
611
612
  }
613
+ // A plugin may have claimed this series' dash. Resolved here rather than
614
+ // written into the config above, so the caller's own dashing is untouched
615
+ // and there is nothing to restore when the claim goes. See weave/Claims.
616
+ strokeDashArray = resolveClaimed(
617
+ w,
618
+ 'stroke.dashArray',
619
+ realIndex,
620
+ strokeDashArray,
621
+ )
612
622
 
613
623
  const el = this.drawPath({
614
624
  d,
@@ -740,7 +740,34 @@ class Range {
740
740
  ticks = Math.round(gl.svgWidth / 150)
741
741
 
742
742
  // no labels provided and total number of dataPoints is less than 30
743
- if (cnf.xaxis.type === 'numeric' && gl.dataPoints < 30) {
743
+ const inferredNumericX =
744
+ this.w.axisFlags.isXNumeric &&
745
+ this.w.axisFlags.dataFormatXNumeric &&
746
+ cnf.xaxis.type !== 'datetime' &&
747
+ !cnf.xaxis.convertedCatToNumeric &&
748
+ !gl.isBarHorizontal
749
+ const fontSize = parseFloat(cnf.xaxis.labels.style.fontSize) || 12
750
+ const widestLabelLength = this.w.labelData.labels.reduce(
751
+ (widest, seriesLabels) =>
752
+ Array.isArray(seriesLabels)
753
+ ? seriesLabels.reduce(
754
+ (seriesWidest, label) =>
755
+ Math.max(seriesWidest, String(label).length),
756
+ widest,
757
+ )
758
+ : widest,
759
+ 0,
760
+ )
761
+ // Range runs before axis text is laid out, so use the conventional
762
+ // 0.6em average glyph width to reject one-tick-per-point when the
763
+ // labels cannot plausibly fit in the chart's available width.
764
+ const allPointLabelsFit =
765
+ gl.dataPoints * widestLabelLength * fontSize * 0.6 <= gl.svgWidth
766
+ if (
767
+ (cnf.xaxis.type === 'numeric' ||
768
+ (inferredNumericX && allPointLabelsFit)) &&
769
+ gl.dataPoints < 30
770
+ ) {
744
771
  ticks = gl.dataPoints - 1
745
772
  }
746
773
 
@@ -45,10 +45,60 @@ export default class Toolbar {
45
45
  /** @type {HTMLElement | null} */ this.elMenu = null
46
46
  /** @type {HTMLElement[]} */ this.elMenuItems = []
47
47
  /** @type {any} */ this.t = null
48
+ /**
49
+ * What the last render drew purely because the chart was zoomed: the whole
50
+ * toolbar ('wrap'), just the reset control ('control'), or neither. Read by
51
+ * {@link Toolbar#clearZoomAffordance}, which has to take it down by hand.
52
+ *
53
+ * @type {'wrap' | 'control' | null}
54
+ */
55
+ this._drawnForZoom = null
56
+ }
57
+
58
+ /**
59
+ * Whether this chart's built-in ways back out of a zoom are switched on.
60
+ *
61
+ * The gate `chart.zoom.resetControl` opens, and the one the Escape shortcut
62
+ * reads too, so a page that says it supplies its own reset gets neither.
63
+ * 'auto' means "supply one when nothing else on screen can": a toolbar
64
+ * showing its reset tool is a way back, and anything else is not.
65
+ *
66
+ * @returns {boolean}
67
+ */
68
+ resetControlAllowed() {
69
+ const c = this.w.config.chart
70
+ if (!c.zoom || !c.zoom.enabled) return false
71
+ const setting =
72
+ c.zoom.resetControl === undefined ? 'auto' : c.zoom.resetControl
73
+ if (setting !== 'auto') return !!setting
74
+ const onScreen =
75
+ c.toolbar && c.toolbar.show && c.toolbar.tools && c.toolbar.tools.reset
76
+ return !onScreen
48
77
  }
49
78
 
50
- createToolbar() {
79
+ /**
80
+ * Whether a reset control has to be drawn for the state the chart is in now.
81
+ *
82
+ * Only while the chart is actually zoomed, which is the whole idea: the
83
+ * control appears at the moment the viewer changed the view, where they are
84
+ * already looking, and goes again when the range does. A page that never
85
+ * zooms never sees it, so `toolbar: { show: false }` still means an empty
86
+ * chart for everyone who does not zoom.
87
+ *
88
+ * @returns {boolean}
89
+ */
90
+ resetControlDue() {
91
+ return !!this.w.interact.zoomed && this.resetControlAllowed()
92
+ }
93
+
94
+ /**
95
+ * @param {{ resetOnly?: boolean }} [opts] `resetOnly` draws the on-demand
96
+ * reset control and nothing else, for a chart whose page asked for no
97
+ * toolbar at all. See {@link Toolbar#resetControlDue}.
98
+ */
99
+ createToolbar(opts = {}) {
51
100
  const w = this.w
101
+ const resetOnly = !!opts.resetOnly
52
102
 
53
103
  const createDiv = () => {
54
104
  return BrowserAPIs.createElementNS('http://www.w3.org/1999/xhtml', 'div')
@@ -84,6 +134,32 @@ export default class Toolbar {
84
134
 
85
135
  this.t = w.config.chart.toolbar.tools
86
136
 
137
+ this._drawnForZoom = null
138
+
139
+ if (resetOnly) {
140
+ // Every control off but the one that undoes the zoom. A copy, because
141
+ // this is the state of one render and not a change to the page's config.
142
+ this.t = {
143
+ zoom: false,
144
+ zoomin: false,
145
+ zoomout: false,
146
+ selection: false,
147
+ pan: false,
148
+ measure: false,
149
+ download: false,
150
+ customIcons: [],
151
+ reset: true,
152
+ }
153
+ this._drawnForZoom = 'wrap'
154
+ } else if (!this.t.reset && this.resetControlDue()) {
155
+ // A toolbar that is shown with its reset tool switched off, on a chart
156
+ // that is now zoomed: the same dead end, so the same answer. It comes and
157
+ // goes with the zoom, which does shift the icons beside it; that is the
158
+ // cost of a config which otherwise offers no way back at all.
159
+ this.t = { ...this.t, reset: true }
160
+ this._drawnForZoom = 'control'
161
+ }
162
+
87
163
  if (Array.isArray(this.t.customIcons)) {
88
164
  for (let i = 0; i < this.t.customIcons.length; i++) {
89
165
  this.elCustomIcons.push(createBtn())
@@ -221,7 +297,19 @@ export default class Toolbar {
221
297
  this.elMenuIcon.setAttribute('aria-expanded', 'false')
222
298
  }
223
299
 
224
- this._createHamburgerMenu(elToolbarWrap)
300
+ // Nothing opens it in a reset-only toolbar, and an export menu is not what
301
+ // a page that asked for no toolbar is being handed a control for.
302
+ if (!resetOnly) this._createHamburgerMenu(elToolbarWrap)
303
+
304
+ if (resetOnly) {
305
+ // Just the one binding, rather than the whole set: there is no menu to
306
+ // open, no mode to toggle, and nothing here reaches for the document.
307
+ this.elZoomReset?.addEventListener(
308
+ 'click',
309
+ this.handleZoomReset.bind(this),
310
+ )
311
+ return
312
+ }
225
313
 
226
314
  if (w.interact.zoomEnabled) {
227
315
  this.elZoom.classList.add(this.selectedClass)
@@ -736,6 +824,27 @@ export default class Toolbar {
736
824
  }
737
825
  }
738
826
 
827
+ /**
828
+ * Take down whatever the zoom alone put on screen.
829
+ *
830
+ * By hand, and not left to the next render, because of the order in
831
+ * {@link Toolbar#handleZoomReset}: the re-render happens while
832
+ * `interact.zoomed` is still true, so the control is drawn once more and then
833
+ * the flag clears with no further pass to notice. Reversing that order would
834
+ * change what every listener downstream of the update sees, which is a much
835
+ * larger promise than this control is worth.
836
+ */
837
+ clearZoomAffordance() {
838
+ const drawn = this._drawnForZoom
839
+ this._drawnForZoom = null
840
+ if (!drawn || !this.elZoomReset) return
841
+ const parent = this.elZoomReset.parentNode
842
+ // 'wrap' means the whole toolbar existed only to carry this one control, so
843
+ // the page goes back to the bare canvas it asked for.
844
+ const gone = drawn === 'wrap' ? parent : this.elZoomReset
845
+ if (gone && gone.parentNode) gone.parentNode.removeChild(gone)
846
+ }
847
+
739
848
  handleZoomReset() {
740
849
  const charts = this.ctx.getSyncedCharts()
741
850
 
@@ -784,6 +893,7 @@ export default class Toolbar {
784
893
  )
785
894
 
786
895
  w.interact.zoomed = false
896
+ ch.ctx.toolbar?.clearZoomAffordance()
787
897
  })
788
898
  }
789
899
 
@@ -138,6 +138,16 @@ export default class ZoomPanSelection extends Toolbar {
138
138
  })
139
139
  }
140
140
 
141
+ // Escape: the way back for the viewer who never goes looking for a button.
142
+ // Bound on the node the gestures are on, which the chart focuses on any
143
+ // pointer down inside it, so the drag that zoomed also left Escape live.
144
+ // Bound unconditionally and resolved per event, because the gate reads
145
+ // config a page can change under a live chart.
146
+ this.hoverArea.addEventListener('keydown', me.escapeResetEvent.bind(me), {
147
+ capture: false,
148
+ passive: true,
149
+ })
150
+
141
151
  // Momentum: passive:false touch listeners (following the wheel-listener
142
152
  // template above) so two-finger pinch and horizontal pan can preventDefault
143
153
  // the browser's native page zoom/scroll. Every _updateOptions destroys and
@@ -348,12 +358,16 @@ export default class ZoomPanSelection extends Toolbar {
348
358
  /**
349
359
  * A wheel or pinch zoom is an incidental gesture: the viewer can land in a
350
360
  * zoomed window without meaning to (a page scroll over the chart, a two-finger
351
- * swipe), so it is only offered when there is a way back out of it. The only
352
- * built-in way back is the toolbar's reset button, hence 'auto' (the default
353
- * for both allowMouseWheelZoom and pinch) resolves against that button being
354
- * present. A page that builds its own reset control sets the option to true
355
- * and gets the gesture with no toolbar. Drag-to-zoom is deliberate, so it is
356
- * not gated this way.
361
+ * swipe), so it is only offered when there is a way back out of it. 'auto'
362
+ * (the default for both allowMouseWheelZoom and pinch) resolves against a
363
+ * reset button that is already on screen. A page that builds its own reset
364
+ * control sets the option to true and gets the gesture with no toolbar.
365
+ * Drag-to-zoom is deliberate, so it is not gated this way.
366
+ *
367
+ * `chart.zoom.resetControl` supplies a reset of its own once a chart IS
368
+ * zoomed, and deliberately does NOT open this gate. It arrives after the
369
+ * fact, and what an incidental wheel zoom takes from the viewer first is the
370
+ * page scroll it swallowed, which no button hands back.
357
371
  *
358
372
  * @param {boolean|'auto'} setting
359
373
  */
@@ -369,6 +383,61 @@ export default class ZoomPanSelection extends Toolbar {
369
383
  return this._incidentalZoomEnabled(zoom && zoom.allowMouseWheelZoom)
370
384
  }
371
385
 
386
+ /**
387
+ * Put keyboard focus on the chart, where the chart is focusable at all.
388
+ *
389
+ * A drag is swallowed by the zoom handlers before the browser can move focus,
390
+ * so a viewer who has just zoomed by hand leaves nothing focused, and every
391
+ * key the chart offers is out of reach: Escape to reset (see
392
+ * {@link ZoomPanSelection#escapeResetEvent}) and the +, - and 0 the keyboard
393
+ * module already binds. Focusing what they just acted on puts those in reach.
394
+ *
395
+ * Pointer-driven focus, which keyboard navigation expects and does not read
396
+ * as a request to start navigating, and which the stylesheet draws no ring
397
+ * around (`svg:focus:not(:focus-visible)`).
398
+ *
399
+ * Only where the accessibility module has made the SVG focusable, which is
400
+ * the default: a page that turned keyboard support off is not handed a tab
401
+ * stop it never asked for, and still has the reset control as its way back.
402
+ */
403
+ _focusForKeyboard() {
404
+ const node = this.w.dom.Paper && this.w.dom.Paper.node
405
+ if (!node || typeof node.focus !== 'function') return
406
+ if (node.getAttribute('tabindex') === null) return
407
+ // Said before the focus lands, or the accessibility module reads it as a
408
+ // request to start navigating and flashes a tooltip after every zoom.
409
+ this.ctx.keyboardNavigation?.notePointerFocus?.()
410
+ try {
411
+ node.focus({ preventScroll: true })
412
+ } catch {
413
+ node.focus()
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Escape, on a zoomed chart, puts the range back.
419
+ *
420
+ * The quiet half of the same answer the on-demand reset control gives, and
421
+ * gated on it, so a page that says it supplies its own way back gets neither.
422
+ *
423
+ * It defers to keyboard navigation, whose Escape dismisses the tooltip and
424
+ * which offers `0` for this, so the key means one thing at a time. It does
425
+ * not stop the event either, so a page listening for Escape still hears it.
426
+ *
427
+ * Reachable because a completed drag-zoom puts focus on the chart; see
428
+ * {@link ZoomPanSelection#_focusForKeyboard}.
429
+ *
430
+ * @param {any} e
431
+ */
432
+ escapeResetEvent(e) {
433
+ if (e.key !== 'Escape' && e.key !== 'Esc') return
434
+ if (!this.w.interact.zoomed) return
435
+ const nav = this.ctx.keyboardNavigation
436
+ if (nav && nav.active) return
437
+ if (!this.resetControlAllowed()) return
438
+ this.handleZoomReset()
439
+ }
440
+
372
441
  /** Lazily-created, re-render-surviving wheel-gesture state. */
373
442
  _wheel() {
374
443
  const it = this.w.interact
@@ -1071,12 +1140,21 @@ export default class ZoomPanSelection extends Toolbar {
1071
1140
  // fix issue #650
1072
1141
  options.yaxis = yaxis
1073
1142
  }
1074
- me.ctx.updateHelpers._updateOptions(
1143
+ const applied = me.ctx.updateHelpers._updateOptions(
1075
1144
  options,
1076
1145
  false,
1077
1146
  me.w.config.chart.animations.dynamicAnimation.enabled,
1078
1147
  )
1079
1148
 
1149
+ // After the render, not before it: the update replaces the SVG, and
1150
+ // focus set on the node that is about to be thrown away goes back to
1151
+ // the document body with it. Measured, not theorised.
1152
+ if (applied && typeof applied.then === 'function') {
1153
+ applied.then(() => me._focusForKeyboard())
1154
+ } else {
1155
+ me._focusForKeyboard()
1156
+ }
1157
+
1080
1158
  if (typeof w.config.chart.events.zoomed === 'function') {
1081
1159
  toolbar.zoomCallback(xaxis, yaxis)
1082
1160
  }
@@ -147,6 +147,20 @@ export default class KeyboardNavigation {
147
147
  this._lastPointerDownAt = Date.now()
148
148
  }
149
149
 
150
+ /**
151
+ * Note that a pointer gesture is about to move focus into the chart.
152
+ *
153
+ * The 100 ms window above catches the browser's own click-to-focus, which
154
+ * lands immediately. A drag-zoom moves focus deliberately, and only once its
155
+ * re-render is done (ZoomPanSelection#_focusForKeyboard), which is far
156
+ * outside that window. Without this it reads as a viewer asking to navigate
157
+ * by keyboard, which activates nav and flashes a tooltip at the first visible
158
+ * point after every zoom.
159
+ */
160
+ notePointerFocus() {
161
+ this._lastPointerDownAt = Date.now()
162
+ }
163
+
150
164
  /**
151
165
  * Called from Events.js keydown handler. Navigation keys are already handled
152
166
  * by the direct SVG listener (which can call preventDefault). This entry
@@ -551,42 +551,39 @@ export default class YAxis {
551
551
 
552
552
  setYAxisTextAlignments() {
553
553
  const w = this.w
554
- const yaxis = Array.from(
555
- w.dom.baseEl.getElementsByClassName('apexcharts-yaxis'),
556
- )
557
554
 
558
- yaxis.forEach((y, index) => {
559
- const yaxe = w.config.yaxis[index]
555
+ // Iterate config, not the DOM collection: Axes.drawAxis skips
556
+ // ignoreYAxisIndexes entirely, so a collapsed axis makes the DOM
557
+ // shorter than config.yaxis and a later visible axis would be missed.
558
+ w.config.yaxis.forEach((yaxe, index) => {
560
559
  if (yaxe && !yaxe.floating && yaxe.labels.align !== undefined) {
561
560
  const yAxisInner = w.dom.baseEl.querySelector(
562
561
  `.apexcharts-yaxis[rel='${index}'] .apexcharts-yaxis-texts-g`,
563
562
  )
563
+ // drawYaxis returns before adding .apexcharts-yaxis-texts-g when the
564
+ // axis is hidden, so there is nothing to align.
565
+ if (!yAxisInner) return
566
+
564
567
  const yAxisTexts = Array.from(
565
568
  w.dom.baseEl.querySelectorAll(
566
569
  `.apexcharts-yaxis[rel='${index}'] .apexcharts-yaxis-label`,
567
570
  ),
568
571
  )
569
- const rect = /** @type {Element} */ (yAxisInner).getBoundingClientRect()
572
+ const rect = yAxisInner.getBoundingClientRect()
570
573
 
571
574
  yAxisTexts.forEach((label) => {
572
575
  label.setAttribute('text-anchor', yaxe.labels.align)
573
576
  })
574
577
 
575
578
  if (yaxe.labels.align === 'left' && !yaxe.opposite) {
576
- ;/** @type {Element} */ (yAxisInner).setAttribute(
577
- 'transform',
578
- `translate(-${rect.width}, 0)`,
579
- )
579
+ yAxisInner.setAttribute('transform', `translate(-${rect.width}, 0)`)
580
580
  } else if (yaxe.labels.align === 'center') {
581
- ;/** @type {Element} */ (yAxisInner).setAttribute(
581
+ yAxisInner.setAttribute(
582
582
  'transform',
583
583
  `translate(${(rect.width / 2) * (!yaxe.opposite ? -1 : 1)}, 0)`,
584
584
  )
585
585
  } else if (yaxe.labels.align === 'right' && yaxe.opposite) {
586
- ;/** @type {Element} */ (yAxisInner).setAttribute(
587
- 'transform',
588
- `translate(${rect.width}, 0)`,
589
- )
586
+ yAxisInner.setAttribute('transform', `translate(${rect.width}, 0)`)
590
587
  }
591
588
  }
592
589
  })