@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,4 +1,4 @@
1
- import { useContext, useState } from 'react'
1
+ import { type PointerEvent as ReactPointerEvent, useContext, useEffect, useRef, useState } from 'react'
2
2
  import ConfigContext from '../../../ConfigContext'
3
3
  import DOMPurify from 'dompurify'
4
4
  import { APP_FONT_COLOR } from '@cdc/core/helpers/constants'
@@ -6,9 +6,19 @@ import { isMobileAnnotationViewport, isMobileFontViewport } from '@cdc/core/help
6
6
 
7
7
  // helpers
8
8
  import { findNearestDatum } from './findNearestDatum'
9
+ import {
10
+ type AnnotationLabelRect,
11
+ type AnnotationLayout,
12
+ getAnnotationLabelRect,
13
+ getFacingAnnotationSide,
14
+ MIN_ANNOTATION_LABEL_WIDTH_EM,
15
+ MIN_EVENT_LINE_LABEL_WIDTH_EM,
16
+ resolveAnnotationLayout,
17
+ resolveAnnotationRectangleDrag
18
+ } from '../helpers/resolveAnnotationLayout'
9
19
 
10
20
  // visx
11
- import { HtmlLabel, CircleSubject, EditableAnnotation, Connector, Annotation as VisxAnnotation } from '@visx/annotation'
21
+ import { HtmlLabel, CircleSubject, Connector, Annotation as VisxAnnotation } from '@visx/annotation'
12
22
  import { MarkerArrow } from '@visx/marker'
13
23
  import { LinePath } from '@visx/shape'
14
24
 
@@ -18,6 +28,94 @@ import './AnnotationDraggable.styles.css'
18
28
  export const EVENT_LINE_LABEL_OFFSET = 2
19
29
  export const snapEventLineDx = (dx: number) => (dx >= 0 ? EVENT_LINE_LABEL_OFFSET : -EVENT_LINE_LABEL_OFFSET)
20
30
 
31
+ type ContentAlignment = 'start' | 'center' | 'end'
32
+
33
+ const getCalloutContentAlignment = (horizontalAnchor: AnnotationLayout['horizontalAnchor']): ContentAlignment => {
34
+ if (horizontalAnchor === 'middle') return 'center'
35
+ return horizontalAnchor === 'end' ? 'end' : 'start'
36
+ }
37
+
38
+ type ResizeHandleProps = {
39
+ edge: 'left' | 'right'
40
+ initialWidth: number
41
+ maxWidth: number
42
+ minWidthEm: number
43
+ onPreview: (width: number) => void
44
+ onCommit: (width: number, fontSize: number) => void
45
+ onCancel: () => void
46
+ }
47
+
48
+ const AnnotationResizeHandle = ({
49
+ edge,
50
+ initialWidth,
51
+ maxWidth,
52
+ minWidthEm,
53
+ onPreview,
54
+ onCommit,
55
+ onCancel
56
+ }: ResizeHandleProps) => {
57
+ const handlePointerDown = (event: ReactPointerEvent<HTMLSpanElement>) => {
58
+ event.preventDefault()
59
+ event.stopPropagation()
60
+
61
+ const startX = event.clientX
62
+ const labelElement = event.currentTarget.parentElement
63
+ const measuredWidth = labelElement?.getBoundingClientRect().width || 0
64
+ const startWidth = measuredWidth > 0 ? measuredWidth : initialWidth
65
+ const computedFontSize = labelElement ? Number.parseFloat(window.getComputedStyle(labelElement).fontSize) || 16 : 16
66
+ const minWidth = minWidthEm * computedFontSize
67
+ const boundedMaxWidth = Math.max(minWidth, maxWidth)
68
+ let previewWidth = startWidth
69
+ let moved = false
70
+
71
+ const removeListeners = () => {
72
+ window.removeEventListener('pointermove', handlePointerMove)
73
+ window.removeEventListener('pointerup', finishResize)
74
+ window.removeEventListener('pointercancel', cancelResize)
75
+ }
76
+
77
+ const handlePointerMove = (pointerEvent: PointerEvent) => {
78
+ pointerEvent.preventDefault()
79
+ pointerEvent.stopPropagation()
80
+ const delta = pointerEvent.clientX - startX
81
+ previewWidth = Math.min(boundedMaxWidth, Math.max(minWidth, startWidth + (edge === 'right' ? delta : -delta)))
82
+ moved = true
83
+ onPreview(previewWidth)
84
+ }
85
+
86
+ const finishResize = (pointerEvent: PointerEvent) => {
87
+ pointerEvent.preventDefault()
88
+ pointerEvent.stopPropagation()
89
+ removeListeners()
90
+ if (moved) onCommit(previewWidth, computedFontSize)
91
+ }
92
+
93
+ const cancelResize = (pointerEvent: PointerEvent) => {
94
+ pointerEvent.preventDefault()
95
+ pointerEvent.stopPropagation()
96
+ removeListeners()
97
+ onCancel()
98
+ }
99
+
100
+ window.addEventListener('pointermove', handlePointerMove)
101
+ window.addEventListener('pointerup', finishResize)
102
+ window.addEventListener('pointercancel', cancelResize)
103
+ }
104
+
105
+ return (
106
+ <span
107
+ className={`annotation__resize-handle annotation__resize-handle--${edge}`}
108
+ data-testid='annotation-resize-handle'
109
+ aria-hidden='true'
110
+ onPointerDown={handlePointerDown}
111
+ onClick={event => {
112
+ event.preventDefault()
113
+ event.stopPropagation()
114
+ }}
115
+ />
116
+ )
117
+ }
118
+
21
119
  // Keep annotation label text in sync with axis tick labels (see LinearChart.tsx).
