@cdc/chart 4.26.6 → 4.26.10

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 (276) hide show
  1. package/CONFIG.md +504 -368
  2. package/dist/cdcchart-CYc-Kayd.es.js +22488 -0
  3. package/dist/cdcchart-DCKn8NmI.es.js +20695 -0
  4. package/dist/cdcchart-Dyui9nk_.es.js +45828 -0
  5. package/dist/cdcchart.js +4 -96498
  6. package/examples/__data__/vega-remote-data.json +6 -0
  7. package/examples/dendrogram.json +18 -0
  8. package/examples/feature/bar/bar-chart-race-annual-change.json +68 -0
  9. package/examples/feature/heatmap/average-age-categorical.json +2 -2
  10. package/examples/feature/heatmap/calendar-heatmap.json +2 -2
  11. package/examples/feature/heatmap/cell-values-bottom-axis.json +2 -2
  12. package/examples/feature/heatmap/sparse-aggregation.json +2 -2
  13. package/examples/feature/line/line-chart-race.json +50 -0
  14. package/examples/feature/sankey/sankey-example-data.json +40 -1441
  15. package/examples/feature/sankey/sankey_chart_data.csv +9 -20
  16. package/examples/network.json +31 -0
  17. package/examples/private/box-plot.json +573 -0
  18. package/examples/private/custom-color-issue.json +625 -0
  19. package/examples/private/custom-colors.json +522 -0
  20. package/examples/private/cutoff.json +10750 -0
  21. package/examples/private/flueview.json +22051 -0
  22. package/examples/private/overlay-legend.json +485 -0
  23. package/examples/private/pie-test.json +469 -0
  24. package/examples/vega-remote.json +22 -0
  25. package/package.json +11 -13
  26. package/src/CdcChart.tsx +2 -2
  27. package/src/CdcChartComponent.tsx +444 -217
  28. package/src/_stories/Chart.BarRace.stories.tsx +443 -0
  29. package/src/_stories/Chart.BoxPlotNumericSort.stories.tsx +189 -0
  30. package/src/_stories/Chart.BoxPlotStatsParity.stories.tsx +207 -0
  31. package/src/_stories/Chart.ColorDistribution.stories.tsx +299 -0
  32. package/src/_stories/Chart.DataCutoff.stories.tsx +147 -0
  33. package/src/_stories/Chart.DataTableDownloadFilename.stories.tsx +60 -0
  34. package/src/_stories/Chart.Dendrogram.stories.tsx +373 -0
  35. package/src/_stories/Chart.Forecast.CILegend.stories.tsx +236 -0
  36. package/src/_stories/Chart.HeatMap.stories.tsx +170 -4
  37. package/src/_stories/Chart.Legend.AxisAlign.stories.tsx +119 -0
  38. package/src/_stories/Chart.LineRace.stories.tsx +149 -0
  39. package/src/_stories/Chart.Network.stories.tsx +432 -0
  40. package/src/_stories/Chart.PaletteConversion.Editor.stories.tsx +208 -0
  41. package/src/_stories/Chart.Patterns.smoke.stories.tsx +31 -0
  42. package/src/_stories/Chart.PercentageValue.stories.tsx +311 -0
  43. package/src/_stories/Chart.PieLabels.stories.tsx +49 -0
  44. package/src/_stories/Chart.RankByValueMutation.stories.tsx +206 -0
  45. package/src/_stories/Chart.RequiredFields.stories.tsx +681 -0
  46. package/src/_stories/Chart.Sankey.stories.tsx +417 -0
  47. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +47 -0
  48. package/src/_stories/Chart.SmallMultiplesNumericTileOrder.stories.tsx +137 -0
  49. package/src/_stories/Chart.TooltipSeriesLabels.stories.tsx +43 -0
  50. package/src/_stories/Chart.VegaRemoteData.stories.tsx +31 -0
  51. package/src/_stories/Chart.smoke.stories.tsx +14 -1
  52. package/src/_stories/ChartAnnotation.Editor.stories.tsx +78 -0
  53. package/src/_stories/ChartAxisTitles.smoke.stories.tsx +48 -0
  54. package/src/_stories/ChartBar.Editor.stories.tsx +499 -57
  55. package/src/_stories/ChartBrush.Editor.stories.tsx +46 -67
  56. package/src/_stories/ChartBrush.Regression.stories.tsx +135 -0
  57. package/src/_stories/ChartEditor.Editor.stories.tsx +33 -3
  58. package/src/_stories/ChartLine.EffectCompatibility.stories.tsx +120 -0
  59. package/src/_stories/ChartPrefixSuffix.smoke.stories.tsx +28 -1
  60. package/src/_stories/_mock/annotation_event-line_mock.json +2 -2
  61. package/src/_stories/_mock/editor-test-configs.json +20 -10
  62. package/src/_stories/_mock/editor-test-datasets.json +38 -42
  63. package/src/_stories/_mock/heatmap-average-age-categorical.json +2 -2
  64. package/src/_stories/_mock/heatmap.json +2 -2
  65. package/src/_stories/_mock/legend-axis-align-stacked-bar.json +125 -0
  66. package/src/_stories/_mock/sankey_mock.json +23 -27
  67. package/src/components/Annotations/components/AnnotationDraggable.styles.css +52 -0
  68. package/src/components/Annotations/components/AnnotationDraggable.tsx +639 -139
  69. package/src/components/Annotations/components/AnnotationDropdown.styles.css +44 -4
  70. package/src/components/Annotations/components/AnnotationDropdown.tsx +10 -32
  71. package/src/components/Annotations/helpers/resolveAnnotationLayout.ts +212 -0
  72. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +612 -19
  73. package/src/components/Annotations/tests/AnnotationDropdown.test.tsx +50 -0
  74. package/src/components/Annotations/tests/resolveAnnotationLayout.test.ts +203 -0
  75. package/src/components/Axis/BottomAxis.tsx +1 -1
  76. package/src/components/Axis/Categorical.Axis.tsx +3 -3
  77. package/src/components/Axis/RightAxis.tsx +21 -13
  78. package/src/components/BarChart/components/BarChart.Horizontal.tsx +39 -8
  79. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +13 -4
  80. package/src/components/BarChart/components/BarChart.StackedVertical.test.ts +28 -0
  81. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +30 -26
  82. package/src/components/BarChart/components/BarChart.Vertical.tsx +38 -28
  83. package/src/components/BarChart/components/PortionPatternOverlay.tsx +61 -0
  84. package/src/components/BarChart/components/context.tsx +6 -0
  85. package/src/components/BarChart/helpers/getBarData.ts +28 -28
  86. package/src/components/BarChart/helpers/getBarHeights.ts +2 -2
  87. package/src/components/BarChart/helpers/getPatternUrl.ts +2 -5
  88. package/src/components/BarChart/helpers/index.ts +8 -0
  89. package/src/components/BarChart/helpers/portionPattern.ts +243 -0
  90. package/src/components/BarChart/helpers/tests/addMinimumBarHeights.test.ts +42 -0
  91. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +13 -0
  92. package/src/components/BarChart/helpers/tests/portionPattern.test.ts +280 -0
  93. package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +202 -0
  94. package/src/components/BarChart/helpers/useBarChart.ts +60 -60
  95. package/src/components/BarChartRace/BarChartRace.test.tsx +199 -0
  96. package/src/components/BarChartRace/BarChartRace.tsx +230 -0
  97. package/src/components/BarChartRace/BarChartRaceFallback.test.tsx +61 -0
  98. package/src/components/BarChartRace/BarChartRaceFallback.tsx +48 -0
  99. package/src/components/BarChartRace/bar-chart-race.css +160 -0
  100. package/src/components/BarChartRace/helpers.test.ts +114 -0
  101. package/src/components/BarChartRace/helpers.ts +185 -0
  102. package/src/components/BarChartRace/index.ts +3 -0
  103. package/src/components/BoxPlot/BoxPlot.Horizontal.tsx +7 -6
  104. package/src/components/BoxPlot/BoxPlot.Vertical.tsx +9 -7
  105. package/src/components/BoxPlot/helpers/index.ts +13 -83
  106. package/src/components/Brush/BrushSelector.tsx +10 -24
  107. package/src/components/Brush/MiniChartPreview.tsx +33 -1
  108. package/src/components/Dendrogram/components/Dendrogram.tsx +374 -0
  109. package/src/components/Dendrogram/dendrogram.css +113 -0
  110. package/src/components/Dendrogram/helpers/layoutDendrogram.test.ts +95 -0
  111. package/src/components/Dendrogram/helpers/layoutDendrogram.ts +56 -0
  112. package/src/components/Dendrogram/helpers/prepareDendrogramData.test.ts +135 -0
  113. package/src/components/Dendrogram/helpers/prepareDendrogramData.ts +166 -0
  114. package/src/components/Dendrogram/helpers/resolveDendrogramLabels.test.ts +196 -0
  115. package/src/components/Dendrogram/helpers/resolveDendrogramLabels.ts +418 -0
  116. package/src/components/Dendrogram/index.tsx +1 -0
  117. package/src/components/Dendrogram/types/index.ts +66 -0
  118. package/src/components/EditorPanel/EditorPanel.tsx +1111 -805
  119. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +50 -5
  120. package/src/components/EditorPanel/components/Panels/Panel.Dendrogram.css +10 -0
  121. package/src/components/EditorPanel/components/Panels/Panel.Dendrogram.tsx +211 -0
  122. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +19 -8
  123. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +104 -61
  124. package/src/components/EditorPanel/components/Panels/Panel.Network.css +9 -0
  125. package/src/components/EditorPanel/components/Panels/Panel.Network.tsx +202 -0
  126. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +236 -36
  127. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +6 -1
  128. package/src/components/EditorPanel/components/Panels/Panel.Sankey.tsx +89 -105
  129. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +21 -6
  130. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +1 -1
  131. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +8 -66
  132. package/src/components/EditorPanel/components/Panels/RacingControls.test.tsx +131 -0
  133. package/src/components/EditorPanel/components/Panels/RacingControls.tsx +185 -0
  134. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +2 -2
  135. package/src/components/EditorPanel/components/Panels/index.tsx +4 -0
  136. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +20 -7
  137. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +25 -2
  138. package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +5 -4
  139. package/src/components/EditorPanel/useEditorPermissions.test.tsx +94 -0
  140. package/src/components/EditorPanel/useEditorPermissions.ts +75 -25
  141. package/src/components/Forecasting/Forecasting.tsx +16 -7
  142. package/src/components/HeatMap/components/HeatMap.tsx +511 -178
  143. package/src/components/HeatMap/heatmap.constants.ts +7 -3
  144. package/src/components/HeatMap/heatmap.css +101 -2
  145. package/src/components/HeatMap/helpers/layout.ts +80 -0
  146. package/src/components/HeatMap/tests/HeatMap.test.tsx +339 -16
  147. package/src/components/HeatMap/tests/helpers.test.ts +79 -0
  148. package/src/components/Legend/Legend.Component.tsx +163 -148
  149. package/src/components/Legend/Legend.Suppression.tsx +3 -3
  150. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +7 -0
  151. package/src/components/Legend/LegendGroup/LegendGroup.tsx +47 -35
  152. package/src/components/Legend/helpers/createFormatLabels.test.ts +226 -0
  153. package/src/components/Legend/helpers/createFormatLabels.tsx +45 -51
  154. package/src/components/LineChart/components/LineChart.Circle.tsx +3 -3
  155. package/src/components/LineChart/helpers.test.ts +126 -38
  156. package/src/components/LineChart/helpers.ts +9 -6
  157. package/src/components/LineChart/index.tsx +71 -27
  158. package/src/components/LineChart/tests/LineChart.test.tsx +630 -0
  159. package/src/components/LineChartRace/LineChartRace.test.tsx +275 -0
  160. package/src/components/LineChartRace/LineChartRace.tsx +134 -0
  161. package/src/components/LineChartRace/LineChartRaceFallback.tsx +28 -0
  162. package/src/components/LineChartRace/helpers.test.ts +88 -0
  163. package/src/components/LineChartRace/helpers.ts +157 -0
  164. package/src/components/LineChartRace/index.ts +3 -0
  165. package/src/components/LineChartRace/line-chart-race.css +16 -0
  166. package/src/components/LinearChart/ValueAxisAnchors.tsx +58 -0
  167. package/src/components/LinearChart/VisualizationRenderer.tsx +18 -23
  168. package/src/components/LinearChart/tests/LinearChart.test.tsx +412 -13
  169. package/src/components/LinearChart/tests/VisualizationRenderer.test.tsx +141 -0
  170. package/src/components/LinearChart/tests/mockConfigContext.ts +2 -1
  171. package/src/components/LinearChart.tsx +85 -54
  172. package/src/components/Network/components/Network.tsx +372 -0
  173. package/src/components/Network/helpers/layoutNetwork.test.ts +68 -0
  174. package/src/components/Network/helpers/layoutNetwork.ts +87 -0
  175. package/src/components/Network/helpers/prepareNetworkData.test.ts +116 -0
  176. package/src/components/Network/helpers/prepareNetworkData.ts +101 -0
  177. package/src/components/Network/helpers/resolveNetworkLabels.test.ts +112 -0
  178. package/src/components/Network/helpers/resolveNetworkLabels.ts +206 -0
  179. package/src/components/Network/index.tsx +3 -0
  180. package/src/components/Network/network.css +107 -0
  181. package/src/components/Network/types/index.ts +56 -0
  182. package/src/components/PairedBarChart.jsx +5 -10
  183. package/src/components/PairedBarChart.test.tsx +71 -0
  184. package/src/components/PieChart/PieChart.tsx +121 -13
  185. package/src/components/PieChart/helpers/labelPlacement.test.ts +88 -0
  186. package/src/components/PieChart/helpers/labelPlacement.ts +95 -0
  187. package/src/components/RacingChartRenderer.tsx +44 -0
  188. package/src/components/Regions/components/Regions.test.tsx +179 -0
  189. package/src/components/Regions/components/Regions.tsx +104 -69
  190. package/src/components/Regions/index.tsx +2 -3
  191. package/src/components/Sankey/components/Sankey.tsx +264 -437
  192. package/src/components/Sankey/helpers/getSankeyTooltip.ts +12 -0
  193. package/src/components/Sankey/helpers/prepareSankeyData.test.ts +71 -0
  194. package/src/components/Sankey/helpers/prepareSankeyData.ts +90 -0
  195. package/src/components/Sankey/sankey.scss +57 -116
  196. package/src/components/Sankey/types/index.ts +41 -4
  197. package/src/components/ScatterPlot/ScatterPlot.jsx +28 -12
  198. package/src/components/ScatterPlot/ScatterPlot.test.tsx +60 -0
  199. package/src/components/SmallMultiples/SmallMultiples.tsx +12 -12
  200. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +7 -5
  201. package/src/components/raceTiming.ts +18 -0
  202. package/src/data/initial-state.js +79 -31
  203. package/src/helpers/applyChartTypeContract.ts +71 -0
  204. package/src/helpers/axisLabelFontSize.ts +2 -2
  205. package/src/helpers/boxPlotStats.ts +60 -0
  206. package/src/helpers/bumpChartTooltip.ts +2 -1
  207. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.test.ts +37 -0
  208. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +3 -2
  209. package/src/helpers/calculateRightYAxisWidth.ts +60 -0
  210. package/src/helpers/categoryOrder.ts +60 -0
  211. package/src/helpers/colorAssignmentHelpers.ts +2 -2
  212. package/src/helpers/createNewChartConfig.ts +56 -0
  213. package/src/helpers/dataDrivenYAxisCategories.ts +18 -0
  214. package/src/helpers/forecastLegend.ts +88 -0
  215. package/src/helpers/getBoxPlotConfig.ts +26 -38
  216. package/src/helpers/getChartTypeDefaultPalette.ts +29 -0
  217. package/src/helpers/getColorCodeCategoryColorMap.ts +26 -0
  218. package/src/helpers/getColorScale.ts +31 -22
  219. package/src/helpers/getComboChartConfig.ts +0 -1
  220. package/src/helpers/getDynamicYAxisCategories.ts +69 -0
  221. package/src/helpers/getExcludedData.ts +0 -1
  222. package/src/helpers/getHasBoundarySuppression.ts +4 -3
  223. package/src/helpers/getMinMax.ts +11 -8
  224. package/src/helpers/getNewRuntime.ts +4 -3
  225. package/src/helpers/getPiePercent.ts +3 -3
  226. package/src/helpers/getStackedSeriesMax.ts +51 -0
  227. package/src/helpers/getV21ChartDistributionColors.ts +57 -0
  228. package/src/helpers/handleRankByValue.ts +15 -15
  229. package/src/helpers/isConvertLineToBarGraph.ts +2 -2
  230. package/src/helpers/seriesColumnSettings.ts +31 -3
  231. package/src/helpers/sizeHelpers.ts +4 -5
  232. package/src/helpers/smallMultiplesHelpers.ts +10 -13
  233. package/src/helpers/tests/applyChartTypeContract.test.ts +37 -0
  234. package/src/helpers/tests/boxPlotStats.test.ts +31 -0
  235. package/src/helpers/tests/bumpChartTooltip.test.ts +29 -0
  236. package/src/helpers/tests/calculateRightYAxisWidth.test.ts +105 -0
  237. package/src/helpers/tests/categoryOrder.test.ts +57 -0
  238. package/src/helpers/tests/countNumOfTicks.test.ts +11 -0
  239. package/src/helpers/tests/createNewChartConfig.test.ts +182 -0
  240. package/src/helpers/tests/getBoxPlotConfig.test.ts +70 -0
  241. package/src/helpers/tests/getColorCodeCategoryColorMap.test.ts +113 -0
  242. package/src/helpers/tests/getColorScale.test.ts +226 -1
  243. package/src/helpers/tests/getDynamicYAxisCategories.test.ts +61 -0
  244. package/src/helpers/tests/getMinMax.test.ts +41 -0
  245. package/src/helpers/tests/getStackedSeriesMax.test.ts +26 -0
  246. package/src/helpers/tests/handleRankByValue.test.ts +55 -37
  247. package/src/helpers/tests/seriesColumnSettings.test.ts +83 -0
  248. package/src/helpers/tests/smallMultiplesHelpers.test.ts +37 -1
  249. package/src/helpers/tests/tooltipHelpers.test.ts +80 -0
  250. package/src/helpers/tooltipHelpers.ts +16 -0
  251. package/src/hooks/tests/useRightAxis.test.tsx +143 -2
  252. package/src/hooks/tests/useScales.test.tsx +3 -6
  253. package/src/hooks/tests/useTooltip.test.tsx +484 -0
  254. package/src/hooks/usePrefersReducedMotion.ts +22 -0
  255. package/src/hooks/useReduceData.ts +4 -36
  256. package/src/hooks/useRightAxis.ts +19 -26
  257. package/src/hooks/useScales.ts +15 -0
  258. package/src/hooks/useTooltip.tsx +106 -71
  259. package/src/scss/main.scss +74 -2
  260. package/src/store/chart.reducer.ts +4 -4
  261. package/src/test/CdcChart.dataTable.test.tsx +582 -14
  262. package/src/test/CdcChart.dendrogram.test.tsx +277 -0
  263. package/src/test/CdcChart.finalization.test.tsx +143 -0
  264. package/src/test/CdcChart.network.test.tsx +271 -0
  265. package/src/types/ChartConfig.ts +91 -54
  266. package/src/types/ChartContext.ts +5 -0
  267. package/src/types/Horizon.ts +1 -1
  268. package/LICENSE +0 -201
  269. package/dist/cdcchart-BWVIUcAF.es.js +0 -4802
  270. package/src/_stories/Chart.Sankey.smoke.stories.tsx +0 -62
  271. package/src/components/Sankey/components/ColumnList.tsx +0 -19
  272. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +0 -34
  273. package/src/components/Sankey/useSankeyAlert.tsx +0 -60
  274. package/src/data/legacy-defaults.ts +0 -18
  275. package/src/helpers/ensureSpecialChartAxisTypes.ts +0 -11
  276. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +0 -30
