@cdc/chart 4.26.5 → 4.26.6

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 (91) hide show
  1. package/CONFIG.md +12 -8
  2. package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
  3. package/dist/cdcchart.js +34093 -32767
  4. package/examples/feature/heatmap/average-age-categorical.json +1 -1
  5. package/examples/feature/heatmap/calendar-heatmap.json +1 -1
  6. package/examples/feature/heatmap/sparse-aggregation.json +1 -1
  7. package/examples/private/bad-y-axis.json +543 -0
  8. package/examples/private/bar-chart-negative-value.json +455 -0
  9. package/package.json +3 -3
  10. package/src/CdcChart.tsx +5 -4
  11. package/src/CdcChartComponent.tsx +32 -9
  12. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
  13. package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
  14. package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
  15. package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
  16. package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
  17. package/src/_stories/_mock/heatmap-average-age-categorical.json +1 -1
  18. package/src/_stories/_mock/heatmap.json +1 -1
  19. package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
  20. package/src/components/Annotations/components/AnnotationDraggable.tsx +219 -119
  21. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
  22. package/src/components/Axis/LeftAxis.tsx +3 -0
  23. package/src/components/Axis/LeftAxisGridlines.tsx +8 -1
  24. package/src/components/Axis/RightAxis.tsx +3 -0
  25. package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
  26. package/src/components/EditorPanel/EditorPanel.tsx +367 -136
  27. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +101 -52
  28. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
  29. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
  30. package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
  31. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +3 -0
  32. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
  33. package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
  34. package/src/components/EditorPanel/components/panels.scss +10 -0
  35. package/src/components/EditorPanel/editor-panel.scss +47 -0
  36. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +6 -2
  37. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +20 -2
  38. package/src/components/EditorPanel/useEditorPermissions.test.tsx +256 -11
  39. package/src/components/EditorPanel/useEditorPermissions.ts +41 -5
  40. package/src/components/HeatMap/components/HeatMap.tsx +22 -17
  41. package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +29 -15
  42. package/src/components/HeatMap/heatmap.css +15 -1
  43. package/src/components/HeatMap/tests/HeatMap.test.tsx +39 -7
  44. package/src/components/Legend/Legend.Component.tsx +2 -2
  45. package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
  46. package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
  47. package/src/components/LineChart/helpers.test.ts +230 -1
  48. package/src/components/LineChart/helpers.ts +26 -13
  49. package/src/components/LineChart/index.tsx +21 -4
  50. package/src/components/LinearChart/tests/LinearChart.test.tsx +30 -0
  51. package/src/components/LinearChart.tsx +50 -15
  52. package/src/components/ScatterPlot/ScatterPlot.jsx +3 -3
  53. package/src/components/SmallMultiples/SmallMultipleTile.tsx +3 -0
  54. package/src/components/SmallMultiples/SmallMultiples.tsx +38 -9
  55. package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
  56. package/src/data/initial-state.js +1 -0
  57. package/src/helpers/calculateLeftYAxisWidth.ts +9 -3
  58. package/src/helpers/colorAssignmentHelpers.ts +102 -0
  59. package/src/helpers/dataHelpers.ts +11 -1
  60. package/src/helpers/getAxisMaxOverride.ts +16 -0
  61. package/src/helpers/getCleanTopTickMax.ts +64 -0
  62. package/src/helpers/getColorScale.ts +18 -9
  63. package/src/helpers/getHasBoundarySuppression.ts +26 -0
  64. package/src/helpers/getMinMax.ts +24 -22
  65. package/src/helpers/getYAxisDomainData.ts +38 -0
  66. package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
  67. package/src/helpers/hasSpacedInlineLabel.ts +10 -0
  68. package/src/helpers/seriesColumnSettings.ts +5 -4
  69. package/src/helpers/smallMultiplesHelpers.ts +5 -0
  70. package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
  71. package/src/helpers/tests/dataHelpers.test.ts +135 -0
  72. package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
  73. package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
  74. package/src/helpers/tests/getColorScale.test.ts +197 -0
  75. package/src/helpers/tests/getMinMax.test.ts +211 -0
  76. package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
  77. package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
  78. package/src/helpers/tests/{getYAxisAutoPaddingMode.test.ts → hasSpacedInlineLabel.test.ts} +17 -32
  79. package/src/helpers/tests/seriesColumnSettings.test.ts +16 -2
  80. package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
  81. package/src/hooks/tests/useRightAxis.test.tsx +127 -0
  82. package/src/hooks/tests/useScales.test.tsx +307 -29
  83. package/src/hooks/useRightAxis.ts +30 -13
  84. package/src/hooks/useScales.ts +45 -28
  85. package/src/hooks/useTooltip.tsx +3 -1
  86. package/src/test/CdcChart.dataTable.test.tsx +122 -0
  87. package/src/types/ChartConfig.ts +6 -1
  88. package/src/types/ChartContext.ts +3 -0
  89. package/src/helpers/getYAxisAutoPadding.ts +0 -55
  90. package/src/helpers/getYAxisAutoPaddingMode.ts +0 -17
  91. package/src/helpers/tests/getYAxisAutoPadding.test.ts +0 -37