22
120
  const TICK_LABEL_FONT_SIZE = 16
23
121
  const TICK_LABEL_FONT_SIZE_SMALL = 13
@@ -34,17 +132,16 @@ const Annotations = ({
34
132
  onDragStateChange
35
133
  }) => {
36
134
  // prettier-ignore
37
- const { config, dimensions, isEditor, updateConfig, colorScale, transformedData, parseDate, currentViewport, visibleAnnotations } = useContext(ConfigContext)
135
+ const { config, isEditor, updateConfig, colorScale, transformedData, parseDate, currentViewport, vizViewport, visibleAnnotations } = useContext(ConfigContext)
38
136
 
39
137
  // destructure config items here...
40
138
  const { annotations, visualizationType } = config
41
- const [height] = dimensions
42
-
43
- const AnnotationComponent = isEditor ? EditableAnnotation : VisxAnnotation
44
- const isMobile = isMobileAnnotationViewport(currentViewport) && config?.general?.mobileAnnotationDisplay !== 'text'
139
+ const annotationViewport = vizViewport ?? currentViewport
140
+ const isMobile = isMobileAnnotationViewport(annotationViewport) && config?.general?.mobileAnnotationDisplay !== 'text'
45
141
 
46
142
  // Match the axis tick label font size (and its viewport-based scaling) for visual consistency.
47
- const tickLabelFontSize = isMobileFontViewport(currentViewport) ? TICK_LABEL_FONT_SIZE_SMALL : TICK_LABEL_FONT_SIZE
143
+ const usesMobileFontSize = isMobileFontViewport(annotationViewport)
144
+ const tickLabelFontSize = usesMobileFontSize ? TICK_LABEL_FONT_SIZE_SMALL : TICK_LABEL_FONT_SIZE
48
145
 
49
146
  /**
50
147
  * Scale dx/dy offsets based on savedDimensions vs current dimensions.
@@ -61,22 +158,61 @@ const Annotations = ({
61
158
  }
62
159
 
63
160
  // Track live drag position for real-time anchor calculations
64
- const [liveDrag, setLiveDrag] = useState<{ index: number; dx: number } | null>(null)
65
-
66
- // Helper to determine label anchoring when near chart edges
67
- const getAnnotationAnchors = (annotationX: number, dx: number, labelWidth: number) => {
68
- const endpointX = annotationX + dx
69
-
70
- const leftOverflow = dx < 0 && endpointX - labelWidth < 0
71
- const rightOverflow = dx > 0 && endpointX + labelWidth > xMax
72
-
73
- if (leftOverflow || rightOverflow) {
74
- // Center label above the endpoint
75
- return { horizontalAnchor: 'middle' as const, verticalAnchor: 'end' as const }
76
- }
161
+ const [liveDrag, setLiveDrag] = useState<{
162
+ index: number
163
+ dx: number
164
+ dy: number
165
+ layout?: AnnotationLayout
166
+ } | null>(null)
167
+ const [liveSubjectDrag, setLiveSubjectDrag] = useState<{ index: number; x: number; y: number } | null>(null)
168
+ const [draggingLabelIndex, setDraggingLabelIndex] = useState<number | null>(null)
169
+ const [draggingSubjectIndex, setDraggingSubjectIndex] = useState<number | null>(null)
170
+ const [previewWidths, setPreviewWidths] = useState<Record<number, number>>({})
171
+ const [labelMeasurements, setLabelMeasurements] = useState<
172
+ Record<number, { width: number; height: number; naturalWidth: number }>
173
+ >({})
174
+ const labelRefs = useRef<Record<number, HTMLDivElement | null>>({})
175
+ const runtimeConstrainedWidths = useRef<Record<number, number | undefined>>({})
176
+
177
+ useEffect(() => {
178
+ setLabelMeasurements(current => {
179
+ let changed = false
180
+ const next = { ...current }
181
+
182
+ Object.entries(labelRefs.current).forEach(([indexKey, node]) => {
183
+ if (!node) return
184
+ const index = Number(indexKey)
185
+ const { width, height } = node.getBoundingClientRect()
186
+ if (width <= 0 || height <= 0) return
187
+ const previous = current[index]
188
+ const naturalWidth =
189
+ runtimeConstrainedWidths.current[index] === undefined ? width : previous?.naturalWidth ?? width
190
+
191
+ if (
192
+ !previous ||
193
+ Math.abs(previous.width - width) > 0.5 ||
194
+ Math.abs(previous.height - height) > 0.5 ||
195
+ Math.abs(previous.naturalWidth - naturalWidth) > 0.5
196
+ ) {
197
+ next[index] = { width, height, naturalWidth }
198
+ changed = true
199
+ }
200
+ })
77
201
 
78
- // Let visx auto-decide
79
- return { horizontalAnchor: null, verticalAnchor: null }
202
+ return changed ? next : current
203
+ })
204
+ })
205
+
206
+ const getSvgPointerPosition = (clientX: number, clientY: number) => {
207
+ const referencedElement = svgRef?.current
208
+ const svg = referencedElement?.ownerSVGElement || referencedElement
209
+ const matrix = svg?.getScreenCTM?.()
210
+ if (!svg?.createSVGPoint || !matrix) return { x: clientX, y: clientY }
211
+
212
+ const point = svg.createSVGPoint()
213
+ point.x = clientX
214
+ point.y = clientY
215
+ return point.matrixTransform(matrix.inverse())
80
216
  }
81
217
 
82
218
  return (
@@ -85,10 +221,54 @@ const Annotations = ({
85
221
  const originalIndex = annotations.indexOf(annotation)
86
222
  const text = annotation.text || ''
87
223
  const isEventLine = annotation.style === 'event-line'
88
-
89
224
  // Calculate scaled dx/dy offsets based on savedDimensions
90
225
  const { scaledDx: rawScaledDx, scaledDy } = getScaledOffsets(annotation)
91
- const scaledDx = isEventLine ? snapEventLineDx(rawScaledDx) : rawScaledDx
226
+ const scaledDx = isEventLine
227
+ ? annotation.labelPosition === 'left'
228
+ ? -EVENT_LINE_LABEL_OFFSET
229
+ : annotation.labelPosition === 'right'
230
+ ? EVENT_LINE_LABEL_OFFSET
231
+ : snapEventLineDx(rawScaledDx)
232
+ : rawScaledDx
233
+ const displayedDx = liveDrag?.index === annotationIndex ? liveDrag.dx : scaledDx
234
+ const displayedDy = liveDrag?.index === annotationIndex ? liveDrag.dy : scaledDy
235
+ const previewLabelWidthPx = previewWidths[originalIndex]
236
+
237
+ const previewLabelWidth = (width: number) => {
238
+ setPreviewWidths(current => ({ ...current, [originalIndex]: width }))
239
+ }
240
+
241
+ const clearPreviewLabelWidth = () => {
242
+ setPreviewWidths(current => {
243
+ const next = { ...current }
244
+ delete next[originalIndex]
245
+ return next
246
+ })
247
+ }
248
+
249
+ const persistLabelWidth = (width: number, fontSize: number) => {
250
+ const updatedAnnotations = [...annotations]
251
+ updatedAnnotations[originalIndex] = {
252
+ ...updatedAnnotations[originalIndex],
253
+ labelWidthEm: width / fontSize
254
+ }
255
+ clearPreviewLabelWidth()
256
+ updateConfig({ ...config, annotations: updatedAnnotations })
257
+ }
258
+
259
+ const persistLabelPosition = (dx: number, dy: number, autoSide?: AnnotationLayout['side']) => {
260
+ const updatedAnnotations = [...annotations]
261
+ updatedAnnotations[originalIndex] = {
262
+ ...updatedAnnotations[originalIndex],
263
+ dx,
264
+ dy,
265
+ savedDimensions: [xMax, yMax],
266
+ ...((!annotation.labelPosition || annotation.labelPosition === 'auto') && !isEventLine && autoSide
267
+ ? { autoSide }
268
+ : {})
269
+ }
270
+ updateConfig({ ...config, annotations: updatedAnnotations })
271
+ }
92
272
 
93
273
  // Default to absolute positioning
94
274
  let annotationX = xScaleAnnotation(annotation.x)
@@ -147,135 +327,385 @@ const Annotations = ({
147
327
  }
148
328
  }
149
329
 
150
- // sanitize the text for setting dangerouslySetInnerHTML
151
- const sanitizedData = () => ({
152
- __html: DOMPurify.sanitize(text)
330
+ const displayedAnnotationX = liveSubjectDrag?.index === annotationIndex ? liveSubjectDrag.x : annotationX
331
+ const displayedAnnotationY = liveSubjectDrag?.index === annotationIndex ? liveSubjectDrag.y : annotationY
332
+ const legacyLabelWidth = isEventLine || config.general.showAnnotationDropdown ? 186 : 150
333
+ const minLabelWidthEm = isEventLine ? MIN_EVENT_LINE_LABEL_WIDTH_EM : MIN_ANNOTATION_LABEL_WIDTH_EM
334
+ const measurement = labelMeasurements[originalIndex]
335
+ const authoredLabelWidth =
336
+ annotation.labelWidthEm === undefined
337
+ ? measurement?.naturalWidth ?? legacyLabelWidth
338
+ : Math.max(minLabelWidthEm, annotation.labelWidthEm) * tickLabelFontSize
339
+ const desiredLabelWidth = previewLabelWidthPx ?? authoredLabelWidth
340
+ const endpointX = displayedAnnotationX + displayedDx
341
+ const endpointY = displayedAnnotationY + displayedDy
342
+ const eventLineSide =
343
+ annotation.labelPosition === 'left' || annotation.labelPosition === 'right'
344
+ ? annotation.labelPosition
345
+ : displayedDx >= 0
346
+ ? 'right'
347
+ : 'left'
348
+ const calculatedLayout = resolveAnnotationLayout({
349
+ endpointX,
350
+ endpointY,
351
+ dx: displayedDx,
352
+ dy: displayedDy,
353
+ labelWidth: desiredLabelWidth,
354
+ labelHeight: measurement?.height ?? 0,
355
+ plotWidth: xMax,
356
+ plotHeight: yMax,
357
+ fontSize: tickLabelFontSize,
358
+ minWidthEm: minLabelWidthEm,
359
+ labelPosition: isEventLine ? eventLineSide : annotation.labelPosition,
360
+ autoSide: isEventLine ? undefined : annotation.autoSide,
361
+ clampNormalAxis: !isEventLine
153
362
  })
363
+ const resolvedLayout = liveDrag?.index === annotationIndex && liveDrag.layout ? liveDrag.layout : calculatedLayout
364
+ const renderedDx = resolvedLayout.x - displayedAnnotationX
365
+ const renderedDy = resolvedLayout.y - displayedAnnotationY
366
+ const connectorDx = isMobile ? scaledDx : renderedDx
367
+ const connectorDy = isMobile ? scaledDy : renderedDy
368
+ const usesLegacyEventLineSizing =
369
+ isEventLine && annotation.labelWidthEm === undefined && previewLabelWidthPx === undefined
370
+ const runtimeWidth =
371
+ previewLabelWidthPx ?? (usesLegacyEventLineSizing ? undefined : resolvedLayout.constrainedWidth)
372
+ const availableResizeWidth =
373
+ resolvedLayout.side === 'left'
374
+ ? resolvedLayout.x
375
+ : resolvedLayout.side === 'right'
376
+ ? xMax - resolvedLayout.x
377
+ : xMax
378
+ const maxResizeWidth = Math.max(minLabelWidthEm * tickLabelFontSize, availableResizeWidth)
379
+ runtimeConstrainedWidths.current[originalIndex] =
380
+ previewLabelWidthPx === undefined && !usesLegacyEventLineSizing ? resolvedLayout.constrainedWidth : undefined
381
+ const labelContainerStyle = usesLegacyEventLineSizing
382
+ ? { width: 'fit-content', maxWidth: `${legacyLabelWidth}px` }
383
+ : runtimeWidth !== undefined
384
+ ? { width: `${runtimeWidth}px` }
385
+ : annotation.labelWidthEm !== undefined
386
+ ? { width: `${authoredLabelWidth}px` }
387
+ : { width: 'fit-content', maxWidth: `${legacyLabelWidth}px` }
388
+
389
+ const resolveEventLineDraggedPosition = (rawDx: number, rawDy: number) => {
390
+ const dragDx =
391
+ annotation.labelPosition === 'left'
392
+ ? -EVENT_LINE_LABEL_OFFSET
393
+ : annotation.labelPosition === 'right'
394
+ ? EVENT_LINE_LABEL_OFFSET
395
+ : snapEventLineDx(rawDx)
396
+ const dragEventLineSide =
397
+ annotation.labelPosition === 'left' || annotation.labelPosition === 'right'
398
+ ? annotation.labelPosition
399
+ : dragDx >= 0
400
+ ? 'right'
401
+ : 'left'
402
+
403
+ const layout = resolveAnnotationLayout({
404
+ endpointX: displayedAnnotationX + dragDx,
405
+ endpointY: displayedAnnotationY + rawDy,
406
+ dx: dragDx,
407
+ dy: rawDy,
408
+ labelWidth: desiredLabelWidth,
409
+ labelHeight: measurement?.height ?? 0,
410
+ plotWidth: xMax,
411
+ plotHeight: yMax,
412
+ fontSize: tickLabelFontSize,
413
+ minWidthEm: minLabelWidthEm,
414
+ labelPosition: dragEventLineSide,
415
+ clampNormalAxis: false
416
+ })
417
+
418
+ return {
419
+ dx: layout.x - displayedAnnotationX,
420
+ dy: layout.y - displayedAnnotationY,
421
+ layout
422
+ }
423
+ }
154
424
 
155
- return (
156
- <AnnotationComponent
157
- key={`annotation-${originalIndex}-${annotation.x}-${annotation.y}-${annotation.dx}-${annotation.dy}`}
158
- width={xMax}
159
- height={yMax}
160
- dx={scaledDx} // label position (scaled to current chart dimensions)
161
- dy={scaledDy} // label position (scaled to current chart dimensions)
162
- x={annotationX}
163
- y={annotationY}
164
- canEditLabel={annotation.edit.label || false}
165
- canEditSubject={(annotation.edit.subject && (isEventLine || annotation.connectionType !== 'none')) || false}
166
- labelDragHandleProps={{ r: 15, stroke: 'red' }}
167
- subjectDragHandleProps={{ r: 15, stroke: 'red' }}
168
- onDragStart={() => {
169
- onDragStateChange(true)
170
- setLiveDrag({ index: annotationIndex, dx: scaledDx })
171
- }}
172
- onDragMove={props => {
173
- const previewDx = isEventLine ? snapEventLineDx(props.dx) : props.dx
174
- setLiveDrag({ index: annotationIndex, dx: previewDx })
175
- }}
176
- onDragEnd={props => {
177
- onDragStateChange(false)
178
- setLiveDrag(null)
179
-
180
- let updatedAnnotations = [...annotations]
181
-
182
- // Current chart dimensions to save with the annotation
183
- const currentDimensions: [number, number] = [xMax, yMax]
184
-
185
- const isLabelOnlyDrag =
186
- annotation.anchorMode === 'data'
187
- ? annotationX === props.x && annotationY === props.y
188
- : annotation.x === xScaleAnnotation.invert(props.x) && annotation.y === yScaleAnnotation.invert(props.y)
189
-
190
- if (isLabelOnlyDrag) {
191
- // Event-line snaps dx; callout persists raw drag offset.
192
- const persistedDx = isEventLine ? snapEventLineDx(props.dx) : props.dx
425
+ const startLabelDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
426
+ if (!isEditor || !annotation.edit.label) return
427
+
428
+ event.preventDefault()
429
+ event.stopPropagation()
430
+ const startPoint = getSvgPointerPosition(event.clientX, event.clientY)
431
+ const startRect = isEventLine
432
+ ? undefined
433
+ : getAnnotationLabelRect(
434
+ resolvedLayout.x,
435
+ resolvedLayout.y,
436
+ measurement?.width ?? resolvedLayout.width,
437
+ measurement?.height ?? 0,
438
+ resolvedLayout.side
439
+ )
440
+ const initialDrag = isEventLine
441
+ ? resolveEventLineDraggedPosition(scaledDx, scaledDy)
442
+ : {
443
+ dx: renderedDx,
444
+ dy: renderedDy,
445
+ layout: resolvedLayout
446
+ }
447
+ let nextDrag: {
448
+ dx: number
449
+ dy: number
450
+ layout: AnnotationLayout
451
+ } = initialDrag
452
+
453
+ const removeListeners = () => {
454
+ window.removeEventListener('pointermove', handlePointerMove)
455
+ window.removeEventListener('pointerup', finishDrag)
456
+ window.removeEventListener('pointercancel', cancelDrag)
457
+ }
458
+
459
+ const handlePointerMove = (pointerEvent: PointerEvent) => {
460
+ pointerEvent.preventDefault()
461
+ pointerEvent.stopPropagation()
462
+ const pointer = getSvgPointerPosition(pointerEvent.clientX, pointerEvent.clientY)
463
+ const pointerDx = pointer.x - startPoint.x
464
+ const pointerDy = pointer.y - startPoint.y
465
+
466
+ if (isEventLine) {
467
+ nextDrag = resolveEventLineDraggedPosition(initialDrag.dx + pointerDx, initialDrag.dy + pointerDy)
468
+ } else if (startRect) {
469
+ const intendedRect = {
470
+ ...startRect,
471
+ left: startRect.left + pointerDx,
472
+ top: startRect.top + pointerDy
473
+ }
474
+ const dragSide =
475
+ annotation.labelPosition && annotation.labelPosition !== 'auto'
476
+ ? annotation.labelPosition
477
+ : getFacingAnnotationSide(displayedAnnotationX, displayedAnnotationY, intendedRect)
478
+
479
+ const resolvedDrag = resolveAnnotationRectangleDrag({
480
+ rect: intendedRect,
481
+ subjectX: displayedAnnotationX,
482
+ subjectY: displayedAnnotationY,
483
+ plotWidth: xMax,
484
+ plotHeight: yMax,
485
+ side: dragSide
486
+ })
487
+ nextDrag = {
488
+ dx: resolvedDrag.dx,
489
+ dy: resolvedDrag.dy,
490
+ layout: resolvedDrag.layout
491
+ }
492
+ }
493
+ setLiveDrag({ index: annotationIndex, ...nextDrag })
494
+ }
495
+
496
+ const finishDrag = (pointerEvent: PointerEvent) => {
497
+ pointerEvent.preventDefault()
498
+ pointerEvent.stopPropagation()
499
+ removeListeners()
500
+ onDragStateChange(false)
501
+ setDraggingLabelIndex(null)
502
+ setLiveDrag(null)
503
+ persistLabelPosition(nextDrag.dx, nextDrag.dy, nextDrag.layout.side)
504
+ }
505
+
506
+ const cancelDrag = (pointerEvent: PointerEvent) => {
507
+ pointerEvent.preventDefault()
508
+ pointerEvent.stopPropagation()
509
+ removeListeners()
510
+ onDragStateChange(false)
511
+ setDraggingLabelIndex(null)
512
+ setLiveDrag(null)
513
+ }
514
+
515
+ onDragStateChange(true)
516
+ setDraggingLabelIndex(annotationIndex)
517
+ setLiveDrag({ index: annotationIndex, ...initialDrag })
518
+ window.addEventListener('pointermove', handlePointerMove)
519
+ window.addEventListener('pointerup', finishDrag)
520
+ window.addEventListener('pointercancel', cancelDrag)
521
+ }
522
+
523
+ const startSubjectDrag = (event: ReactPointerEvent<SVGCircleElement>) => {
524
+ if (!isEditor || !annotation.edit.subject || (!isEventLine && annotation.connectionType === 'none')) return
525
+
526
+ event.preventDefault()
527
+ event.stopPropagation()
528
+ const startPoint = getSvgPointerPosition(event.clientX, event.clientY)
529
+ let nextX = annotationX
530
+ let nextY = annotationY
531
+
532
+ const removeListeners = () => {
533
+ window.removeEventListener('pointermove', handlePointerMove)
534
+ window.removeEventListener('pointerup', finishDrag)
535
+ window.removeEventListener('pointercancel', cancelDrag)
536
+ }
537
+
538
+ const handlePointerMove = (pointerEvent: PointerEvent) => {
539
+ pointerEvent.preventDefault()
540
+ pointerEvent.stopPropagation()
541
+ const pointer = getSvgPointerPosition(pointerEvent.clientX, pointerEvent.clientY)
542
+ nextX = annotationX + pointer.x - startPoint.x
543
+ nextY = annotationY + pointer.y - startPoint.y
544
+ setLiveSubjectDrag({ index: annotationIndex, x: nextX, y: nextY })
545
+ }
546
+
547
+ const finishDrag = (pointerEvent: PointerEvent) => {
548
+ pointerEvent.preventDefault()
549
+ pointerEvent.stopPropagation()
550
+ removeListeners()
551
+ onDragStateChange(false)
552
+ setDraggingSubjectIndex(null)
553
+ setLiveSubjectDrag(null)
554
+
555
+ const updatedAnnotations = [...annotations]
556
+ const currentDimensions: [number, number] = [xMax, yMax]
557
+
558
+ if (annotation.anchorMode === 'data') {
559
+ const nearestDatum = findNearestDatum({
560
+ data: transformedData || config.data,
561
+ xScale,
562
+ xAxisType: config.xAxis.type,
563
+ xAxisDataKey: config.xAxis.dataKey,
564
+ seriesKey: annotation.seriesKey,
565
+ xPixel: nextX,
566
+ parseDate
567
+ })
568
+
569
+ if (nearestDatum) {
193
570
  updatedAnnotations[originalIndex] = {
194
571
  ...updatedAnnotations[originalIndex],
195
- dx: persistedDx,
196
- dy: props.dy,
572
+ dataX: nearestDatum.x,
573
+ x: xScaleAnnotation.invert(nextX),
574
+ y: yScaleAnnotation.invert(nextY),
197
575
  savedDimensions: currentDimensions
198
576
  }
199
- } else {
200
- if (annotation.anchorMode === 'data') {
201
- let nearestDatum = findNearestDatum({
202
- data: transformedData || config.data,
203
- xScale,
204
- xAxisType: config.xAxis.type,
205
- xAxisDataKey: config.xAxis.dataKey,
206
- seriesKey: annotation.seriesKey,
207
- xPixel: props.x,
208
- parseDate
209
- })
210
-
211
- if (nearestDatum) {
212
- updatedAnnotations[originalIndex] = {
213
- ...updatedAnnotations[originalIndex],
214
- dataX: nearestDatum.x,
215
- x: xScaleAnnotation.invert(props.x),
216
- y: yScaleAnnotation.invert(props.y),
217
- savedDimensions: currentDimensions
218
- }
219
- }
220
- } else {
221
- updatedAnnotations[originalIndex] = {
222
- ...updatedAnnotations[originalIndex],
223
- x: xScaleAnnotation.invert(props.x),
224
- y: yScaleAnnotation.invert(props.y),
225
- savedDimensions: currentDimensions
226
- }
227
- }
228
577
  }
578
+ } else {
579
+ updatedAnnotations[originalIndex] = {
580
+ ...updatedAnnotations[originalIndex],
581
+ x: xScaleAnnotation.invert(nextX),
582
+ y: yScaleAnnotation.invert(nextY),
583
+ savedDimensions: currentDimensions
584
+ }
585
+ }
229
586
 
230
- updateConfig({
231
- ...config,
232
- annotations: updatedAnnotations
233
- })
234
- }}
587
+ updateConfig({ ...config, annotations: updatedAnnotations })
588
+ }
589
+
590
+ const cancelDrag = (pointerEvent: PointerEvent) => {
591
+ pointerEvent.preventDefault()
592
+ pointerEvent.stopPropagation()
593
+ removeListeners()
594
+ onDragStateChange(false)
595
+ setDraggingSubjectIndex(null)
596
+ setLiveSubjectDrag(null)
597
+ }
598
+
599
+ onDragStateChange(true)
600
+ setDraggingSubjectIndex(annotationIndex)
601
+ setLiveSubjectDrag({ index: annotationIndex, x: annotationX, y: annotationY })
602
+ window.addEventListener('pointermove', handlePointerMove)
603
+ window.addEventListener('pointerup', finishDrag)
604
+ window.addEventListener('pointercancel', cancelDrag)
605
+ }
606
+
607
+ // sanitize the text for setting dangerouslySetInnerHTML
608
+ const sanitizedData = () => ({
609
+ __html: DOMPurify.sanitize(text)
610
+ })
611
+
612
+ return (
613
+ <VisxAnnotation
614
+ key={`annotation-${originalIndex}`}
615
+ dx={connectorDx}
616
+ dy={connectorDy}
617
+ x={displayedAnnotationX}
618
+ y={displayedAnnotationY}
235
619
  >
236
620
  {isEventLine ? (
237
621
  <>
238
622
  <line
239
- x1={annotationX}
240
- x2={annotationX}
623
+ x1={displayedAnnotationX}
624
+ x2={displayedAnnotationX}
241
625
  y1={0}
242
626
  y2={yMax}
243
- stroke='#333'
627
+ stroke={APP_FONT_COLOR}
244
628
  strokeWidth={1}
245
629
  className='annotation__event-line'
246
630
  pointerEvents='none'
247
631
  />
248
632
  {!isMobile &&
249
633
  (() => {
250
- // Use live dx during drag (already in current space), otherwise use scaled dx
251
- const currentDx = liveDrag?.index === annotationIndex ? liveDrag.dx : scaledDx
252
- const onRight = currentDx >= 0
634
+ const onRight = resolvedLayout.side === 'right'
635
+ const horizontalAnchor = resolvedLayout.horizontalAnchor
636
+ const verticalAnchor = resolvedLayout.verticalAnchor
637
+ const handleEdge = horizontalAnchor === 'end' ? 'left' : 'right'
253
638
  return (
254
639
  <HtmlLabel
255
640
  className='annotation__desktop-label'
256
- containerStyle={{ width: 'fit-content', maxWidth: '186px' }}
257
- horizontalAnchor={onRight ? 'start' : 'end'}
258
- verticalAnchor='middle'
641
+ containerStyle={labelContainerStyle}
642
+ horizontalAnchor={horizontalAnchor}
643
+ verticalAnchor={verticalAnchor}
644
+ x={resolvedLayout.x}
645
+ y={resolvedLayout.y}
259
646
  showAnchorLine={false}
260
647
  >
261
648
  <div
649
+ ref={node => {
650
+ labelRefs.current[originalIndex] = node
651
+ }}
262
652
  className={`annotation__event-line-label ${
263
653
  onRight
264
654
  ? 'cove-annotation-event-line__label--right'
265
655
  : 'cove-annotation-event-line__label--left'
656
+ } ${isEditor && annotation.edit.label ? 'annotation__label--editable' : ''} ${
657
+ draggingLabelIndex === annotationIndex ? 'annotation__label--dragging' : ''
266
658
  }`}
267
659
  style={{
268
660
  backgroundColor: `rgba(255, 255, 255, ${
269
661
  annotation?.opacity ? Number(annotation?.opacity) / 100 : 1
270
662
  })`,
271
663
  padding: '6px 8px',
272
- color: annotation.colors?.label || APP_FONT_COLOR,
273
- fontSize: tickLabelFontSize
664
+ color: APP_FONT_COLOR,
665
+ fontSize: tickLabelFontSize,
666
+ ...(usesLegacyEventLineSizing ? {} : { boxSizing: 'border-box' as const }),
667
+ ...(usesLegacyEventLineSizing
668
+ ? {}
669
+ : { width: '100%', maxWidth: 'none', position: 'relative' as const }),
670
+ ...(config.general.showAnnotationDropdown
671
+ ? { display: 'inline-flex', alignItems: 'center', flexDirection: 'row' as const }
672
+ : {}),
673
+ ...(usesMobileFontSize ? { lineHeight: '1.1em' } : {})
274
674
  }}
675
+ data-horizontal-anchor={horizontalAnchor}
676
+ data-vertical-anchor={verticalAnchor}
677
+ data-label-dx={renderedDx}
678
+ data-label-dy={renderedDy}
679
+ data-resolved-side={resolvedLayout.side}
680
+ onPointerDown={startLabelDrag}
275
681
  tabIndex={0}
276
682
  aria-label={`Annotation text that reads: ${annotation.text}`}
277
683
  >
278
- <div dangerouslySetInnerHTML={sanitizedData()} />
684
+ {config.general.showAnnotationDropdown && (
685
+ <p
686
+ className='annotation__has-dropdown-number'
687
+ style={{ margin: '2px 6px', position: 'relative', left: '-4px' }}
688
+ >
689
+ {originalIndex + 1}
690
+ </p>
691
+ )}
692
+ <div
693
+ className={
694
+ config.general.showAnnotationDropdown ? 'annotation__event-line-label-text' : undefined
695
+ }
696
+ dangerouslySetInnerHTML={sanitizedData()}
697
+ />
698
+ {isEditor && annotation.edit.label && (
699
+ <AnnotationResizeHandle
700
+ edge={handleEdge}
701
+ initialWidth={runtimeWidth ?? authoredLabelWidth}
702
+ maxWidth={maxResizeWidth}
703
+ minWidthEm={minLabelWidthEm}
704
+ onPreview={previewLabelWidth}
705
+ onCommit={persistLabelWidth}
706
+ onCancel={clearPreviewLabelWidth}
707
+ />
708
+ )}
279
709
  </div>
280
710
  </HtmlLabel>
281
711
  )
@@ -284,16 +714,16 @@ const Annotations = ({
284
714
  <>
285
715
  <circle
286
716
  fill='white'
287
- cx={annotationX + scaledDx}
288
- cy={annotationY + scaledDy}
717
+ cx={displayedAnnotationX + connectorDx}
718
+ cy={displayedAnnotationY + connectorDy}
289
719
  r={12}
290
720
  className='annotation__mobile-label annotation__mobile-label-circle'
291
721
  stroke={APP_FONT_COLOR}
292
722
  />
293
723
  <text
294
724
  height={16}
295
- x={annotationX + scaledDx}
296
- y={annotationY + scaledDy + 1}
725
+ x={displayedAnnotationX + connectorDx}
726
+ y={displayedAnnotationY + connectorDy + 1}
297
727
  fontSize={14}
298
728
  className='annotation__mobile-label'
299
729
  alignmentBaseline='middle'
@@ -308,32 +738,64 @@ const Annotations = ({
308
738
  <>
309
739
  {!isMobile &&
310
740
  (() => {
311
- const labelWidth = config.general.showAnnotationDropdown ? 186 : 150
312
741
  // Use live dx during drag (already in current space), otherwise use scaled dx
313
- const currentDx = liveDrag?.index === annotationIndex ? liveDrag.dx : scaledDx
314
- const { horizontalAnchor, verticalAnchor } = getAnnotationAnchors(annotationX, currentDx, labelWidth)
742
+ const currentDx = renderedDx
743
+ const { horizontalAnchor, verticalAnchor } = resolvedLayout
744
+ const usesLegacyAlignment =
745
+ annotation.autoSide === undefined &&
746
+ (!annotation.labelPosition || annotation.labelPosition === 'auto')
747
+ const contentAlignment = usesLegacyAlignment ? 'start' : getCalloutContentAlignment(horizontalAnchor)
748
+ const handleEdge = horizontalAnchor === 'end' ? 'left' : 'right'
315
749
  return (
316
750
  <HtmlLabel
317
751
  className='annotation__desktop-label'
318
- containerStyle={{ width: 'fit-content', maxWidth: `${labelWidth}px` }}
752
+ containerStyle={labelContainerStyle}
319
753
  horizontalAnchor={horizontalAnchor}
320
754
  verticalAnchor={verticalAnchor}
755
+ x={resolvedLayout.x}
756
+ y={resolvedLayout.y}
321
757
  showAnchorLine={false}
322
758
  >
323
759
  <div
760
+ ref={node => {
761
+ labelRefs.current[originalIndex] = node
762
+ }}
763
+ className={
764
+ isEditor && annotation.edit.label
765
+ ? `annotation__label--editable ${
766
+ draggingLabelIndex === annotationIndex ? 'annotation__label--dragging' : ''
767
+ }`
768
+ : undefined
769
+ }
324
770
  style={{
325
771
  borderRadius: 5, // Optional: set border radius
326
772
  backgroundColor: `rgba(255, 255, 255, ${
327
773
  annotation?.opacity ? Number(annotation?.opacity) / 100 : 1
328
774
  })`,
329
775
  padding: '10px',
330
- width: 'auto',
776
+ width: runtimeWidth === undefined && annotation.labelWidthEm === undefined ? 'auto' : '100%',
777
+ boxSizing: 'border-box',
778
+ position: 'relative',
331
779
  display: config.general.showAnnotationDropdown ? 'inline-flex' : 'flex',
332
- justifyContent: 'start',
780
+ justifyContent:
781
+ contentAlignment === 'center'
782
+ ? 'center'
783
+ : contentAlignment === 'end'
784
+ ? 'flex-end'
785
+ : 'flex-start',
333
786
  flexDirection: 'row',
334
787
  alignItems: 'center',
335
- fontSize: tickLabelFontSize
788
+ color: APP_FONT_COLOR,
789
+ fontSize: tickLabelFontSize,
790
+ ...(usesMobileFontSize ? { lineHeight: '1.1em' } : {})
336
791
  }}
792
+ data-horizontal-anchor={horizontalAnchor}
793
+ data-vertical-anchor={verticalAnchor}
794
+ data-label-dx={renderedDx}
795
+ data-label-dy={renderedDy}
796
+ data-content-alignment={contentAlignment}
797
+ data-resolved-side={resolvedLayout.side}
798
+ onPointerDown={startLabelDrag}
337
799
  // role='presentation'
338
800
  tabIndex={0}
339
801
  aria-label={`Annotation text that reads: ${annotation.text}`}
@@ -348,23 +810,47 @@ const Annotations = ({
348
810
  </p>
349
811
  </>
350
812
  )}
351
- <div dangerouslySetInnerHTML={sanitizedData()} />
813
+ <div
814
+ className={`annotation__label-text ${
815
+ contentAlignment === 'end' ? 'annotation__label-text--right' : ''
816
+ }`}
817
+ dangerouslySetInnerHTML={sanitizedData()}
818
+ />
819
+ {isEditor && annotation.edit.label && (
820
+ <AnnotationResizeHandle
821
+ edge={handleEdge}
822
+ initialWidth={runtimeWidth ?? authoredLabelWidth}
823
+ maxWidth={maxResizeWidth}
824
+ minWidthEm={minLabelWidthEm}
825
+ onPreview={previewLabelWidth}
826
+ onCommit={persistLabelWidth}
827
+ onCancel={clearPreviewLabelWidth}
828
+ />
829
+ )}
352
830
  </div>
353
831
  </HtmlLabel>
354
832
  )
355
833
  })()}
356
834
  {annotation.connectionType === 'line' && (
357
- <Connector type='line' pathProps={{ markerStart: `url(#marker-start--${originalIndex})` }} />
835
+ <Connector
836
+ type='line'
837
+ stroke={APP_FONT_COLOR}
838
+ pathProps={{ markerStart: `url(#marker-start--${originalIndex})` }}
839
+ />
358
840
  )}
359
841
  {annotation.connectionType === 'elbow' && (
360
- <Connector type='elbow' pathProps={{ markerStart: `url(#marker-start--${originalIndex})` }} />
842
+ <Connector
843
+ type='elbow'
844
+ stroke={APP_FONT_COLOR}
845
+ pathProps={{ markerStart: `url(#marker-start--${originalIndex})` }}
846
+ />
361
847
  )}
362
848
  {annotation.connectionType === 'curve' && (
363
849
  <LinePath
364
- d={`M ${annotationX},${annotationY}
365
- Q ${annotationX + scaledDx / 2}, ${
366
- annotationY + scaledDy / 2 + Number(annotation?.bezier) || 0
367
- } ${annotationX + scaledDx},${annotationY + scaledDy}`}
850
+ d={`M ${displayedAnnotationX},${displayedAnnotationY}
851
+ Q ${displayedAnnotationX + connectorDx / 2}, ${
852
+ displayedAnnotationY + connectorDy / 2 + Number(annotation?.bezier) || 0
853
+ } ${displayedAnnotationX + connectorDx},${displayedAnnotationY + connectorDy}`}
368
854
  stroke={APP_FONT_COLOR}
369
855
  fill='none'
370
856
  marker-start={`url(#marker-start--${originalIndex})`}
@@ -377,8 +863,8 @@ const Annotations = ({
377
863
  <MarkerArrow
378
864
  fill={APP_FONT_COLOR}
379
865
  id={`marker-start--${originalIndex}`}
380
- x={annotationX}
381
- y={annotationY}
866
+ x={displayedAnnotationX}
867
+ y={displayedAnnotationY}
382
868
  stroke={APP_FONT_COLOR}
383
869
  markerWidth={12}
384
870
  size={10}
@@ -391,16 +877,16 @@ const Annotations = ({
391
877
  <>
392
878
  <circle
393
879
  fill='white'
394
- cx={annotationX + scaledDx}
395
- cy={annotationY + scaledDy}
880
+ cx={displayedAnnotationX + connectorDx}
881
+ cy={displayedAnnotationY + connectorDy}
396
882
  r={12}
397
883
  className='annotation__mobile-label annotation__mobile-label-circle'
398
884
  stroke={APP_FONT_COLOR}
399
885
  />
400
886
  <text
401
887
  height={16}
402
- x={annotationX + scaledDx}
403
- y={annotationY + scaledDy + 1}
888
+ x={displayedAnnotationX + connectorDx}
889
+ y={displayedAnnotationY + connectorDy + 1}
404
890
  fontSize={14}
405
891
  className='annotation__mobile-label'
406
892
  alignmentBaseline='middle'
@@ -412,7 +898,21 @@ const Annotations = ({
412
898
  )}
413
899
  </>
414
900
  )}
415
- </AnnotationComponent>
901
+ {isEditor && annotation.edit.subject && (isEventLine || annotation.connectionType !== 'none') && (
902
+ <circle
903
+ cx={displayedAnnotationX}
904
+ cy={displayedAnnotationY}
905
+ r={15}
906
+ fill='transparent'
907
+ stroke='red'
908
+ strokeDasharray='4,2'
909
+ strokeWidth={2}
910
+ cursor={draggingSubjectIndex === annotationIndex ? 'grabbing' : 'grab'}
911
+ data-testid='annotation-subject-drag-handle'
912
+ onPointerDown={startSubjectDrag}
913
+ />
914
+ )}
915
+ </VisxAnnotation>
416
916
  )
417
917
  })
418
918
  )