@@ -1,19 +1,24 @@
1
1
  import React from 'react'
2
- import { render } from '@testing-library/react'
3
- import { describe, expect, it, beforeAll, vi } from 'vitest'
2
+ import { fireEvent, render } from '@testing-library/react'
3
+ import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
4
4
  import { scaleBand, scaleLinear } from '@visx/scale'
5
5
  import AnnotationDraggable, { EVENT_LINE_LABEL_OFFSET, snapEventLineDx } from '../components/AnnotationDraggable'
6
6
  import ConfigContext from '../../../ConfigContext'
7
7
  import { createMockChartContext } from '../../LinearChart/tests/mockConfigContext'
8
+ import { APP_FONT_COLOR } from '@cdc/core/helpers/constants'
9
+ import getViewport from '@cdc/core/helpers/getViewport'
10
+ import { getAnnotationLabelRect } from '../helpers/resolveAnnotationLayout'
8
11
 
9
12
  // jsdom compat for visx (ResizeObserver + SVG bbox).
10
13
  vi.stubGlobal(
11
14
  'ResizeObserver',
12
- vi.fn(() => ({
13
- observe: vi.fn(),
14
- unobserve: vi.fn(),
15
- disconnect: vi.fn()
16
- }))
15
+ vi.fn(function ResizeObserver() {
16
+ return {
17
+ observe: vi.fn(),
18
+ unobserve: vi.fn(),
19
+ disconnect: vi.fn()
20
+ }
21
+ })
17
22
  )
