@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
@@ -0,0 +1,135 @@
1
+ import { prepareDendrogramData } from './prepareDendrogramData'
2
+
3
+ describe('prepareDendrogramData', () => {
4
+ const columns = { node: 'id', parent: 'parentId', style: 'linkStyle', nodeColor: 'color' }
5
+
6
+ it('prepares one row per node with optional styles and literal colors', () => {
7
+ const result = prepareDendrogramData(
8
+ [
9
+ { id: 'root', parentId: '', linkStyle: 'dashed', color: '#005eaa' },
10
+ { id: 'child', parentId: 'root', linkStyle: 'DASHED', color: 'not-a-color' },
11
+ { id: 3, parentId: 'child' }
12
+ ],
13
+ columns
14
+ )
15
+
16
+ expect(result).toEqual({
17
+ rootId: 'root',
18
+ rejectedRowCount: 0,
19
+ nodes: [
20
+ { id: 'root', parentId: null, label: 'root', style: 'dashed', color: '#005eaa' },
21
+ { id: 'child', parentId: 'root', label: 'child', style: 'dashed' },
22
+ { id: '3', parentId: 'child', label: '3', style: 'solid' }
23
+ ]
24
+ })
25
+ })
26
+
27
+ it('rejects malformed rows and non-scalar identifiers', () => {
28
+ const result = prepareDendrogramData(
29
+ [
30
+ null,
31
+ ['root'],
32
+ { id: { value: 'root' }, parentId: '' },
33
+ { id: 'root', parentId: [] },
34
+ { id: 'ok', parentId: '' }
35
+ ],
36
+ columns
37
+ )
38
+
39
+ expect(result.rejectedRowCount).toBe(4)
40
+ expect(result.nodes).toEqual([{ id: 'ok', parentId: null, label: 'ok', style: 'solid' }])
41
+ expect(result.error).toBeUndefined()
42
+ })
43
+
44
+ it('handles null mappings and deeply nested hierarchies without recursion', () => {
45
+ expect(prepareDendrogramData([{ id: 'root', parentId: '' }], null as any)).toEqual({
46
+ nodes: [],
47
+ rejectedRowCount: 1
48
+ })
49
+
50
+ const rows = Array.from({ length: 5000 }, (_, index) => ({
51
+ id: `node-${index}`,
52
+ parentId: index === 0 ? '' : `node-${index - 1}`
53
+ }))
54
+ const result = prepareDendrogramData(rows, columns)
55
+ expect(result.error).toBeUndefined()
56
+ expect(result.nodes).toHaveLength(5000)
57
+ })
58
+
59
+ it('counts duplicate node IDs as rejected rows', () => {
60
+ const result = prepareDendrogramData(
61
+ [
62
+ { id: 'root', parentId: '' },
63
+ { id: 'root', parentId: '' }
64
+ ],
65
+ columns
66
+ )
67
+
68
+ expect(result.error?.code).toBe('duplicate-node')
69
+ expect(result.rejectedRowCount).toBe(1)
70
+ })
71
+
72
+ it.each([
73
+ {
74
+ name: 'duplicate IDs',
75
+ rows: [
76
+ { id: 'root', parentId: '' },
77
+ { id: 'root', parentId: '' }
78
+ ],
79
+ code: 'duplicate-node'
80
+ },
81
+ {
82
+ name: 'multiple roots',
83
+ rows: [
84
+ { id: 'root-a', parentId: '' },
85
+ { id: 'root-b', parentId: '' }
86
+ ],
87
+ code: 'multiple-roots'
88
+ },
89
+ {
90
+ name: 'missing parents',
91
+ rows: [
92
+ { id: 'root', parentId: '' },
93
+ { id: 'child', parentId: 'missing' }
94
+ ],
95
+ code: 'missing-parent'
96
+ },
97
+ {
98
+ name: 'self-parenting',
99
+ rows: [
100
+ { id: 'root', parentId: '' },
101
+ { id: 'child', parentId: 'child' }
102
+ ],
103
+ code: 'self-parent'
104
+ },
105
+ {
106
+ name: 'cycles',
107
+ rows: [
108
+ { id: 'root', parentId: '' },
109
+ { id: 'a', parentId: 'b' },
110
+ { id: 'b', parentId: 'a' }
111
+ ],
112
+ code: 'cycle'
113
+ }
114
+ ])('returns an actionable validation error for $name', ({ rows, code }) => {
115
+ const result = prepareDendrogramData(rows, columns)
116
+
117
+ expect(result.error?.code).toBe(code)
118
+ expect(result.error?.message).toBeTruthy()
119
+ expect(result.rootId).toBeUndefined()
120
+ })
121
+
122
+ it('does not mutate source rows or config mappings', () => {
123
+ const rows = [
124
+ { id: 'root', parentId: '' },
125
+ { id: 'child', parentId: 'root' }
126
+ ]
127
+ const rowsBefore = structuredClone(rows)
128
+ const columnsBefore = structuredClone(columns)
129
+
130
+ prepareDendrogramData(rows, columns)
131
+
132
+ expect(rows).toEqual(rowsBefore)
133
+ expect(columns).toEqual(columnsBefore)
134
+ })
135
+ })
@@ -0,0 +1,166 @@
1
+ import { sanitizePaletteColor } from '@cdc/core/helpers/palettes/colorValidation'
2
+
3
+ import type {
4
+ DendrogramColumns,
5
+ DendrogramNode,
6
+ DendrogramPreparedData,
7
+ DendrogramRawRow,
8
+ DendrogramValidationError
9
+ } from '../types'
10
+
11
+ const getScalar = (row: DendrogramRawRow, column?: string): string | null => {
12
+ if (!column) return null
13
+ const value = row[column]
14
+ if (!['string', 'number'].includes(typeof value)) return null
15
+ const normalized = String(value).trim()
16
+ return normalized || null
17
+ }
18
+
19
+ const getParent = (row: DendrogramRawRow, column?: string): string | null | undefined => {
20
+ if (!column) return undefined
21
+ const value = row[column]
22
+ if (value === null || typeof value === 'undefined' || value === '') return null
23
+ if (!['string', 'number'].includes(typeof value)) return undefined
24
+ return String(value).trim() || null
25
+ }
26
+
27
+ const getError = (nodes: DendrogramNode[]): { rootId?: string; error?: DendrogramValidationError } => {
28
+ const nodesById = new Map<string, DendrogramNode>()
29
+ for (const node of nodes) {
30
+ if (nodesById.has(node.id)) {
31
+ return {
32
+ error: {
33
+ code: 'duplicate-node',
34
+ message: `Node ID “${node.id}” appears more than once. Each node must have a unique ID.`
35
+ }
36
+ }
37
+ }
38
+ nodesById.set(node.id, node)
39
+ }
40
+
41
+ const selfParent = nodes.find(node => node.parentId === node.id)
42
+ if (selfParent) {
43
+ return {
44
+ error: {
45
+ code: 'self-parent',
46
+ message: `Node “${selfParent.id}” cannot be its own parent.`
47
+ }
48
+ }
49
+ }
50
+
51
+ const missingParentNode = nodes.find(node => node.parentId !== null && !nodesById.has(node.parentId))
52
+ if (missingParentNode) {
53
+ return {
54
+ error: {
55
+ code: 'missing-parent',
56
+ message: `Node “${missingParentNode.id}” references missing parent “${missingParentNode.parentId}”.`
57
+ }
58
+ }
59
+ }
60
+
61
+ const visited = new Set<string>()
62
+ for (const node of nodes) {
63
+ if (visited.has(node.id)) continue
64
+ const path = new Set<string>()
65
+ const pathNodes: string[] = []
66
+ let current: DendrogramNode | undefined = node
67
+ while (current && !visited.has(current.id)) {
68
+ if (path.has(current.id)) {
69
+ return {
70
+ error: {
71
+ code: 'cycle',
72
+ message: `The hierarchy contains a cycle involving node “${current.id}”. Remove the circular parent relationship.`
73
+ }
74
+ }
75
+ }
76
+ path.add(current.id)
77
+ pathNodes.push(current.id)
78
+ current = current.parentId === null ? undefined : nodesById.get(current.parentId)
79
+ }
80
+ pathNodes.forEach(id => visited.add(id))
81
+ }
82
+
83
+ const roots = nodes.filter(node => node.parentId === null)
84
+ if (!roots.length) {
85
+ return {
86
+ error: {
87
+ code: 'missing-root',
88
+ message: 'The hierarchy needs one root row with a blank Parent ID.'
89
+ }
90
+ }
91
+ }
92
+ if (roots.length > 1) {
93
+ return {
94
+ error: {
95
+ code: 'multiple-roots',
96
+ message: `The hierarchy has ${roots.length} roots. Leave Parent ID blank for exactly one row.`
97
+ }
98
+ }
99
+ }
100
+
101
+ const rootId = roots[0].id
102
+ const childrenByParent = new Map<string, string[]>()
103
+ nodes.forEach(node => {
104
+ if (node.parentId === null) return
105
+ const siblings = childrenByParent.get(node.parentId)
106
+ if (siblings) siblings.push(node.id)
107
+ else childrenByParent.set(node.parentId, [node.id])
108
+ })
109
+ const reachable = new Set<string>()
110
+ const pending = [rootId]
111
+ while (pending.length) {
112
+ const id = pending.pop()!
113
+ if (reachable.has(id)) continue
114
+ reachable.add(id)
115
+ childrenByParent.get(id)?.forEach(childId => pending.push(childId))
116
+ }
117
+ if (reachable.size !== nodes.length) {
118
+ return {
119
+ error: {
120
+ code: 'disconnected',
121
+ message: 'Every node must connect to the single root. Check the Parent ID relationships.'
122
+ }
123
+ }
124
+ }
125
+
126
+ return { rootId }
127
+ }
128
+
129
+ export const prepareDendrogramData = (data: unknown, columns: DendrogramColumns = {}): DendrogramPreparedData => {
130
+ const safeColumns = columns && typeof columns === 'object' ? columns : {}
131
+ const rawRows = Array.isArray(data) ? data : []
132
+ const nodes: DendrogramNode[] = []
133
+ let rejectedRowCount = 0
134
+
135
+ rawRows.forEach(rawRow => {
136
+ if (!rawRow || typeof rawRow !== 'object' || Array.isArray(rawRow)) {
137
+ rejectedRowCount += 1
138
+ return
139
+ }
140
+
141
+ const row = rawRow as DendrogramRawRow
142
+ const id = getScalar(row, safeColumns.node)
143
+ const parentId = getParent(row, safeColumns.parent)
144
+ if (!id || typeof parentId === 'undefined') {
145
+ rejectedRowCount += 1
146
+ return
147
+ }
148
+
149
+ const style = String(safeColumns.style ? row[safeColumns.style] ?? '' : '')
150
+ .trim()
151
+ .toLowerCase()
152
+ const color = safeColumns.nodeColor ? sanitizePaletteColor(row[safeColumns.nodeColor]) : ''
153
+ nodes.push({
154
+ id,
155
+ parentId,
156
+ label: id,
157
+ style: style === 'dashed' ? 'dashed' : 'solid',
158
+ ...(color ? { color } : {})
159
+ })
160
+ })
161
+
162
+ if (!nodes.length) return { nodes, rejectedRowCount }
163
+
164
+ const duplicateCount = nodes.length - new Set(nodes.map(node => node.id)).size
165
+ return { nodes, rejectedRowCount: rejectedRowCount + duplicateCount, ...getError(nodes) }
166
+ }
@@ -0,0 +1,196 @@
1
+ import type { DendrogramLayoutLink, DendrogramLayoutNode } from '../types'
2
+ import { layoutDendrogram } from './layoutDendrogram'
3
+ import { prepareDendrogramData } from './prepareDendrogramData'
4
+ import {
5
+ estimateDendrogramLabelWidth,
6
+ resolveDendrogramLabels,
7
+ resolveDendrogramLabelsWithDiagnostics
8
+ } from './resolveDendrogramLabels'
9
+
10
+ const makeNode = (id: string, x: number, y: number, depth = 0): DendrogramLayoutNode => ({
11
+ id,
12
+ parentId: depth ? 'root' : null,
13
+ label: id,
14
+ style: 'solid',
15
+ depth,
16
+ x,
17
+ y
18
+ })
19
+
20
+ const makeLink = (source: DendrogramLayoutNode, target: DendrogramLayoutNode): DendrogramLayoutLink => ({
21
+ id: `${source.id}-${target.id}`,
22
+ source,
23
+ target,
24
+ style: 'solid'
25
+ })
26
+
27
+ describe('resolveDendrogramLabels', () => {
28
+ it('moves an internal horizontal label away from its outgoing connector', () => {
29
+ const root = makeNode('A long root label', 20, 40)
30
+ const child = makeNode('Child', 160, 40, 1)
31
+ const placements = resolveDendrogramLabels([root, child], [makeLink(root, child)], {
32
+ orientation: 'horizontal',
33
+ connectionType: 'line',
34
+ nodeRadius: 6
35
+ })
36
+
37
+ expect(placements.find(placement => placement.nodeId === root.id)?.anchor).toBe('top')
38
+ expect(placements.find(placement => placement.nodeId === child.id)?.anchor).toBe('right')
39
+ })
40
+
41
+ it('moves an internal vertical label away from its outgoing connector', () => {
42
+ const root = makeNode('A long root label', 40, 20)
43
+ const child = makeNode('Child', 40, 160, 1)
44
+ const placements = resolveDendrogramLabels([root, child], [makeLink(root, child)], {
45
+ orientation: 'vertical',
46
+ connectionType: 'curve',
47
+ nodeRadius: 6
48
+ })
49
+
50
+ expect(placements.find(placement => placement.nodeId === root.id)?.anchor).toBe('right')
51
+ expect(placements.find(placement => placement.nodeId === child.id)?.anchor).toBe('bottom')
52
+ })
53
+
54
+ it('avoids previously placed label bounds when another candidate is available', () => {
55
+ const first = makeNode('Long first label', 40, 40)
56
+ const second = makeNode('Long second label', 40, 50)
57
+ const placements = resolveDendrogramLabels([first, second], [], {
58
+ orientation: 'horizontal',
59
+ connectionType: 'elbow',
60
+ nodeRadius: 2
61
+ })
62
+
63
+ expect(placements[0].anchor).toBe('right')
64
+ expect(placements[1].anchor).not.toBe('right')
65
+ })
66
+
67
+ it('uses stable orientation priority when every candidate conflicts', () => {
68
+ const target = makeNode('Target', 100, 100)
69
+ const blockers = [
70
+ makeNode('top', 100, 75),
71
+ makeNode('right', 145, 100),
72
+ makeNode('bottom', 100, 125),
73
+ makeNode('left', 55, 100)
74
+ ]
75
+ const options = {
76
+ orientation: 'horizontal' as const,
77
+ connectionType: 'line' as const,
78
+ nodeRadius: 20,
79
+ characterWidth: 7,
80
+ labelHeight: 14,
81
+ labelGap: 0
82
+ }
83
+
84
+ const first = resolveDendrogramLabels([target, ...blockers], [], options)
85
+ const second = resolveDendrogramLabels([target, ...blockers], [], options)
86
+
87
+ expect(second).toEqual(first)
88
+ expect(first[0].anchor).toBe('right')
89
+ })
90
+
91
+ it('does not mutate layout input', () => {
92
+ const root = makeNode('Root', 20, 20)
93
+ const child = makeNode('Child', 160, 20, 1)
94
+ const nodes = [root, child]
95
+ const links = [makeLink(root, child)]
96
+ const before = structuredClone({ nodes, links })
97
+
98
+ resolveDendrogramLabels(nodes, links, {
99
+ orientation: 'horizontal',
100
+ connectionType: 'curve',
101
+ nodeRadius: 6
102
+ })
103
+
104
+ expect({ nodes, links }).toEqual(before)
105
+ })
106
+
107
+ it('uses conservative deterministic estimates for wide and non-ASCII glyphs', () => {
108
+ expect(estimateDendrogramLabelWidth('MMMM')).toBeGreaterThan(estimateDendrogramLabelWidth('iiii'))
109
+ expect(estimateDendrogramLabelWidth('健康')).toBeGreaterThan(estimateDendrogramLabelWidth('ii'))
110
+ expect(estimateDendrogramLabelWidth('健康')).toBeGreaterThanOrEqual(24)
111
+ expect(estimateDendrogramLabelWidth('🙂')).toBeGreaterThanOrEqual(12)
112
+ expect(estimateDendrogramLabelWidth('A🙂')).toBe(estimateDendrogramLabelWidth('A🙂'))
113
+ })
114
+
115
+ it('reserves the rendered line box for the 16px label font', () => {
116
+ const [placement] = resolveDendrogramLabels([makeNode('Label', 20, 20)], [], {
117
+ orientation: 'horizontal',
118
+ connectionType: 'line',
119
+ nodeRadius: 4
120
+ })
121
+
122
+ expect(placement.bounds.height).toBe(18)
123
+ })
124
+
125
+ it('detects a curved connector crossing a candidate label box', () => {
126
+ const labeled = makeNode('Crossing label', 90, 100)
127
+ const source = makeNode('Source', 0, 0)
128
+ const target = makeNode('Target', 200, 200, 1)
129
+ const placements = resolveDendrogramLabels([labeled, source, target], [makeLink(source, target)], {
130
+ orientation: 'horizontal',
131
+ connectionType: 'curve',
132
+ nodeRadius: 6
133
+ })
134
+
135
+ expect(placements[0].anchor).not.toBe('right')
136
+ })
137
+
138
+ it('spatially prefilters a large tree while preserving deterministic output', () => {
139
+ const rowCount = 1023
140
+ const prepared = prepareDendrogramData(
141
+ Array.from({ length: rowCount }, (_, index) => ({
142
+ id: `node-${index}`,
143
+ parentId: index === 0 ? '' : `node-${Math.floor((index - 1) / 2)}`
144
+ })),
145
+ { node: 'id', parent: 'parentId' }
146
+ )
147
+ const layout = layoutDendrogram(prepared, {
148
+ orientation: 'horizontal',
149
+ leafSpacing: 20,
150
+ depthSpacing: 100,
151
+ nodeRadius: 4
152
+ })
153
+ const options = { orientation: 'horizontal' as const, connectionType: 'curve' as const, nodeRadius: 4 }
154
+ const first = resolveDendrogramLabelsWithDiagnostics(layout.nodes, layout.links, options)
155
+ const second = resolveDendrogramLabelsWithDiagnostics(layout.nodes, layout.links, options)
156
+ const bruteForceChecks = layout.nodes.length * layout.nodes.length * 4
157
+
158
+ expect(first.placements).toHaveLength(rowCount)
159
+ expect(second).toEqual(first)
160
+ expect(first.diagnostics.nodeChecks).toBeLessThan(bruteForceChecks / 20)
161
+ expect(first.diagnostics.labelChecks).toBeLessThan(bruteForceChecks / 20)
162
+ expect(first.diagnostics.linkChecks).toBeLessThan(bruteForceChecks / 4)
163
+ })
164
+
165
+ it('deduplicates connector conflicts by link identity within each candidate', () => {
166
+ const labeled = makeNode('Crossing label', 90, 100)
167
+ const source = makeNode('Source', 0, 0)
168
+ const target = makeNode('Target', 200, 200, 1)
169
+ const nodes = [labeled, source, target]
170
+ const links = [makeLink(source, target)]
171
+ const baseOptions = {
172
+ orientation: 'horizontal' as const,
173
+ connectionType: 'curve' as const,
174
+ nodeRadius: 6
175
+ }
176
+ const coarse = resolveDendrogramLabelsWithDiagnostics(nodes, links, { ...baseOptions, curveTolerance: 2 })
177
+ const dense = resolveDendrogramLabelsWithDiagnostics(nodes, links, { ...baseOptions, curveTolerance: 0.01 })
178
+
179
+ expect(dense.diagnostics.linkChecks).toBeGreaterThan(coarse.diagnostics.linkChecks)
180
+ expect(coarse.diagnostics.linkConflicts).toBeLessThanOrEqual(nodes.length * 4)
181
+ expect(dense.diagnostics.linkConflicts).toBeLessThanOrEqual(nodes.length * 4)
182
+ expect(dense.placements).toEqual(coarse.placements)
183
+ })
184
+
185
+ it('handles labels spanning far more cells than the spatial-grid cap', () => {
186
+ const extremelyLongLabel = 'W'.repeat(100_000)
187
+ const nodes = [makeNode(extremelyLongLabel, 20, 20), makeNode('Nearby', 20, 40)]
188
+ const options = { orientation: 'horizontal' as const, connectionType: 'line' as const, nodeRadius: 4 }
189
+ const first = resolveDendrogramLabelsWithDiagnostics(nodes, [], options)
190
+ const second = resolveDendrogramLabelsWithDiagnostics(nodes, [], options)
191
+
192
+ expect(first.placements).toHaveLength(2)
193
+ expect(first.placements[0].bounds.width).toBe(1_600_000)
194
+ expect(second).toEqual(first)
195
+ })
196
+ })