@@ -277,7 +277,7 @@
277
277
  "hide": false,
278
278
  "position": "top",
279
279
  "style": "gradient",
280
- "subStyle": "smooth",
280
+ "subStyle": "linear blocks",
281
281
  "label": "Average age",
282
282
  "hideBorder": false
283
283
  },
@@ -117,7 +117,7 @@
117
117
  "hide": false,
118
118
  "position": "top",
119
119
  "style": "gradient",
120
- "subStyle": "smooth",
120
+ "subStyle": "linear blocks",
121
121
  "label": "Cases per 100,000",
122
122
  "hideBorder": false
123
123
  },
@@ -1,5 +1,5 @@
1
1
  .cove-visualization.type-chart .annotation__desktop-label {
2
- font-size: 16px;
2
+ /* font-size is applied inline so it stays in sync with the axis tick label size and scales by viewport. */
3
3
  line-height: 1.1rem;
4
4
  }
5
5
 
@@ -22,4 +22,25 @@
22
22
  text-align: center;
23
23
  width: 24px;
24
24
  }
25
+
26
+ .annotation__event-line {
27
+ pointer-events: none;
28
+ }
29
+
30
+ .annotation__event-line-label {
31
+ max-width: 186px;
32
+ width: fit-content;
33
+ }
34
+
35
+ /* Label right of the line. */
36
+ .cove-annotation-event-line__label--right {
37
+ padding-left: 0.5rem;
38
+ text-align: left;
39
+ }
40
+
41
+ /* Label left of the line. */
42
+ .cove-annotation-event-line__label--left {
43
+ padding-right: 0.5rem;
44
+ text-align: right;
45
+ }
25
46
  }