18
23
 
19
24
  beforeAll(() => {
@@ -33,6 +38,23 @@ beforeAll(() => {
33
38
  }))
34
39
  })
35
40
 
41
+ afterEach(() => {
42
+ vi.restoreAllMocks()
43
+ })
44
+
45
+ const mockElementRect = (width: number, height: number) =>
46
+ vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockReturnValue({
47
+ x: 0,
48
+ y: 0,
49
+ width,
50
+ height,
51
+ top: 0,
52
+ left: 0,
53
+ right: width,
54
+ bottom: height,
55
+ toJSON: () => ({})
56
+ })
57
+
36
58
  const buildScales = (xMax: number, yMax: number) => ({
37
59
  xScale: scaleBand({ domain: ['Jan', 'Feb'], range: [0, xMax], padding: 0 }),
38
60
  yScale: scaleLinear({ domain: [0, 100], range: [yMax, 0] }),
@@ -40,6 +62,13 @@ const buildScales = (xMax: number, yMax: number) => ({
40
62
  yScaleAnnotation: scaleLinear({ domain: [0, 100], range: [0, yMax] })
41
63
  })
42
64
 
65
+ const pointerEvent = (type: string, clientX: number, clientY = 0) => {
66
+ const event = new Event(type, { bubbles: true, cancelable: true })
67
+ Object.defineProperty(event, 'clientX', { value: clientX })
68
+ Object.defineProperty(event, 'clientY', { value: clientY })
69
+ return event
70
+ }
71
+
43
72
  const baseEventLineAnnotation = {
44
73
  text: 'Voting Rights Act',
45
74
  style: 'event-line',
@@ -59,29 +88,35 @@ const baseEventLineAnnotation = {
59
88
  lineType: 'curveLinear'
60
89
  }
61
90
 
62
- const renderAnnotationDraggable = (annotation: any, xMax = 800, yMax = 400) => {
91
+ const buildAnnotationContext = (annotation: any, contextOverrides: Record<string, any> = {}) => {
63
92
  const data = [
64
93
  { month: 'Jan', value: 50 },
65
94
  { month: 'Feb', value: 75 }
66
95
  ]
67
- const context = createMockChartContext(
96
+ return createMockChartContext(
68
97
  {
69
98
  annotations: [annotation],
70
99
  visualizationType: 'Line',
71
100
  xAxis: { type: 'categorical', dataKey: 'month' } as any,
72
101
  series: [{ dataKey: 'value', type: 'Line' }] as any,
73
102
  data,
74
- general: { showAnnotationDropdown: false, mobileAnnotationDisplay: 'symbol' } as any
103
+ general: {
104
+ showAnnotationDropdown: contextOverrides.showAnnotationDropdown ?? false,
105
+ mobileAnnotationDisplay: contextOverrides.mobileAnnotationDisplay || 'symbol'
106
+ } as any
75
107
  } as any,
76
108
  {
77
109
  transformedData: data,
78
- visibleAnnotations: [annotation]
110
+ visibleAnnotations: [annotation],
111
+ ...contextOverrides
79
112
  }
80
113
  )
114
+ }
81
115
 
116
+ const annotationTree = (context: any, xMax = 800, yMax = 400) => {
82
117
  const scales = buildScales(xMax, yMax)
83
- return render(
84
- <ConfigContext.Provider value={context as any}>
118
+ return (
119
+ <ConfigContext.Provider value={context}>
85
120
  <svg width={xMax} height={yMax}>
86
121
  <AnnotationDraggable
87
122
  {...scales}
@@ -89,14 +124,109 @@ const renderAnnotationDraggable = (annotation: any, xMax = 800, yMax = 400) => {
89
124
  yMax={yMax}
90
125
  seriesScale={undefined}
91
126
  svgRef={{ current: null } as any}
92
- onDragStateChange={() => {}}
127
+ onDragStateChange={context.handleDragStateChange || (() => {})}
93
128
  />
94
129
  </svg>
95
130
  </ConfigContext.Provider>
96
131
  )
97
132
  }
98
133
 
134
+ const renderAnnotationDraggable = (
135
+ annotation: any,
136
+ xMax = 800,
137
+ yMax = 400,
138
+ contextOverrides: Record<string, any> = {}
139
+ ) => {
140
+ const context = buildAnnotationContext(annotation, contextOverrides)
141
+ context.handleDragStateChange = contextOverrides.handleDragStateChange || (() => {})
142
+ return render(annotationTree(context, xMax, yMax))
143
+ }
144
+
99
145
  describe('AnnotationDraggable - event-line style', () => {
146
+ it.each(['right', 'left'] as const)('shows its dropdown number with the %s-side text label', side => {
147
+ const annotation = { ...baseEventLineAnnotation, labelPosition: side }
148
+ const { container } = renderAnnotationDraggable(annotation, 800, 400, { showAnnotationDropdown: true })
149
+ const label = container.querySelector('.annotation__event-line-label') as HTMLElement
150
+ const badge = label.querySelector('.annotation__has-dropdown-number') as HTMLElement
151
+
152
+ expect(badge?.textContent).toBe('1')
153
+ expect(label.style.display).toBe('inline-flex')
154
+ expect(label.querySelector('.annotation__event-line-label-text')?.textContent).toBe(annotation.text)
155
+ expect(label.classList.contains(`cove-annotation-event-line__label--${side}`)).toBe(true)
156
+ expect(
157
+ badge.compareDocumentPosition(label.querySelector('.annotation__event-line-label-text')!) &
158
+ Node.DOCUMENT_POSITION_FOLLOWING
159
+ ).toBeTruthy()
160
+ })
161
+
162
+ it('uses the original annotation index when callouts and event lines are mixed', () => {
163
+ const callout = { ...baseEventLineAnnotation, style: 'callout' }
164
+ const eventLine = { ...baseEventLineAnnotation }
165
+ const context = buildAnnotationContext(eventLine, { showAnnotationDropdown: true })
166
+ context.config.annotations = [callout, eventLine] as any
167
+ context.visibleAnnotations = [eventLine] as any
168
+
169
+ const { container } = render(annotationTree(context))
170
+ expect(container.querySelector('.annotation__event-line-label .annotation__has-dropdown-number')?.textContent).toBe(
171
+ '2'
172
+ )
173
+ })
174
+
175
+ it('keeps the plain event-line text label when the dropdown is off', () => {
176
+ const { container } = renderAnnotationDraggable(baseEventLineAnnotation)
177
+ const label = container.querySelector('.annotation__event-line-label') as HTMLElement
178
+
179
+ expect(label.querySelector('.annotation__has-dropdown-number')).toBeNull()
180
+ expect(label.querySelector('.annotation__event-line-label-text')).toBeNull()
181
+ expect(label.style.display).toBe('')
182
+ })
183
+
184
+ it('keeps the authored 4em width floor with a dropdown badge', () => {
185
+ const { container } = renderAnnotationDraggable({ ...baseEventLineAnnotation, labelWidthEm: 2 }, 800, 400, {
186
+ showAnnotationDropdown: true
187
+ })
188
+ const label = container.querySelector('.annotation__event-line-label') as HTMLElement
189
+
190
+ expect((label.parentElement as HTMLElement).style.width).toBe('64px')
191
+ expect(label.querySelector('.annotation__has-dropdown-number')).toBeTruthy()
192
+ expect(label.querySelector('.annotation__event-line-label-text')).toBeTruthy()
193
+ })
194
+
195
+ it('shows the dropdown badge at the smallest HTML-label viewport', () => {
196
+ const { container } = renderAnnotationDraggable(baseEventLineAnnotation, 577, 400, {
197
+ showAnnotationDropdown: true,
198
+ vizViewport: 'sm'
199
+ })
200
+
201
+ expect(container.querySelector('.annotation__event-line-label .annotation__has-dropdown-number')?.textContent).toBe(
202
+ '1'
203
+ )
204
+ expect(container.querySelector('.annotation__mobile-label')).toBeNull()
205
+ })
206
+
207
+ it('uses one mobile symbol number without a desktop badge', () => {
208
+ const { container } = renderAnnotationDraggable(baseEventLineAnnotation, 320, 200, {
209
+ showAnnotationDropdown: true,
210
+ vizViewport: 'xs'
211
+ })
212
+
213
+ expect(container.querySelector('text.annotation__mobile-label')?.textContent).toBe('1')
214
+ expect(container.querySelector('.annotation__has-dropdown-number')).toBeNull()
215
+ })
216
+
217
+ it('shows a badge in mobile full-text mode when the dropdown is on', () => {
218
+ const { container } = renderAnnotationDraggable(baseEventLineAnnotation, 320, 200, {
219
+ showAnnotationDropdown: true,
220
+ mobileAnnotationDisplay: 'text',
221
+ vizViewport: 'xs'
222
+ })
223
+
224
+ expect(container.querySelector('.annotation__event-line-label .annotation__has-dropdown-number')?.textContent).toBe(
225
+ '1'
226
+ )
227
+ expect(container.querySelector('.annotation__mobile-label')).toBeNull()
228
+ })
229
+
100
230
  it('renders a full-height vertical line that spans 0..yMax', () => {
101
231
  const { container } = renderAnnotationDraggable(baseEventLineAnnotation)
102
232
 
@@ -106,14 +236,19 @@ describe('AnnotationDraggable - event-line style', () => {
106
236
  expect(line?.getAttribute('y2')).toBe('400')
107
237
  })
108
238
 
109
- it('strokes the vertical line with the axis color (#333), ignoring colors.connector', () => {
239
+ it('uses the application font color for the vertical line and label, ignoring legacy annotation colors', () => {
110
240
  const { container } = renderAnnotationDraggable({
111
241
  ...baseEventLineAnnotation,
112
242
  colors: { connector: '#ff0000', label: '#000' }
113
243
  })
114
244
 
115
245
  const line = container.querySelector('line.annotation__event-line')
116
- expect(line?.getAttribute('stroke')).toBe('#333')
246
+ const label = container.querySelector('.annotation__event-line-label') as HTMLElement
247
+ const expectedColor = document.createElement('div')
248
+ expectedColor.style.color = APP_FONT_COLOR
249
+
250
+ expect(line?.getAttribute('stroke')).toBe(APP_FONT_COLOR)
251
+ expect(label.style.color).toBe(expectedColor.style.color)
117
252
  })
118
253
 
119
254
  it('uses the right-side label class (text-align left) when dx >= 0', () => {
@@ -122,8 +257,11 @@ describe('AnnotationDraggable - event-line style', () => {
122
257
  dx: 12
123
258
  })
124
259
 
125
- expect(container.querySelector('.cove-annotation-event-line__label--right')).toBeTruthy()
260
+ const label = container.querySelector('.cove-annotation-event-line__label--right') as HTMLElement
261
+ expect(label).toBeTruthy()
126
262
  expect(container.querySelector('.cove-annotation-event-line__label--left')).toBeFalsy()
263
+ expect(label.dataset.contentAlignment).toBeUndefined()
264
+ expect(label.querySelector('.annotation__label-text')).toBeNull()
127
265
  })
128
266
 
129
267
  it('uses the left-side label class (text-align right) when dx < 0', () => {
@@ -144,6 +282,25 @@ describe('AnnotationDraggable - event-line style', () => {
144
282
 
145
283
  expect(container.querySelector('.circle-subject')).toBeFalsy()
146
284
  })
285
+
286
+ it('keeps the legacy 186px fit-content cap when width is omitted', () => {
287
+ const { container } = renderAnnotationDraggable(baseEventLineAnnotation, 120)
288
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
289
+ const wrapper = label.parentElement as HTMLElement
290
+
291
+ expect(wrapper.style.width).toBe('fit-content')
292
+ expect(wrapper.style.maxWidth).toBe('186px')
293
+ expect(label.style.boxSizing).toBe('')
294
+ })
295
+
296
+ it('renders an authored event-line width with the 4em floor', () => {
297
+ const { container } = renderAnnotationDraggable({ ...baseEventLineAnnotation, labelWidthEm: 2 })
298
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
299
+ const wrapper = label.parentElement as HTMLElement
300
+
301
+ expect(wrapper.style.width).toBe('64px')
302
+ expect(label.style.boxSizing).toBe('border-box')
303
+ })
147
304
  })
148
305
 
149
306
  describe('snapEventLineDx', () => {
@@ -184,15 +341,451 @@ describe('AnnotationDraggable - event-line dx snapping at render', () => {
184
341
  })
185
342
 
186
343
  describe('AnnotationDraggable - callout style (regression)', () => {
187
- it('still renders a callout annotation without a full-height event line', () => {
344
+ it('renders text, connectors, and markers with the application font color', () => {
188
345
  const calloutAnnotation = {
189
346
  ...baseEventLineAnnotation,
190
347
  style: 'callout' as const,
191
348
  anchorMode: 'fixed' as const,
192
- dataX: undefined
349
+ dataX: undefined,
350
+ colors: { connector: '#ff0000', label: '#000', marker: '#00ff00' }
193
351
  }
194
352
  const { container } = renderAnnotationDraggable(calloutAnnotation)
195
353
 
196
354
  expect(container.querySelector('line.annotation__event-line')).toBeFalsy()
355
+ expect(container.querySelector('.visx-annotation-connector')?.getAttribute('stroke')).toBe(APP_FONT_COLOR)
356
+ expect(container.querySelector('marker')?.getAttribute('stroke')).toBe(APP_FONT_COLOR)
357
+
358
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
359
+ const expectedColor = document.createElement('div')
360
+ expectedColor.style.color = APP_FONT_COLOR
361
+ expect(label.style.color).toBe(expectedColor.style.color)
362
+ })
363
+
364
+ it('preserves production dx/dy scaling from saved dimensions', () => {
365
+ const annotation = {
366
+ ...baseEventLineAnnotation,
367
+ style: 'callout' as const,
368
+ anchorMode: 'fixed' as const,
369
+ dataX: undefined,
370
+ dx: 100,
371
+ dy: -50,
372
+ savedDimensions: [400, 200]
373
+ }
374
+ const { container } = renderAnnotationDraggable(annotation, 800, 400)
375
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
376
+
377
+ expect(label.dataset.labelDx).toBe('200')
378
+ expect(label.dataset.labelDy).toBe('-100')
379
+ })
380
+ })
381
+
382
+ describe('AnnotationDraggable - width and explicit placement', () => {
383
+ const calloutAnnotation = {
384
+ ...baseEventLineAnnotation,
385
+ style: 'callout' as const,
386
+ anchorMode: 'fixed' as const,
387
+ dataX: undefined,
388
+ edit: { subject: false, label: true }
389
+ }
390
+
391
+ it('keeps legacy fit-content width and automatic anchors when the new fields are omitted', () => {
392
+ const { container } = renderAnnotationDraggable(calloutAnnotation)
393
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
394
+ const wrapper = label.parentElement as HTMLElement
395
+
396
+ expect(wrapper.style.width).toBe('fit-content')
397
+ expect(wrapper.style.maxWidth).toBe('150px')
398
+ expect(label.dataset.horizontalAnchor).toBe('start')
399
+ expect(label.dataset.verticalAnchor).toBe('middle')
400
+ })
401
+
402
+ it('keeps legacy automatic text left aligned while placing the resize handle on the resolved side', () => {
403
+ const annotation = { ...calloutAnnotation, dx: -100, dy: 0 }
404
+ const { getByTestId, container } = renderAnnotationDraggable(annotation, 800, 400, { isEditor: true })
405
+
406
+ expect(getByTestId('annotation-resize-handle')).toHaveClass('annotation__resize-handle--left')
407
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
408
+ const text = label.querySelector('.annotation__label-text') as HTMLElement
409
+ expect(label).toHaveClass('annotation__label--editable')
410
+ expect(label.dataset.contentAlignment).toBe('start')
411
+ expect(text).not.toHaveClass('annotation__label-text--right')
412
+ })
413
+
414
+ it('uses directional text alignment when autoSide is persisted', () => {
415
+ const annotation = { ...calloutAnnotation, dx: -100, dy: 0, autoSide: 'left' as const }
416
+ const { container } = renderAnnotationDraggable(annotation)
417
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
418
+ const text = label.querySelector('.annotation__label-text') as HTMLElement
419
+
420
+ expect(label.dataset.contentAlignment).toBe('end')
421
+ expect(text).toHaveClass('annotation__label-text--right')
422
+ })
423
+
424
+ it('centers the content block for an automatically resolved vertical label while keeping its text left aligned', () => {
425
+ const annotation = { ...calloutAnnotation, dx: 10, dy: -100, autoSide: 'above' as const, labelWidthEm: 12 }
426
+ const { container } = renderAnnotationDraggable(annotation)
427
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
428
+ const text = label.querySelector('.annotation__label-text') as HTMLElement
429
+
430
+ expect(label.dataset.contentAlignment).toBe('center')
431
+ expect(text).toHaveClass('annotation__label-text')
432
+ })
433
+
434
+ it('removes the label circle while retaining the enabled subject circle', () => {
435
+ const annotation = { ...calloutAnnotation, edit: { subject: true, label: true } }
436
+ const { container } = renderAnnotationDraggable(annotation, 800, 400, { isEditor: true })
437
+
438
+ expect(container.querySelectorAll('circle[stroke="red"]')).toHaveLength(1)
439
+ })
440
+
441
+ it('keeps the measured label mounted after label and subject positions are saved', () => {
442
+ const annotation = { ...calloutAnnotation, edit: { subject: true, label: true } }
443
+ const updateConfig = vi.fn()
444
+ const firstRender = renderAnnotationDraggable(annotation, 800, 400, { isEditor: true, updateConfig })
445
+ const initialLabel = firstRender.container.querySelector('g.annotation__desktop-label')
446
+ const label = firstRender.container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
447
+
448
+ fireEvent(label, pointerEvent('pointerdown', 0, 0))
449
+ fireEvent(window, pointerEvent('pointermove', 40, 20))
450
+ fireEvent(window, pointerEvent('pointerup', 40, 20))
451
+
452
+ const labelDraggedAnnotation = updateConfig.mock.calls.at(-1)?.[0].annotations[0]
453
+ const labelContext = buildAnnotationContext(labelDraggedAnnotation, { isEditor: true, updateConfig })
454
+ firstRender.rerender(annotationTree(labelContext))
455
+ expect(firstRender.container.querySelector('g.annotation__desktop-label')).toBe(initialLabel)
456
+
457
+ const subjectHandle = firstRender.getByTestId('annotation-subject-drag-handle')
458
+ fireEvent(subjectHandle, pointerEvent('pointerdown', 0, 0))
459
+ fireEvent(window, pointerEvent('pointermove', 30, 15))
460
+ fireEvent(window, pointerEvent('pointerup', 30, 15))
461
+
462
+ const subjectDraggedAnnotation = updateConfig.mock.calls.at(-1)?.[0].annotations[0]
463
+ const subjectContext = buildAnnotationContext(subjectDraggedAnnotation, { isEditor: true, updateConfig })
464
+ firstRender.rerender(annotationTree(subjectContext))
465
+ expect(firstRender.container.querySelector('g.annotation__desktop-label')).toBe(initialLabel)
466
+ })
467
+
468
+ it('does not outline labels outside editor label-edit mode', () => {
469
+ const updateConfig = vi.fn()
470
+ const { container, queryByTestId } = renderAnnotationDraggable(
471
+ {
472
+ ...calloutAnnotation,
473
+ edit: { subject: false, label: false }
474
+ },
475
+ 800,
476
+ 400,
477
+ { isEditor: true, updateConfig }
478
+ )
479
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
480
+
481
+ expect(label).not.toHaveClass('annotation__label--editable')
482
+ expect(queryByTestId('annotation-resize-handle')).toBeNull()
483
+ fireEvent(label, pointerEvent('pointerdown', 0, 0))
484
+ fireEvent(window, pointerEvent('pointermove', 60, 30))
485
+ fireEvent(window, pointerEvent('pointerup', 60, 30))
486
+ expect(updateConfig).not.toHaveBeenCalled()
487
+ })
488
+
489
+ it('derives pixel widths from saved em values and the responsive font size', () => {
490
+ const wide = { ...calloutAnnotation, labelWidthEm: 12.5 }
491
+ const first = renderAnnotationDraggable(wide, 800, 400, { currentViewport: 'lg', vizViewport: 'lg' })
492
+ const firstLabel = first.container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
493
+ expect((firstLabel.parentElement as HTMLElement).style.width).toBe('200px')
494
+ expect(firstLabel.style.fontSize).toBe('16px')
495
+ first.unmount()
496
+
497
+ const second = renderAnnotationDraggable(wide, 400, 400, {
498
+ currentViewport: 'lg',
499
+ vizViewport: 'xxs',
500
+ mobileAnnotationDisplay: 'text'
501
+ })
502
+ const secondLabel = second.container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
503
+ expect((secondLabel.parentElement as HTMLElement).style.width).toBe('162.5px')
504
+ expect(secondLabel.style.fontSize).toBe('13px')
505
+ })
506
+
507
+ it.each([
508
+ ['left', 'end', 'middle', 'end'],
509
+ ['right', 'start', 'middle', 'start'],
510
+ ['above', 'middle', 'end', 'center'],
511
+ ['below', 'middle', 'start', 'center']
512
+ ] as const)(
513
+ 'maps %s directly to its Visx anchors and content alignment at plot edges',
514
+ (labelPosition, horizontal, vertical, contentAlignment) => {
515
+ const annotation = {
516
+ ...calloutAnnotation,
517
+ x: labelPosition === 'right' ? 100 : 0,
518
+ y: labelPosition === 'below' ? 100 : 0,
519
+ dx: labelPosition === 'left' ? -240 : 240,
520
+ dy: labelPosition === 'above' ? -180 : 180,
521
+ labelPosition,
522
+ labelWidthEm: 12
523
+ }
524
+ const { container } = renderAnnotationDraggable(annotation)
525
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
526
+
527
+ expect(label.dataset.horizontalAnchor).toBe(horizontal)
528
+ expect(label.dataset.verticalAnchor).toBe(vertical)
529
+ expect(label.dataset.contentAlignment).toBe(contentAlignment)
530
+ expect(label.querySelector('.annotation__label-text')).toHaveClass(
531
+ contentAlignment === 'end' ? 'annotation__label-text--right' : 'annotation__label-text'
532
+ )
533
+ if (contentAlignment !== 'end') {
534
+ expect(label.querySelector('.annotation__label-text')).not.toHaveClass('annotation__label-text--right')
535
+ }
536
+ expect(label.dataset.resolvedSide).toBe(labelPosition)
537
+ }
538
+ )
539
+
540
+ it('keeps explicit event-line side and placement when the label is dragged', () => {
541
+ const updateConfig = vi.fn()
542
+ const annotation = { ...baseEventLineAnnotation, labelPosition: 'left' as const }
543
+ const { container } = renderAnnotationDraggable(annotation, 800, 400, { isEditor: true, updateConfig })
544
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
545
+ const labelGroup = container.querySelector('g.annotation__desktop-label')
546
+ const initialTransform = labelGroup?.getAttribute('transform')
547
+ const initialX = initialTransform?.match(/translate\(([^,]+)/)?.[1]
548
+
549
+ expect(container.querySelector('circle[stroke="red"]')).toBeNull()
550
+ fireEvent(label, pointerEvent('pointerdown', 0, 0))
551
+ fireEvent(window, pointerEvent('pointermove', 80, 40))
552
+ expect(labelGroup?.getAttribute('transform')?.match(/translate\(([^,]+)/)?.[1]).toBe(initialX)
553
+ fireEvent(window, pointerEvent('pointerup', 80, 40))
554
+
555
+ const saved = updateConfig.mock.calls.at(-1)?.[0].annotations[0]
556
+ expect(saved.labelPosition).toBe('left')
557
+ expect(saved.dx).toBe(-EVENT_LINE_LABEL_OFFSET)
558
+ expect(saved.dy).not.toBe(annotation.dy)
559
+ })
560
+
561
+ it('keeps explicit callout placement when the label is dragged', () => {
562
+ const updateConfig = vi.fn()
563
+ const annotation = { ...calloutAnnotation, labelPosition: 'above' as const }
564
+ const { container } = renderAnnotationDraggable(annotation, 800, 400, { isEditor: true, updateConfig })
565
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
566
+
567
+ expect(container.querySelector('circle[stroke="red"]')).toBeNull()
568
+ fireEvent(label, pointerEvent('pointerdown', 0, 0))
569
+ expect(label).toHaveClass('annotation__label--dragging')
570
+ fireEvent(window, pointerEvent('pointermove', 60, 30))
571
+ fireEvent(window, pointerEvent('pointerup', 60, 30))
572
+ expect(label).not.toHaveClass('annotation__label--dragging')
573
+
574
+ const saved = updateConfig.mock.calls.at(-1)?.[0].annotations[0]
575
+ expect(saved.labelPosition).toBe('above')
576
+ expect(saved.dx).not.toBe(annotation.dx)
577
+ expect(saved.dy).not.toBe(annotation.dy)
578
+ })
579
+
580
+ it('persists the bounded automatic position used by the label and connector', () => {
581
+ mockElementRect(96, 40)
582
+ const updateConfig = vi.fn()
583
+ const annotation = { ...calloutAnnotation, x: 90, y: 50, dx: 100, dy: 0 }
584
+ const { container } = renderAnnotationDraggable(annotation, 800, 400, { isEditor: true, updateConfig })
585
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
586
+
587
+ expect(label.dataset.resolvedSide).toBe('right')
588
+ fireEvent(label, pointerEvent('pointerdown', 0, 0))
589
+ fireEvent(window, pointerEvent('pointermove', 20, 15))
590
+ fireEvent(window, pointerEvent('pointerup', 20, 15))
591
+
592
+ const saved = updateConfig.mock.calls.at(-1)?.[0].annotations[0]
593
+ expect(saved.dx).toBe(-16)
594
+ expect(saved.dy).toBe(15)
595
+ expect(saved.autoSide).toBe('right')
596
+ expect(saved.savedDimensions).toEqual([800, 400])
597
+ })
598
+
599
+ it('keeps the dragged rectangle continuous when its facing edge changes', () => {
600
+ mockElementRect(100, 20)
601
+ const updateConfig = vi.fn()
602
+ const annotation = { ...calloutAnnotation, x: 50, y: 50, dx: 100, dy: 0, labelWidthEm: 6.25 }
603
+ const { container } = renderAnnotationDraggable(annotation, 800, 400, { isEditor: true, updateConfig })
604
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
605
+
606
+ fireEvent(label, pointerEvent('pointerdown', 0, 0))
607
+ fireEvent(window, pointerEvent('pointermove', -200, -20))
608
+ expect(label.dataset.resolvedSide).toBe('above')
609
+ fireEvent(window, pointerEvent('pointerup', -200, -20))
610
+
611
+ const saved = updateConfig.mock.calls.at(-1)?.[0].annotations[0]
612
+ expect(saved.autoSide).toBe('above')
613
+ expect(getAnnotationLabelRect(400 + saved.dx, 200 + saved.dy, 100, 20, saved.autoSide)).toEqual({
614
+ left: 300,
615
+ top: 170,
616
+ width: 100,
617
+ height: 20
618
+ })
619
+ })
620
+
621
+ it('keeps a persisted automatic side across chart sizes', () => {
622
+ const annotation = { ...calloutAnnotation, x: 90, y: 50, dx: 100, dy: 0, autoSide: 'right' as const }
623
+ const wide = renderAnnotationDraggable(annotation, 800, 400)
624
+ const wideLabel = wide.container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
625
+ expect(wideLabel.dataset.resolvedSide).toBe('right')
626
+ wide.unmount()
627
+
628
+ const narrow = renderAnnotationDraggable(annotation, 300, 400)
629
+ const narrowLabel = narrow.container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
630
+ expect(narrowLabel.dataset.resolvedSide).toBe('right')
631
+ })
632
+
633
+ it('uses scaled offsets to choose the legacy automatic side when autoSide is omitted', () => {
634
+ const annotation = {
635
+ ...calloutAnnotation,
636
+ dx: -43,
637
+ dy: -38,
638
+ savedDimensions: [1368, 250]
639
+ }
640
+ const taller = renderAnnotationDraggable(annotation, 800, 200)
641
+ const tallerLabel = taller.container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
642
+ expect(tallerLabel.dataset.resolvedSide).toBe('above')
643
+ taller.unmount()
644
+
645
+ const shorter = renderAnnotationDraggable(annotation, 800, 100)
646
+ const shorterLabel = shorter.container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
647
+ expect(shorterLabel.dataset.resolvedSide).toBe('left')
648
+ })
649
+
650
+ it('stops at the top edge and begins the next drag without dead distance', () => {
651
+ mockElementRect(100, 20)
652
+ const updateConfig = vi.fn()
653
+ const annotation = { ...calloutAnnotation, labelPosition: 'right' as const }
654
+ const rendered = renderAnnotationDraggable(annotation, 800, 400, { isEditor: true, updateConfig })
655
+ let label = rendered.container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
656
+
657
+ fireEvent(label, pointerEvent('pointerdown', 0, 0))
658
+ fireEvent(window, pointerEvent('pointermove', 0, -500))
659
+ expect(label.dataset.labelDy).toBe('-190')
660
+ fireEvent(window, pointerEvent('pointerup', 0, -500))
661
+
662
+ const firstSaved = updateConfig.mock.calls.at(-1)?.[0].annotations[0]
663
+ expect(firstSaved.dy).toBe(-190)
664
+
665
+ const secondContext = buildAnnotationContext(firstSaved, { isEditor: true, updateConfig })
666
+ rendered.rerender(annotationTree(secondContext))
667
+ label = rendered.container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
668
+ fireEvent(label, pointerEvent('pointerdown', 0, 0))
669
+ fireEvent(window, pointerEvent('pointermove', 0, 5))
670
+ fireEvent(window, pointerEvent('pointerup', 0, 5))
671
+
672
+ const secondSaved = updateConfig.mock.calls.at(-1)?.[0].annotations[0]
673
+ expect(secondSaved.dy).toBe(-185)
674
+ })
675
+
676
+ it('previews resize locally, persists em on release, and does not start label dragging', () => {
677
+ const updateConfig = vi.fn()
678
+ const onDragStateChange = vi.fn()
679
+ const annotation = { ...calloutAnnotation, labelPosition: 'right' as const }
680
+ const { getByTestId, container } = renderAnnotationDraggable(annotation, 800, 400, {
681
+ isEditor: true,
682
+ updateConfig,
683
+ handleDragStateChange: onDragStateChange
684
+ })
685
+ const handle = getByTestId('annotation-resize-handle')
686
+
687
+ fireEvent(handle, pointerEvent('pointerdown', 100))
688
+ fireEvent(window, pointerEvent('pointermove', 350))
689
+ expect(updateConfig).not.toHaveBeenCalled()
690
+ expect(onDragStateChange).not.toHaveBeenCalled()
691
+ const label = container.querySelector('div[aria-label^="Annotation text"]') as HTMLElement
692
+ expect((label.parentElement as HTMLElement).style.width).toBe('388px')
693
+
694
+ fireEvent(window, pointerEvent('pointerup', 350))
695
+ expect(updateConfig).toHaveBeenCalledOnce()
696
+ expect(updateConfig.mock.calls[0][0].annotations[0].labelWidthEm).toBe(24.25)
697
+ })
698
+
699
+ it.each([
700
+ ['callout', calloutAnnotation, 6],
701
+ ['event-line', baseEventLineAnnotation, 4]
702
+ ] as const)('uses the %s resize floor', (_style, annotation, expectedWidthEm) => {
703
+ const updateConfig = vi.fn()
704
+ const { getByTestId } = renderAnnotationDraggable(annotation, 800, 400, {
705
+ isEditor: true,
706
+ updateConfig
707
+ })
708
+
709
+ const handle = getByTestId('annotation-resize-handle')
710
+ fireEvent(handle, pointerEvent('pointerdown', 300))
711
+ fireEvent(window, pointerEvent('pointermove', 0))
712
+ fireEvent(window, pointerEvent('pointerup', 0))
713
+
714
+ expect(updateConfig.mock.calls[0][0].annotations[0].labelWidthEm).toBe(expectedWidthEm)
715
+ })
716
+
717
+ it('leaves mobile symbol annotations unchanged', () => {
718
+ const annotation = {
719
+ ...calloutAnnotation,
720
+ labelPosition: 'left' as const,
721
+ labelWidthEm: 12
722
+ }
723
+ const { container, queryByTestId } = renderAnnotationDraggable(annotation, 320, 200, {
724
+ currentViewport: 'lg',
725
+ vizViewport: 'xxs',
726
+ isEditor: true
727
+ })
728
+
729
+ expect(container.querySelector('.annotation__desktop-label')).toBeFalsy()
730
+ expect(container.querySelector('.annotation__mobile-label-circle')).toBeTruthy()
731
+ expect(queryByTestId('annotation-resize-handle')).toBeNull()
732
+ })
733
+
734
+ it.each(['line', 'elbow', 'curve'] as const)(
735
+ 'connects the %s path to the mobile number instead of the hidden text layout',
736
+ connectionType => {
737
+ const annotation = {
738
+ ...calloutAnnotation,
739
+ connectionType,
740
+ dx: 100,
741
+ dy: 0,
742
+ labelPosition: 'right' as const,
743
+ labelWidthEm: 12
744
+ }
745
+ const { container } = renderAnnotationDraggable(annotation, 320, 200, {
746
+ currentViewport: 'xxs',
747
+ vizViewport: 'xxs'
748
+ })
749
+ const circle = container.querySelector('.annotation__mobile-label-circle')
750
+ const connector =
751
+ connectionType === 'curve'
752
+ ? container.querySelector(`path[marker-start="url(#marker-start--0)"]`)
753
+ : container.querySelector('.visx-annotation-connector')
754
+ const pathEnd = connector?.getAttribute('d')?.match(/(-?\d+(?:\.\d+)?),(-?\d+(?:\.\d+)?)$/)
755
+
756
+ expect(pathEnd).toBeTruthy()
757
+ expect(Number(pathEnd?.[1])).toBe(Number(circle?.getAttribute('cx')))
758
+ expect(Number(pathEnd?.[2])).toBe(Number(circle?.getAttribute('cy')))
759
+ }
760
+ )
761
+
762
+ it('uses vizViewport ahead of currentViewport for in-chart mobile behavior', () => {
763
+ const { container } = renderAnnotationDraggable(calloutAnnotation, 320, 200, {
764
+ currentViewport: 'xxs',
765
+ vizViewport: 'lg'
766
+ })
767
+
768
+ expect(container.querySelector('.annotation__desktop-label')).toBeTruthy()
769
+ expect(container.querySelector('.annotation__mobile-label-circle')).toBeFalsy()
770
+ })
771
+
772
+ it('preserves the text-to-symbol transition between 577px and 576px', () => {
773
+ const desktopViewport = getViewport(577)
774
+ const mobileViewport = getViewport(576)
775
+ expect(desktopViewport).toBe('sm')
776
+ expect(mobileViewport).toBe('xs')
777
+
778
+ const desktop = renderAnnotationDraggable(calloutAnnotation, 577, 400, {
779
+ currentViewport: desktopViewport,
780
+ vizViewport: desktopViewport
781
+ })
782
+ expect(desktop.container.querySelector('.annotation__desktop-label')).toBeTruthy()
783
+ desktop.unmount()
784
+
785
+ const mobile = renderAnnotationDraggable(calloutAnnotation, 576, 400, {
786
+ currentViewport: mobileViewport,
787
+ vizViewport: mobileViewport
788
+ })
789
+ expect(mobile.container.querySelector('.annotation__mobile-label-circle')).toBeTruthy()
197
790
  })
198
791
  })