@cdc/chart 4.26.5 → 4.26.7
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.
- package/CONFIG.md +20 -14
- package/dist/{cdcchart-DQ00cQCm.es.js → cdcchart-BWVIUcAF.es.js} +5 -55
- package/dist/cdcchart.js +36554 -34320
- package/examples/feature/heatmap/average-age-categorical.json +1 -1
- package/examples/feature/heatmap/calendar-heatmap.json +1 -1
- package/examples/feature/heatmap/sparse-aggregation.json +1 -1
- package/examples/private/bad-y-axis.json +543 -0
- package/examples/private/bar-chart-negative-value.json +455 -0
- package/examples/private/flueview.json +22051 -0
- package/package.json +3 -11
- package/src/CdcChart.tsx +7 -6
- package/src/CdcChartComponent.tsx +71 -18
- package/src/_stories/Chart.PieLabels.stories.tsx +47 -0
- package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +183 -0
- package/src/_stories/Chart.YAxisAutoMaxStrategy.stories.tsx +52 -0
- package/src/_stories/Chart.YAxisFilterDomain.stories.tsx +90 -0
- package/src/_stories/Chart.smoke.stories.tsx +14 -1
- package/src/_stories/ChartAnnotation.smoke.stories.tsx +10 -0
- package/src/_stories/ChartPrefixSuffix.smoke.stories.tsx +28 -1
- package/src/_stories/_mock/annotation_event-line_mock.json +1064 -0
- package/src/_stories/_mock/heatmap-average-age-categorical.json +1 -1
- package/src/_stories/_mock/heatmap.json +1 -1
- package/src/components/Annotations/components/AnnotationDraggable.styles.css +22 -1
- package/src/components/Annotations/components/AnnotationDraggable.tsx +219 -119
- package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +198 -0
- package/src/components/Axis/LeftAxis.tsx +3 -0
- package/src/components/Axis/LeftAxisGridlines.tsx +8 -1
- package/src/components/Axis/RightAxis.tsx +3 -0
- package/src/components/Axis/tests/AxisTickValues.test.tsx +86 -0
- package/src/components/BarChart/components/BarChart.Horizontal.tsx +1 -2
- package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +1 -1
- package/src/components/BarChart/components/BarChart.StackedVertical.tsx +1 -1
- package/src/components/BarChart/components/BarChart.Vertical.tsx +1 -2
- package/src/components/BarChart/helpers/getBarData.ts +28 -28
- package/src/components/BarChart/helpers/getBarHeights.ts +2 -2
- package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +98 -0
- package/src/components/BarChart/helpers/useBarChart.ts +4 -5
- package/src/components/BoxPlot/BoxPlot.Horizontal.tsx +7 -6
- package/src/components/BoxPlot/BoxPlot.Vertical.tsx +9 -7
- package/src/components/BoxPlot/helpers/index.ts +4 -3
- package/src/components/Brush/BrushSelector.tsx +1 -1
- package/src/components/EditorPanel/EditorPanel.tsx +457 -165
- package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +101 -52
- package/src/components/EditorPanel/components/Panels/Panel.General.tsx +16 -7
- package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +3 -3
- package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +19 -4
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.test.tsx +98 -0
- package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +10 -0
- package/src/components/EditorPanel/components/Panels/Panel.Visual.test.tsx +484 -0
- package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +3 -0
- package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +262 -0
- package/src/components/EditorPanel/components/Panels/panelVisual.styles.css +32 -0
- package/src/components/EditorPanel/components/panels.scss +10 -0
- package/src/components/EditorPanel/editor-panel.scss +47 -0
- package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +6 -2
- package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +20 -2
- package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +3 -3
- package/src/components/EditorPanel/useEditorPermissions.test.tsx +288 -11
- package/src/components/EditorPanel/useEditorPermissions.ts +46 -6
- package/src/components/Forecasting/Forecasting.tsx +1 -1
- package/src/components/HeatMap/components/HeatMap.tsx +22 -17
- package/src/components/HeatMap/components/HeatMapGradientLegend.tsx +29 -15
- package/src/components/HeatMap/heatmap.css +15 -1
- package/src/components/HeatMap/tests/HeatMap.test.tsx +39 -7
- package/src/components/Legend/Legend.Component.tsx +56 -61
- package/src/components/Legend/Legend.Suppression.tsx +3 -3
- package/src/components/Legend/LegendGroup/LegendGroup.tsx +0 -1
- package/src/components/Legend/helpers/createFormatLabels.test.ts +88 -0
- package/src/components/Legend/helpers/createFormatLabels.tsx +6 -1
- package/src/components/LineChart/helpers.test.ts +230 -1
- package/src/components/LineChart/helpers.ts +25 -14
- package/src/components/LineChart/index.tsx +23 -7
- package/src/components/LinearChart/tests/LinearChart.test.tsx +30 -0
- package/src/components/LinearChart.tsx +51 -15
- package/src/components/PieChart/PieChart.tsx +117 -10
- package/src/components/PieChart/helpers/labelPlacement.test.ts +88 -0
- package/src/components/PieChart/helpers/labelPlacement.ts +95 -0
- package/src/components/ScatterPlot/ScatterPlot.jsx +5 -13
- package/src/components/SmallMultiples/SmallMultipleTile.tsx +3 -0
- package/src/components/SmallMultiples/SmallMultiples.tsx +38 -9
- package/src/components/SmallMultiples/tests/SmallMultiples.test.tsx +170 -0
- package/src/data/initial-state.js +3 -0
- package/src/helpers/calculateLeftYAxisWidth.ts +9 -3
- package/src/helpers/categoryOrder.ts +60 -0
- package/src/helpers/colorAssignmentHelpers.ts +102 -0
- package/src/helpers/dataHelpers.ts +11 -1
- package/src/helpers/getAxisMaxOverride.ts +16 -0
- package/src/helpers/getBoxPlotConfig.ts +19 -12
- package/src/helpers/getCleanTopTickMax.ts +64 -0
- package/src/helpers/getColorScale.ts +18 -9
- package/src/helpers/getComboChartConfig.ts +0 -1
- package/src/helpers/getExcludedData.ts +0 -1
- package/src/helpers/getHasBoundarySuppression.ts +27 -0
- package/src/helpers/getMinMax.ts +25 -22
- package/src/helpers/getNewRuntime.ts +0 -2
- package/src/helpers/getPiePercent.ts +3 -3
- package/src/helpers/getYAxisDomainData.ts +38 -0
- package/src/helpers/getYAxisFinalizationEligibility.ts +40 -0
- package/src/helpers/hasSpacedInlineLabel.ts +10 -0
- package/src/helpers/isConvertLineToBarGraph.ts +2 -2
- package/src/helpers/seriesColumnSettings.ts +26 -7
- package/src/helpers/sizeHelpers.ts +0 -2
- package/src/helpers/smallMultiplesHelpers.ts +5 -0
- package/src/helpers/tests/calculateLeftYAxisWidth.test.ts +38 -0
- package/src/helpers/tests/categoryOrder.test.ts +57 -0
- package/src/helpers/tests/dataHelpers.test.ts +135 -0
- package/src/helpers/tests/getAxisMaxOverride.test.ts +36 -0
- package/src/helpers/tests/getCleanTopTickMax.test.ts +165 -0
- package/src/helpers/tests/getColorScale.test.ts +197 -0
- package/src/helpers/tests/getMinMax.test.ts +211 -0
- package/src/helpers/tests/getYAxisDomainData.test.ts +119 -0
- package/src/helpers/tests/getYAxisFinalizationEligibility.test.ts +185 -0
- package/src/helpers/tests/{getYAxisAutoPaddingMode.test.ts → hasSpacedInlineLabel.test.ts} +17 -32
- package/src/helpers/tests/seriesColumnSettings.test.ts +76 -2
- package/src/helpers/tests/smallMultiplesHelpers.test.ts +99 -0
- package/src/hooks/tests/useRightAxis.test.tsx +268 -0
- package/src/hooks/tests/useScales.test.tsx +302 -27
- package/src/hooks/tests/useTooltip.test.tsx +132 -0
- package/src/hooks/useRightAxis.ts +35 -25
- package/src/hooks/useScales.ts +45 -28
- package/src/hooks/useTooltip.tsx +17 -11
- package/src/store/chart.reducer.ts +0 -1
- package/src/test/CdcChart.dataTable.test.tsx +216 -0
- package/src/types/ChartConfig.ts +6 -1
- package/src/types/ChartContext.ts +3 -0
- package/src/helpers/getYAxisAutoPadding.ts +0 -55
- package/src/helpers/getYAxisAutoPaddingMode.ts +0 -17
- package/src/helpers/tests/getYAxisAutoPadding.test.ts +0 -37
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
.cove-visualization.type-chart .annotation__desktop-label {
|
|
2
|
-
font-size
|
|
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
|
-
//
|
|
100
|
-
const annotationSeries =
|
|
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
|
-
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
125
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
{
|
|
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
|
-
<
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
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
|
+
})
|