@@ -2,7 +2,7 @@ import { useContext, 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'
5
- import { isMobileAnnotationViewport } from '@cdc/core/helpers/viewports'
5
+ import { isMobileAnnotationViewport, isMobileFontViewport } from '@cdc/core/helpers/viewports'
6
6
 
7
7
  // helpers
8
8
  import { findNearestDatum } from './findNearestDatum'
@@ -15,6 +15,13 @@ import { LinePath } from '@visx/shape'
15
15
  // styles
16
16
  import './AnnotationDraggable.styles.css'
17
17
 
18
+ export const EVENT_LINE_LABEL_OFFSET = 2
19
+ export const snapEventLineDx = (dx: number) => (dx >= 0 ? EVENT_LINE_LABEL_OFFSET : -EVENT_LINE_LABEL_OFFSET)
20
+
21
+ // Keep annotation label text in sync with axis tick labels (see LinearChart.tsx).
22
+ const TICK_LABEL_FONT_SIZE = 16
23
+ const TICK_LABEL_FONT_SIZE_SMALL = 13
24
+
18
25
  const Annotations = ({
19
26
  xScale,
20
27
  yScale,
@@ -36,6 +43,9 @@ const Annotations = ({
36
43
  const AnnotationComponent = isEditor ? EditableAnnotation : VisxAnnotation
37
44
  const isMobile = isMobileAnnotationViewport(currentViewport) && config?.general?.mobileAnnotationDisplay !== 'text'
38
45
 
46
+ // 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
48
+
39
49
  /**
40
50
  * Scale dx/dy offsets based on savedDimensions vs current dimensions.
41
51
  * This ensures label positions scale proportionally when chart is resized.
@@ -74,9 +84,11 @@ const Annotations = ({
74
84
  visibleAnnotations.map((annotation, annotationIndex) => {
75
85
  const originalIndex = annotations.indexOf(annotation)
76
86
  const text = annotation.text || ''
87
+ const isEventLine = annotation.style === 'event-line'
77
88
 
78
89
  // Calculate scaled dx/dy offsets based on savedDimensions
79
- const { scaledDx, scaledDy } = getScaledOffsets(annotation)
90
+ const { scaledDx: rawScaledDx, scaledDy } = getScaledOffsets(annotation)
91
+ const scaledDx = isEventLine ? snapEventLineDx(rawScaledDx) : rawScaledDx
80
92
 
81
93
  // Default to absolute positioning
82
94
  let annotationX = xScaleAnnotation(annotation.x)
@@ -88,19 +100,22 @@ const Annotations = ({
88
100
  const dataPoint = dataSource.find(d => d[config.xAxis.dataKey] === annotation.dataX)
89
101
 
90
102
  if (dataPoint) {
91
- const dataYValue = dataPoint[annotation.seriesKey]
92
-
93
103
  // For date/date-time axes, convert raw value to timestamp for scale
94
104
  let xScaleInput = annotation.dataX
95
105
  if (config.xAxis.type === 'date' || config.xAxis.type === 'date-time') {
96
106
  xScaleInput = parseDate(xScaleInput, false)?.getTime()
97
107
  }
98
108
 
99
- // Check if this annotation's series is a bar in a grouped bar situation
100
- const annotationSeries = config.series?.find(s => s.dataKey === annotation.seriesKey)
109
+ // Event-line annotations are series-agnostic; treat as single (non-grouped) anchor.
110
+ const annotationSeries = isEventLine
111
+ ? undefined
112
+ : config.series?.find(s => s.dataKey === annotation.seriesKey)
101
113
  const barSeriesCount = config.series?.filter(s => s.type === 'Bar').length || 0
102
114
  const isGroupedBarAnnotation =
103
- annotationSeries?.type === 'Bar' && barSeriesCount > 1 && config.visualizationSubType !== 'stacked'
115
+ !isEventLine &&
116
+ annotationSeries?.type === 'Bar' &&
117
+ barSeriesCount > 1 &&
118
+ config.visualizationSubType !== 'stacked'
104
119
 
105
120
  if (isGroupedBarAnnotation && seriesScale) {
106
121
  // Position at group start + series offset + half series bar width
@@ -112,17 +127,23 @@ const Annotations = ({
112
127
  annotationX = xScale(xScaleInput) + (xScale.bandwidth?.() / 2 || 0)
113
128
  }
114
129
 
115
- // Adjust X for arrow markers based on label direction
116
- if (annotation.marker === 'arrow' && Math.abs(annotation.dx) >= 100) {
117
- const direction = annotation.dx > 0 ? 1 : -1
118
- const relevantBandwidth =
119
- isGroupedBarAnnotation && seriesScale ? seriesScale.bandwidth?.() : xScale.bandwidth?.()
120
- const nudgeAmount = relevantBandwidth ? relevantBandwidth / 6 : 2
121
- annotationX += direction * nudgeAmount
122
- }
130
+ if (isEventLine) {
131
+ // Anchor visx subject at the plot's vertical middle; the line spans full height.
132
+ annotationY = yMax / 2
133
+ } else {
134
+ // Adjust X for arrow markers based on label direction
135
+ if (annotation.marker === 'arrow' && Math.abs(annotation.dx) >= 100) {
136
+ const direction = annotation.dx > 0 ? 1 : -1
137
+ const relevantBandwidth =
138
+ isGroupedBarAnnotation && seriesScale ? seriesScale.bandwidth?.() : xScale.bandwidth?.()
139
+ const nudgeAmount = relevantBandwidth ? relevantBandwidth / 6 : 2
140
+ annotationX += direction * nudgeAmount
141
+ }
123
142
 
124
- // Y position based on marker type
125
- annotationY = yScale(dataYValue) - (annotation.marker === 'circle' ? 0 : 5)
143
+ // Y position based on marker type
144
+ const dataYValue = dataPoint[annotation.seriesKey]
145
+ annotationY = yScale(dataYValue) - (annotation.marker === 'circle' ? 0 : 5)
146
+ }
126
147
  }
127
148
  }
128
149
 
@@ -141,7 +162,7 @@ const Annotations = ({
141
162
  x={annotationX}
142
163
  y={annotationY}
143
164
  canEditLabel={annotation.edit.label || false}
144
- canEditSubject={(annotation.edit.subject && annotation.connectionType !== 'none') || false}
165
+ canEditSubject={(annotation.edit.subject && (isEventLine || annotation.connectionType !== 'none')) || false}
145
166
  labelDragHandleProps={{ r: 15, stroke: 'red' }}
146
167
  subjectDragHandleProps={{ r: 15, stroke: 'red' }}
147
168
  onDragStart={() => {
@@ -149,7 +170,8 @@ const Annotations = ({
149
170
  setLiveDrag({ index: annotationIndex, dx: scaledDx })
150
171
  }}
151
172
  onDragMove={props => {
152
- setLiveDrag({ index: annotationIndex, dx: props.dx })
173
+ const previewDx = isEventLine ? snapEventLineDx(props.dx) : props.dx
174
+ setLiveDrag({ index: annotationIndex, dx: previewDx })
153
175
  }}
154
176
  onDragEnd={props => {
155
177
  onDragStateChange(false)
@@ -166,9 +188,11 @@ const Annotations = ({
166
188
  : annotation.x === xScaleAnnotation.invert(props.x) && annotation.y === yScaleAnnotation.invert(props.y)
167
189
 
168
190
  if (isLabelOnlyDrag) {
191
+ // Event-line snaps dx; callout persists raw drag offset.
192
+ const persistedDx = isEventLine ? snapEventLineDx(props.dx) : props.dx
169
193
  updatedAnnotations[originalIndex] = {
170
194
  ...updatedAnnotations[originalIndex],
171
- dx: props.dx,
195
+ dx: persistedDx,
172
196
  dy: props.dy,
173
197
  savedDimensions: currentDimensions
174
198
  }
@@ -209,107 +233,183 @@ const Annotations = ({
209
233
  })
210
234
  }}
211
235
  >
212
- {!isMobile &&
213
- (() => {
214
- const labelWidth = config.general.showAnnotationDropdown ? 186 : 150
215
- // Use live dx during drag (already in current space), otherwise use scaled dx
216
- const currentDx = liveDrag?.index === annotationIndex ? liveDrag.dx : scaledDx
217
- const { horizontalAnchor, verticalAnchor } = getAnnotationAnchors(annotationX, currentDx, labelWidth)
218
- return (
219
- <HtmlLabel
220
- className='annotation__desktop-label'
221
- containerStyle={{ width: 'fit-content', maxWidth: `${labelWidth}px` }}
222
- horizontalAnchor={horizontalAnchor}
223
- verticalAnchor={verticalAnchor}
224
- showAnchorLine={false}
225
- >
226
- <div
227
- style={{
228
- borderRadius: 5, // Optional: set border radius
229
- backgroundColor: `rgba(255, 255, 255, ${
230
- annotation?.opacity ? Number(annotation?.opacity) / 100 : 1
231
- })`,
232
- padding: '10px',
233
- width: 'auto',
234
- display: config.general.showAnnotationDropdown ? 'inline-flex' : 'flex',
235
- justifyContent: 'start',
236
- flexDirection: 'row',
237
- alignItems: 'center'
238
- }}
239
- // role='presentation'
240
- tabIndex={0}
241
- aria-label={`Annotation text that reads: ${annotation.text}`}
242
- >
243
- {config?.general?.showAnnotationDropdown && (
244
- <>
245
- <p
246
- className='annotation__has-dropdown-number'
247
- style={{ margin: '2px 6px', position: 'relative', left: '-4px' }}
248
- >
249
- {originalIndex + 1}
250
- </p>
251
- </>
252
- )}
253
- <div dangerouslySetInnerHTML={sanitizedData()} />
254
- </div>
255
- </HtmlLabel>
256
- )
257
- })()}
258
- {annotation.connectionType === 'line' && (
259
- <Connector type='line' pathProps={{ markerStart: `url(#marker-start--${originalIndex})` }} />
260
- )}
261
- {annotation.connectionType === 'elbow' && (
262
- <Connector type='elbow' pathProps={{ markerStart: `url(#marker-start--${originalIndex})` }} />
263
- )}
264
- {annotation.connectionType === 'curve' && (
265
- <LinePath
266
- d={`M ${annotationX},${annotationY}
267
- Q ${annotationX + scaledDx / 2}, ${
268
- annotationY + scaledDy / 2 + Number(annotation?.bezier) || 0
269
- } ${annotationX + scaledDx},${annotationY + scaledDy}`}
270
- stroke={APP_FONT_COLOR}
271
- fill='none'
272
- marker-start={`url(#marker-start--${originalIndex})`}
273
- />
274
- )}
275
- {annotation.marker === 'circle' && (
276
- <CircleSubject className='circle-subject' stroke={APP_FONT_COLOR} radius={8} />
277
- )}
278
- {annotation.marker === 'arrow' && (
279
- <MarkerArrow
280
- fill={APP_FONT_COLOR}
281
- id={`marker-start--${originalIndex}`}
282
- x={annotationX}
283
- y={annotationY}
284
- stroke={APP_FONT_COLOR}
285
- markerWidth={12}
286
- size={10}
287
- strokeWidth={1}
288
- orient='auto-start-reverse'
289
- markerUnits='userSpaceOnUse'
290
- />
291
- )}
292
- {isMobile && (
236
+ {isEventLine ? (
293
237
  <>
294
- <circle
295
- fill='white'
296
- cx={annotationX + scaledDx}
297
- cy={annotationY + scaledDy}
298
- r={12}
299
- className='annotation__mobile-label annotation__mobile-label-circle'
300
- stroke={APP_FONT_COLOR}
238
+ <line
239
+ x1={annotationX}
240
+ x2={annotationX}
241
+ y1={0}
242
+ y2={yMax}
243
+ stroke='#333'
244
+ strokeWidth={1}
245
+ className='annotation__event-line'
246
+ pointerEvents='none'
301
247
  />
302
- <text
303
- height={16}
304
- x={annotationX + scaledDx}
305
- y={annotationY + scaledDy + 1}
306
- fontSize={14}
307
- className='annotation__mobile-label'
308
- alignmentBaseline='middle'
309
- textAnchor='middle'
310
- >
311
- {originalIndex + 1}
312
- </text>
248
+ {!isMobile &&
249
+ (() => {
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
253
+ return (
254
+ <HtmlLabel
255
+ className='annotation__desktop-label'
256
+ containerStyle={{ width: 'fit-content', maxWidth: '186px' }}
257
+ horizontalAnchor={onRight ? 'start' : 'end'}
258
+ verticalAnchor='middle'
259
+ showAnchorLine={false}
260
+ >
261
+ <div
262
+ className={`annotation__event-line-label ${
263
+ onRight
264
+ ? 'cove-annotation-event-line__label--right'
265
+ : 'cove-annotation-event-line__label--left'
266
+ }`}
267
+ style={{
268
+ backgroundColor: `rgba(255, 255, 255, ${
269
+ annotation?.opacity ? Number(annotation?.opacity) / 100 : 1
270
+ })`,
271
+ padding: '6px 8px',
272
+ color: annotation.colors?.label || APP_FONT_COLOR,
273
+ fontSize: tickLabelFontSize
274
+ }}
275
+ tabIndex={0}
276
+ aria-label={`Annotation text that reads: ${annotation.text}`}
277
+ >
278
+ <div dangerouslySetInnerHTML={sanitizedData()} />
279
+ </div>
280
+ </HtmlLabel>
281
+ )
282
+ })()}
283
+ {isMobile && (
284
+ <>
285
+ <circle
286
+ fill='white'
287
+ cx={annotationX + scaledDx}
288
+ cy={annotationY + scaledDy}
289
+ r={12}
290
+ className='annotation__mobile-label annotation__mobile-label-circle'
291
+ stroke={APP_FONT_COLOR}
292
+ />
293
+ <text
294
+ height={16}
295
+ x={annotationX + scaledDx}
296
+ y={annotationY + scaledDy + 1}
297
+ fontSize={14}
298
+ className='annotation__mobile-label'
299
+ alignmentBaseline='middle'
300
+ textAnchor='middle'
301
+ >
302
+ {originalIndex + 1}
303
+ </text>
304
+ </>
305
+ )}
306
+ </>
307
+ ) : (
308
+ <>
309
+ {!isMobile &&
310
+ (() => {
311
+ const labelWidth = config.general.showAnnotationDropdown ? 186 : 150
312
+ // 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)
315
+ return (
316
+ <HtmlLabel
317
+ className='annotation__desktop-label'
318
+ containerStyle={{ width: 'fit-content', maxWidth: `${labelWidth}px` }}
319
+ horizontalAnchor={horizontalAnchor}
320
+ verticalAnchor={verticalAnchor}
321
+ showAnchorLine={false}
322
+ >
323
+ <div
324
+ style={{
325
+ borderRadius: 5, // Optional: set border radius
326
+ backgroundColor: `rgba(255, 255, 255, ${
327
+ annotation?.opacity ? Number(annotation?.opacity) / 100 : 1
328
+ })`,
329
+ padding: '10px',
330
+ width: 'auto',
331
+ display: config.general.showAnnotationDropdown ? 'inline-flex' : 'flex',
332
+ justifyContent: 'start',
333
+ flexDirection: 'row',
334
+ alignItems: 'center',
335
+ fontSize: tickLabelFontSize
336
+ }}
337
+ // role='presentation'
338
+ tabIndex={0}
339
+ aria-label={`Annotation text that reads: ${annotation.text}`}
340
+ >
341
+ {config?.general?.showAnnotationDropdown && (
342
+ <>
343
+ <p
344
+ className='annotation__has-dropdown-number'
345
+ style={{ margin: '2px 6px', position: 'relative', left: '-4px' }}
346
+ >
347
+ {originalIndex + 1}
348
+ </p>
349
+ </>
350
+ )}
351
+ <div dangerouslySetInnerHTML={sanitizedData()} />
352
+ </div>
353
+ </HtmlLabel>
354
+ )
355
+ })()}
356
+ {annotation.connectionType === 'line' && (
357
+ <Connector type='line' pathProps={{ markerStart: `url(#marker-start--${originalIndex})` }} />
358
+ )}
359
+ {annotation.connectionType === 'elbow' && (
360
+ <Connector type='elbow' pathProps={{ markerStart: `url(#marker-start--${originalIndex})` }} />
361
+ )}
362
+ {annotation.connectionType === 'curve' && (
363
+ <LinePath
364
+ d={`M ${annotationX},${annotationY}
365
+ Q ${annotationX + scaledDx / 2}, ${
366
+ annotationY + scaledDy / 2 + Number(annotation?.bezier) || 0
367
+ } ${annotationX + scaledDx},${annotationY + scaledDy}`}
368
+ stroke={APP_FONT_COLOR}
369
+ fill='none'
370
+ marker-start={`url(#marker-start--${originalIndex})`}
371
+ />
372
+ )}
373
+ {annotation.marker === 'circle' && (
374
+ <CircleSubject className='circle-subject' stroke={APP_FONT_COLOR} radius={8} />
375
+ )}
376
+ {annotation.marker === 'arrow' && (
377
+ <MarkerArrow
378
+ fill={APP_FONT_COLOR}
379
+ id={`marker-start--${originalIndex}`}
380
+ x={annotationX}
381
+ y={annotationY}
382
+ stroke={APP_FONT_COLOR}
383
+ markerWidth={12}
384
+ size={10}
385
+ strokeWidth={1}
386
+ orient='auto-start-reverse'
387
+ markerUnits='userSpaceOnUse'
388
+ />
389
+ )}
390
+ {isMobile && (
391
+ <>
392
+ <circle
393
+ fill='white'
394
+ cx={annotationX + scaledDx}
395
+ cy={annotationY + scaledDy}
396
+ r={12}
397
+ className='annotation__mobile-label annotation__mobile-label-circle'
398
+ stroke={APP_FONT_COLOR}
399
+ />
400
+ <text
401
+ height={16}
402
+ x={annotationX + scaledDx}
403
+ y={annotationY + scaledDy + 1}
404
+ fontSize={14}
405
+ className='annotation__mobile-label'
406
+ alignmentBaseline='middle'
407
+ textAnchor='middle'
408
+ >
409
+ {originalIndex + 1}
410
+ </text>
411
+ </>
412
+ )}
313
413
  </>
314
414
  )}
315
415
  </AnnotationComponent>
@@ -0,0 +1,198 @@
1
+ import React from 'react'
2
+ import { render } from '@testing-library/react'
3
+ import { describe, expect, it, beforeAll, vi } from 'vitest'
4
+ import { scaleBand, scaleLinear } from '@visx/scale'
5
+ import AnnotationDraggable, { EVENT_LINE_LABEL_OFFSET, snapEventLineDx } from '../components/AnnotationDraggable'
6
+ import ConfigContext from '../../../ConfigContext'
7
+ import { createMockChartContext } from '../../LinearChart/tests/mockConfigContext'
8
+
9
+ // jsdom compat for visx (ResizeObserver + SVG bbox).
10
+ vi.stubGlobal(
11
+ 'ResizeObserver',
12
+ vi.fn(() => ({
13
+ observe: vi.fn(),
14
+ unobserve: vi.fn(),
15
+ disconnect: vi.fn()
16
+ }))
17
+ )
18
+
19
+ beforeAll(() => {
20
+ const mockBBox = { x: 0, y: 0, width: 100, height: 20 }
21
+ // @ts-expect-error mocking SVG method
22
+ SVGElement.prototype.getBBox = vi.fn(() => mockBBox)
23
+ // @ts-expect-error mocking SVG method
24
+ SVGElement.prototype.getBoundingClientRect = vi.fn(() => ({
25
+ x: 0,
26
+ y: 0,
27
+ width: 100,
28
+ height: 20,
29
+ top: 0,
30
+ left: 0,
31
+ right: 100,
32
+ bottom: 20
33
+ }))
34
+ })
35
+
36
+ const buildScales = (xMax: number, yMax: number) => ({
37
+ xScale: scaleBand({ domain: ['Jan', 'Feb'], range: [0, xMax], padding: 0 }),
38
+ yScale: scaleLinear({ domain: [0, 100], range: [yMax, 0] }),
39
+ xScaleAnnotation: scaleLinear({ domain: [0, 100], range: [0, xMax] }),
40
+ yScaleAnnotation: scaleLinear({ domain: [0, 100], range: [0, yMax] })
41
+ })
42
+
43
+ const baseEventLineAnnotation = {
44
+ text: 'Voting Rights Act',
45
+ style: 'event-line',
46
+ anchorMode: 'data',
47
+ dataX: 'Jan',
48
+ x: 50,
49
+ y: 50,
50
+ dx: 12,
51
+ dy: 0,
52
+ opacity: 100,
53
+ colors: { connector: '#444', label: '#000' },
54
+ edit: { subject: false, label: true },
55
+ anchor: { horizontal: false, vertical: false },
56
+ connectionType: 'line' as const,
57
+ marker: 'arrow' as const,
58
+ snapToSubject: false,
59
+ lineType: 'curveLinear'
60
+ }
61
+
62
+ const renderAnnotationDraggable = (annotation: any, xMax = 800, yMax = 400) => {
63
+ const data = [
64
+ { month: 'Jan', value: 50 },
65
+ { month: 'Feb', value: 75 }
66
+ ]
67
+ const context = createMockChartContext(
68
+ {
69
+ annotations: [annotation],
70
+ visualizationType: 'Line',
71
+ xAxis: { type: 'categorical', dataKey: 'month' } as any,
72
+ series: [{ dataKey: 'value', type: 'Line' }] as any,
73
+ data,
74
+ general: { showAnnotationDropdown: false, mobileAnnotationDisplay: 'symbol' } as any
75
+ } as any,
76
+ {
77
+ transformedData: data,
78
+ visibleAnnotations: [annotation]
79
+ }
80
+ )
81
+
82
+ const scales = buildScales(xMax, yMax)
83
+ return render(
84
+ <ConfigContext.Provider value={context as any}>
85
+ <svg width={xMax} height={yMax}>
86
+ <AnnotationDraggable
87
+ {...scales}
88
+ xMax={xMax}
89
+ yMax={yMax}
90
+ seriesScale={undefined}
91
+ svgRef={{ current: null } as any}
92
+ onDragStateChange={() => {}}
93
+ />
94
+ </svg>
95
+ </ConfigContext.Provider>
96
+ )
97
+ }
98
+
99
+ describe('AnnotationDraggable - event-line style', () => {
100
+ it('renders a full-height vertical line that spans 0..yMax', () => {
101
+ const { container } = renderAnnotationDraggable(baseEventLineAnnotation)
102
+
103
+ const line = container.querySelector('line.annotation__event-line')
104
+ expect(line).toBeTruthy()
105
+ expect(line?.getAttribute('y1')).toBe('0')
106
+ expect(line?.getAttribute('y2')).toBe('400')
107
+ })
108
+
109
+ it('strokes the vertical line with the axis color (#333), ignoring colors.connector', () => {
110
+ const { container } = renderAnnotationDraggable({
111
+ ...baseEventLineAnnotation,
112
+ colors: { connector: '#ff0000', label: '#000' }
113
+ })
114
+
115
+ const line = container.querySelector('line.annotation__event-line')
116
+ expect(line?.getAttribute('stroke')).toBe('#333')
117
+ })
118
+
119
+ it('uses the right-side label class (text-align left) when dx >= 0', () => {
120
+ const { container } = renderAnnotationDraggable({
121
+ ...baseEventLineAnnotation,
122
+ dx: 12
123
+ })
124
+
125
+ expect(container.querySelector('.cove-annotation-event-line__label--right')).toBeTruthy()
126
+ expect(container.querySelector('.cove-annotation-event-line__label--left')).toBeFalsy()
127
+ })
128
+
129
+ it('uses the left-side label class (text-align right) when dx < 0', () => {
130
+ const { container } = renderAnnotationDraggable({
131
+ ...baseEventLineAnnotation,
132
+ dx: -12
133
+ })
134
+
135
+ expect(container.querySelector('.cove-annotation-event-line__label--left')).toBeTruthy()
136
+ expect(container.querySelector('.cove-annotation-event-line__label--right')).toBeFalsy()
137
+ })
138
+
139
+ it('does not render a callout marker (no .circle-subject) for event-line', () => {
140
+ const { container } = renderAnnotationDraggable({
141
+ ...baseEventLineAnnotation,
142
+ marker: 'circle' as const
143
+ })
144
+
145
+ expect(container.querySelector('.circle-subject')).toBeFalsy()
146
+ })
147
+ })
148
+
149
+ describe('snapEventLineDx', () => {
150
+ it('snaps any positive value to +EVENT_LINE_LABEL_OFFSET', () => {
151
+ expect(snapEventLineDx(0)).toBe(EVENT_LINE_LABEL_OFFSET)
152
+ expect(snapEventLineDx(1)).toBe(EVENT_LINE_LABEL_OFFSET)
153
+ expect(snapEventLineDx(47)).toBe(EVENT_LINE_LABEL_OFFSET)
154
+ expect(snapEventLineDx(9999)).toBe(EVENT_LINE_LABEL_OFFSET)
155
+ })
156
+
157
+ it('snaps any negative value to -EVENT_LINE_LABEL_OFFSET', () => {
158
+ expect(snapEventLineDx(-1)).toBe(-EVENT_LINE_LABEL_OFFSET)
159
+ expect(snapEventLineDx(-47)).toBe(-EVENT_LINE_LABEL_OFFSET)
160
+ expect(snapEventLineDx(-9999)).toBe(-EVENT_LINE_LABEL_OFFSET)
161
+ })
162
+ })
163
+
164
+ describe('AnnotationDraggable - event-line dx snapping at render', () => {
165
+ it('still uses right-side class when stored dx is a large positive value', () => {
166
+ const { container } = renderAnnotationDraggable({
167
+ ...baseEventLineAnnotation,
168
+ dx: 47
169
+ })
170
+
171
+ expect(container.querySelector('.cove-annotation-event-line__label--right')).toBeTruthy()
172
+ expect(container.querySelector('.cove-annotation-event-line__label--left')).toBeFalsy()
173
+ })
174
+
175
+ it('still uses left-side class when stored dx is a large negative value', () => {
176
+ const { container } = renderAnnotationDraggable({
177
+ ...baseEventLineAnnotation,
178
+ dx: -47
179
+ })
180
+
181
+ expect(container.querySelector('.cove-annotation-event-line__label--left')).toBeTruthy()
182
+ expect(container.querySelector('.cove-annotation-event-line__label--right')).toBeFalsy()
183
+ })
184
+ })
185
+
186
+ describe('AnnotationDraggable - callout style (regression)', () => {
187
+ it('still renders a callout annotation without a full-height event line', () => {
188
+ const calloutAnnotation = {
189
+ ...baseEventLineAnnotation,
190
+ style: 'callout' as const,
191
+ anchorMode: 'fixed' as const,
192
+ dataX: undefined
193
+ }
194
+ const { container } = renderAnnotationDraggable(calloutAnnotation)
195
+
196
+ expect(container.querySelector('line.annotation__event-line')).toBeFalsy()
197
+ })
198
+ })