@cdc/chart 4.26.7 → 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 (255) hide show
  1. package/CONFIG.md +502 -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 -97406
  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/overlay-legend.json +485 -0
  22. package/examples/private/pie-test.json +469 -0
  23. package/examples/vega-remote.json +22 -0
  24. package/package.json +11 -5
  25. package/src/CdcChartComponent.tsx +405 -208
  26. package/src/_stories/Chart.BarRace.stories.tsx +443 -0
  27. package/src/_stories/Chart.BoxPlotNumericSort.stories.tsx +189 -0
  28. package/src/_stories/Chart.BoxPlotStatsParity.stories.tsx +207 -0
  29. package/src/_stories/Chart.ColorDistribution.stories.tsx +299 -0
  30. package/src/_stories/Chart.DataCutoff.stories.tsx +147 -0
  31. package/src/_stories/Chart.DataTableDownloadFilename.stories.tsx +60 -0
  32. package/src/_stories/Chart.Dendrogram.stories.tsx +373 -0
  33. package/src/_stories/Chart.Forecast.CILegend.stories.tsx +236 -0
  34. package/src/_stories/Chart.HeatMap.stories.tsx +170 -4
  35. package/src/_stories/Chart.Legend.AxisAlign.stories.tsx +119 -0
  36. package/src/_stories/Chart.LineRace.stories.tsx +149 -0
  37. package/src/_stories/Chart.Network.stories.tsx +432 -0
  38. package/src/_stories/Chart.PaletteConversion.Editor.stories.tsx +208 -0
  39. package/src/_stories/Chart.Patterns.smoke.stories.tsx +31 -0
  40. package/src/_stories/Chart.PercentageValue.stories.tsx +311 -0
  41. package/src/_stories/Chart.PieLabels.stories.tsx +4 -2
  42. package/src/_stories/Chart.RankByValueMutation.stories.tsx +206 -0
  43. package/src/_stories/Chart.RequiredFields.stories.tsx +681 -0
  44. package/src/_stories/Chart.Sankey.stories.tsx +417 -0
  45. package/src/_stories/Chart.SeriesColorAssignments.stories.tsx +47 -0
  46. package/src/_stories/Chart.SmallMultiplesNumericTileOrder.stories.tsx +137 -0
  47. package/src/_stories/Chart.TooltipSeriesLabels.stories.tsx +43 -0
  48. package/src/_stories/Chart.VegaRemoteData.stories.tsx +31 -0
  49. package/src/_stories/ChartAnnotation.Editor.stories.tsx +78 -0
  50. package/src/_stories/ChartAxisTitles.smoke.stories.tsx +48 -0
  51. package/src/_stories/ChartBar.Editor.stories.tsx +499 -57
  52. package/src/_stories/ChartBrush.Editor.stories.tsx +46 -67
  53. package/src/_stories/ChartBrush.Regression.stories.tsx +135 -0
  54. package/src/_stories/ChartEditor.Editor.stories.tsx +33 -3
  55. package/src/_stories/ChartLine.EffectCompatibility.stories.tsx +120 -0
  56. package/src/_stories/_mock/annotation_event-line_mock.json +2 -2
  57. package/src/_stories/_mock/editor-test-configs.json +20 -10
  58. package/src/_stories/_mock/editor-test-datasets.json +38 -42
  59. package/src/_stories/_mock/heatmap-average-age-categorical.json +2 -2
  60. package/src/_stories/_mock/heatmap.json +2 -2
  61. package/src/_stories/_mock/legend-axis-align-stacked-bar.json +125 -0
  62. package/src/_stories/_mock/sankey_mock.json +23 -27
  63. package/src/components/Annotations/components/AnnotationDraggable.styles.css +52 -0
  64. package/src/components/Annotations/components/AnnotationDraggable.tsx +639 -139
  65. package/src/components/Annotations/components/AnnotationDropdown.styles.css +44 -4
  66. package/src/components/Annotations/components/AnnotationDropdown.tsx +10 -32
  67. package/src/components/Annotations/helpers/resolveAnnotationLayout.ts +212 -0
  68. package/src/components/Annotations/tests/AnnotationDraggable.test.tsx +612 -19
  69. package/src/components/Annotations/tests/AnnotationDropdown.test.tsx +50 -0
  70. package/src/components/Annotations/tests/resolveAnnotationLayout.test.ts +203 -0
  71. package/src/components/Axis/BottomAxis.tsx +1 -1
  72. package/src/components/Axis/Categorical.Axis.tsx +3 -3
  73. package/src/components/Axis/RightAxis.tsx +21 -13
  74. package/src/components/BarChart/components/BarChart.Horizontal.tsx +38 -6
  75. package/src/components/BarChart/components/BarChart.StackedHorizontal.tsx +12 -3
  76. package/src/components/BarChart/components/BarChart.StackedVertical.test.ts +28 -0
  77. package/src/components/BarChart/components/BarChart.StackedVertical.tsx +29 -25
  78. package/src/components/BarChart/components/BarChart.Vertical.tsx +37 -26
  79. package/src/components/BarChart/components/PortionPatternOverlay.tsx +61 -0
  80. package/src/components/BarChart/components/context.tsx +6 -0
  81. package/src/components/BarChart/helpers/getPatternUrl.ts +2 -5
  82. package/src/components/BarChart/helpers/index.ts +8 -0
  83. package/src/components/BarChart/helpers/portionPattern.ts +243 -0
  84. package/src/components/BarChart/helpers/tests/addMinimumBarHeights.test.ts +42 -0
  85. package/src/components/BarChart/helpers/tests/getPatternUrl.test.ts +13 -0
  86. package/src/components/BarChart/helpers/tests/portionPattern.test.ts +280 -0
  87. package/src/components/BarChart/helpers/tests/useBarChart.test.tsx +104 -0
  88. package/src/components/BarChart/helpers/useBarChart.ts +60 -59
  89. package/src/components/BarChartRace/BarChartRace.test.tsx +199 -0
  90. package/src/components/BarChartRace/BarChartRace.tsx +230 -0
  91. package/src/components/BarChartRace/BarChartRaceFallback.test.tsx +61 -0
  92. package/src/components/BarChartRace/BarChartRaceFallback.tsx +48 -0
  93. package/src/components/BarChartRace/bar-chart-race.css +160 -0
  94. package/src/components/BarChartRace/helpers.test.ts +114 -0
  95. package/src/components/BarChartRace/helpers.ts +185 -0
  96. package/src/components/BarChartRace/index.ts +3 -0
  97. package/src/components/BoxPlot/helpers/index.ts +11 -82
  98. package/src/components/Brush/BrushSelector.tsx +9 -23
  99. package/src/components/Brush/MiniChartPreview.tsx +33 -1
  100. package/src/components/Dendrogram/components/Dendrogram.tsx +374 -0
  101. package/src/components/Dendrogram/dendrogram.css +113 -0
  102. package/src/components/Dendrogram/helpers/layoutDendrogram.test.ts +95 -0
  103. package/src/components/Dendrogram/helpers/layoutDendrogram.ts +56 -0
  104. package/src/components/Dendrogram/helpers/prepareDendrogramData.test.ts +135 -0
  105. package/src/components/Dendrogram/helpers/prepareDendrogramData.ts +166 -0
  106. package/src/components/Dendrogram/helpers/resolveDendrogramLabels.test.ts +196 -0
  107. package/src/components/Dendrogram/helpers/resolveDendrogramLabels.ts +418 -0
  108. package/src/components/Dendrogram/index.tsx +1 -0
  109. package/src/components/Dendrogram/types/index.ts +66 -0
  110. package/src/components/EditorPanel/EditorPanel.tsx +1005 -760
  111. package/src/components/EditorPanel/components/Panels/Panel.Annotate.tsx +50 -5
  112. package/src/components/EditorPanel/components/Panels/Panel.Dendrogram.css +10 -0
  113. package/src/components/EditorPanel/components/Panels/Panel.Dendrogram.tsx +211 -0
  114. package/src/components/EditorPanel/components/Panels/Panel.ForestPlotSettings.tsx +19 -8
  115. package/src/components/EditorPanel/components/Panels/Panel.General.tsx +88 -54
  116. package/src/components/EditorPanel/components/Panels/Panel.Network.css +9 -0
  117. package/src/components/EditorPanel/components/Panels/Panel.Network.tsx +202 -0
  118. package/src/components/EditorPanel/components/Panels/Panel.PatternSettings.tsx +233 -33
  119. package/src/components/EditorPanel/components/Panels/Panel.Radar.tsx +6 -1
  120. package/src/components/EditorPanel/components/Panels/Panel.Sankey.tsx +89 -105
  121. package/src/components/EditorPanel/components/Panels/Panel.Series.tsx +2 -2
  122. package/src/components/EditorPanel/components/Panels/Panel.SmallMultiples.tsx +1 -1
  123. package/src/components/EditorPanel/components/Panels/Panel.Visual.tsx +8 -66
  124. package/src/components/EditorPanel/components/Panels/RacingControls.test.tsx +131 -0
  125. package/src/components/EditorPanel/components/Panels/RacingControls.tsx +185 -0
  126. package/src/components/EditorPanel/components/Panels/SeriesColorAssignments.tsx +2 -2
  127. package/src/components/EditorPanel/components/Panels/index.tsx +4 -0
  128. package/src/components/EditorPanel/helpers/getVisualizationTypeConfigUpdate.ts +20 -7
  129. package/src/components/EditorPanel/helpers/tests/getVisualizationTypeConfigUpdate.test.ts +25 -2
  130. package/src/components/EditorPanel/helpers/updateFieldRankByValue.ts +2 -1
  131. package/src/components/EditorPanel/useEditorPermissions.test.tsx +62 -0
  132. package/src/components/EditorPanel/useEditorPermissions.ts +70 -24
  133. package/src/components/Forecasting/Forecasting.tsx +15 -6
  134. package/src/components/HeatMap/components/HeatMap.tsx +511 -178
  135. package/src/components/HeatMap/heatmap.constants.ts +7 -3
  136. package/src/components/HeatMap/heatmap.css +101 -2
  137. package/src/components/HeatMap/helpers/layout.ts +80 -0
  138. package/src/components/HeatMap/tests/HeatMap.test.tsx +339 -16
  139. package/src/components/HeatMap/tests/helpers.test.ts +79 -0
  140. package/src/components/Legend/Legend.Component.tsx +110 -90
  141. package/src/components/Legend/LegendGroup/LegendGroup.styles.css +7 -0
  142. package/src/components/Legend/LegendGroup/LegendGroup.tsx +47 -34
  143. package/src/components/Legend/helpers/createFormatLabels.test.ts +226 -0
  144. package/src/components/Legend/helpers/createFormatLabels.tsx +45 -51
  145. package/src/components/LineChart/components/LineChart.Circle.tsx +3 -3
  146. package/src/components/LineChart/helpers.test.ts +126 -38
  147. package/src/components/LineChart/helpers.ts +9 -4
  148. package/src/components/LineChart/index.tsx +69 -24
  149. package/src/components/LineChart/tests/LineChart.test.tsx +630 -0
  150. package/src/components/LineChartRace/LineChartRace.test.tsx +275 -0
  151. package/src/components/LineChartRace/LineChartRace.tsx +134 -0
  152. package/src/components/LineChartRace/LineChartRaceFallback.tsx +28 -0
  153. package/src/components/LineChartRace/helpers.test.ts +88 -0
  154. package/src/components/LineChartRace/helpers.ts +157 -0
  155. package/src/components/LineChartRace/index.ts +3 -0
  156. package/src/components/LineChartRace/line-chart-race.css +16 -0
  157. package/src/components/LinearChart/ValueAxisAnchors.tsx +58 -0
  158. package/src/components/LinearChart/VisualizationRenderer.tsx +18 -23
  159. package/src/components/LinearChart/tests/LinearChart.test.tsx +412 -13
  160. package/src/components/LinearChart/tests/VisualizationRenderer.test.tsx +141 -0
  161. package/src/components/LinearChart/tests/mockConfigContext.ts +2 -1
  162. package/src/components/LinearChart.tsx +81 -51
  163. package/src/components/Network/components/Network.tsx +372 -0
  164. package/src/components/Network/helpers/layoutNetwork.test.ts +68 -0
  165. package/src/components/Network/helpers/layoutNetwork.ts +87 -0
  166. package/src/components/Network/helpers/prepareNetworkData.test.ts +116 -0
  167. package/src/components/Network/helpers/prepareNetworkData.ts +101 -0
  168. package/src/components/Network/helpers/resolveNetworkLabels.test.ts +112 -0
  169. package/src/components/Network/helpers/resolveNetworkLabels.ts +206 -0
  170. package/src/components/Network/index.tsx +3 -0
  171. package/src/components/Network/network.css +107 -0
  172. package/src/components/Network/types/index.ts +56 -0
  173. package/src/components/PairedBarChart.jsx +5 -10
  174. package/src/components/PairedBarChart.test.tsx +71 -0
  175. package/src/components/PieChart/PieChart.tsx +9 -8
  176. package/src/components/RacingChartRenderer.tsx +44 -0
  177. package/src/components/Regions/components/Regions.test.tsx +179 -0
  178. package/src/components/Regions/components/Regions.tsx +104 -69
  179. package/src/components/Regions/index.tsx +2 -3
  180. package/src/components/Sankey/components/Sankey.tsx +264 -437
  181. package/src/components/Sankey/helpers/getSankeyTooltip.ts +12 -0
  182. package/src/components/Sankey/helpers/prepareSankeyData.test.ts +71 -0
  183. package/src/components/Sankey/helpers/prepareSankeyData.ts +90 -0
  184. package/src/components/Sankey/sankey.scss +57 -116
  185. package/src/components/Sankey/types/index.ts +41 -4
  186. package/src/components/ScatterPlot/ScatterPlot.jsx +28 -4
  187. package/src/components/ScatterPlot/ScatterPlot.test.tsx +60 -0
  188. package/src/components/SmallMultiples/SmallMultiples.tsx +12 -12
  189. package/src/components/SmallMultiples/tests/SmallMultipleTile.test.tsx +7 -5
  190. package/src/components/raceTiming.ts +18 -0
  191. package/src/data/initial-state.js +77 -31
  192. package/src/helpers/applyChartTypeContract.ts +71 -0
  193. package/src/helpers/axisLabelFontSize.ts +2 -2
  194. package/src/helpers/boxPlotStats.ts +60 -0
  195. package/src/helpers/bumpChartTooltip.ts +2 -1
  196. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.test.ts +37 -0
  197. package/src/helpers/calculateHorizontalBarCategoryLabelWidth.ts +3 -2
  198. package/src/helpers/calculateRightYAxisWidth.ts +60 -0
  199. package/src/helpers/colorAssignmentHelpers.ts +2 -2
  200. package/src/helpers/createNewChartConfig.ts +56 -0
  201. package/src/helpers/dataDrivenYAxisCategories.ts +18 -0
  202. package/src/helpers/forecastLegend.ts +88 -0
  203. package/src/helpers/getBoxPlotConfig.ts +18 -37
  204. package/src/helpers/getChartTypeDefaultPalette.ts +29 -0
  205. package/src/helpers/getColorCodeCategoryColorMap.ts +26 -0
  206. package/src/helpers/getColorScale.ts +31 -22
  207. package/src/helpers/getDynamicYAxisCategories.ts +69 -0
  208. package/src/helpers/getMinMax.ts +9 -7
  209. package/src/helpers/getNewRuntime.ts +5 -2
  210. package/src/helpers/getStackedSeriesMax.ts +51 -0
  211. package/src/helpers/getV21ChartDistributionColors.ts +57 -0
  212. package/src/helpers/handleRankByValue.ts +15 -15
  213. package/src/helpers/seriesColumnSettings.ts +10 -0
  214. package/src/helpers/sizeHelpers.ts +4 -3
  215. package/src/helpers/smallMultiplesHelpers.ts +10 -13
  216. package/src/helpers/tests/applyChartTypeContract.test.ts +37 -0
  217. package/src/helpers/tests/boxPlotStats.test.ts +31 -0
  218. package/src/helpers/tests/bumpChartTooltip.test.ts +29 -0
  219. package/src/helpers/tests/calculateRightYAxisWidth.test.ts +105 -0
  220. package/src/helpers/tests/countNumOfTicks.test.ts +11 -0
  221. package/src/helpers/tests/createNewChartConfig.test.ts +182 -0
  222. package/src/helpers/tests/getBoxPlotConfig.test.ts +70 -0
  223. package/src/helpers/tests/getColorCodeCategoryColorMap.test.ts +113 -0
  224. package/src/helpers/tests/getColorScale.test.ts +226 -1
  225. package/src/helpers/tests/getDynamicYAxisCategories.test.ts +61 -0
  226. package/src/helpers/tests/getMinMax.test.ts +41 -0
  227. package/src/helpers/tests/getStackedSeriesMax.test.ts +26 -0
  228. package/src/helpers/tests/handleRankByValue.test.ts +55 -37
  229. package/src/helpers/tests/seriesColumnSettings.test.ts +23 -0
  230. package/src/helpers/tests/smallMultiplesHelpers.test.ts +37 -1
  231. package/src/helpers/tests/tooltipHelpers.test.ts +80 -0
  232. package/src/helpers/tooltipHelpers.ts +16 -0
  233. package/src/hooks/tests/useTooltip.test.tsx +353 -1
  234. package/src/hooks/usePrefersReducedMotion.ts +22 -0
  235. package/src/hooks/useReduceData.ts +4 -36
  236. package/src/hooks/useScales.ts +15 -0
  237. package/src/hooks/useTooltip.tsx +95 -64
  238. package/src/scss/main.scss +74 -2
  239. package/src/store/chart.reducer.ts +4 -3
  240. package/src/test/CdcChart.dataTable.test.tsx +490 -16
  241. package/src/test/CdcChart.dendrogram.test.tsx +277 -0
  242. package/src/test/CdcChart.finalization.test.tsx +143 -0
  243. package/src/test/CdcChart.network.test.tsx +271 -0
  244. package/src/types/ChartConfig.ts +91 -54
  245. package/src/types/ChartContext.ts +5 -0
  246. package/src/types/Horizon.ts +1 -1
  247. package/LICENSE +0 -201
  248. package/dist/cdcchart-BWVIUcAF.es.js +0 -4802
  249. package/src/_stories/Chart.Sankey.smoke.stories.tsx +0 -62
  250. package/src/components/Sankey/components/ColumnList.tsx +0 -19
  251. package/src/components/Sankey/helpers/getSankeyTooltip.tsx +0 -34
  252. package/src/components/Sankey/useSankeyAlert.tsx +0 -60
  253. package/src/data/legacy-defaults.ts +0 -18
  254. package/src/helpers/ensureSpecialChartAxisTypes.ts +0 -11
  255. package/src/helpers/tests/ensureSpecialChartAxisTypes.test.ts +0 -30
@@ -0,0 +1,418 @@
1
+ import type { DendrogramLayoutLink, DendrogramLayoutNode, DendrogramOrientation } from '../types'
2
+
3
+ export type DendrogramLabelAnchor = 'top' | 'right' | 'bottom' | 'left'
4
+
5
+ export type DendrogramLabelBounds = {
6
+ x: number
7
+ y: number
8
+ width: number
9
+ height: number
10
+ }
11
+
12
+ export type DendrogramLabelPlacement = {
13
+ nodeId: string
14
+ anchor: DendrogramLabelAnchor
15
+ x: number
16
+ y: number
17
+ textAnchor: 'start' | 'middle' | 'end'
18
+ dominantBaseline: 'hanging'
19
+ bounds: DendrogramLabelBounds
20
+ }
21
+
22
+ export type DendrogramLabelResolutionDiagnostics = {
23
+ nodeChecks: number
24
+ linkChecks: number
25
+ linkConflicts: number
26
+ labelChecks: number
27
+ }
28
+
29
+ type ResolveOptions = {
30
+ orientation: DendrogramOrientation
31
+ connectionType: 'curve' | 'line' | 'elbow'
32
+ nodeRadius: number
33
+ labelHeight?: number
34
+ labelGap?: number
35
+ curveTolerance?: number
36
+ }
37
+
38
+ type Point = { x: number; y: number }
39
+ type Segment = { linkId: string; start: Point; end: Point; bounds: DendrogramLabelBounds }
40
+ type NodeObstacle = { nodeId: string; bounds: DendrogramLabelBounds }
41
+ type Bounded = { bounds: DendrogramLabelBounds }
42
+
43
+ type BoundsTree<T extends Bounded> = {
44
+ bounds: DendrogramLabelBounds
45
+ items?: T[]
46
+ left?: BoundsTree<T>
47
+ right?: BoundsTree<T>
48
+ }
49
+
50
+ const INDEX_LEAF_SIZE = 8
51
+ const LABEL_GRID_SIZE = 64
52
+ const CURVE_TOLERANCE = 0.5
53
+ const MAX_CURVE_DEPTH = 12
54
+ const MAX_GRID_CELLS_PER_ITEM = 256
55
+
56
+ const getBounds = (start: Point, end: Point): DendrogramLabelBounds => ({
57
+ x: Math.min(start.x, end.x),
58
+ y: Math.min(start.y, end.y),
59
+ width: Math.abs(end.x - start.x),
60
+ height: Math.abs(end.y - start.y)
61
+ })
62
+
63
+ const combineBounds = <T extends Bounded>(items: T[]): DendrogramLabelBounds => {
64
+ const first = items[0].bounds
65
+ return items.slice(1).reduce(
66
+ (result, item) => ({
67
+ x: Math.min(result.x, item.bounds.x),
68
+ y: Math.min(result.y, item.bounds.y),
69
+ width: Math.max(result.x + result.width, item.bounds.x + item.bounds.width) - Math.min(result.x, item.bounds.x),
70
+ height: Math.max(result.y + result.height, item.bounds.y + item.bounds.height) - Math.min(result.y, item.bounds.y)
71
+ }),
72
+ { ...first }
73
+ )
74
+ }
75
+
76
+ const boundsMayIntersect = (a: DendrogramLabelBounds, b: DendrogramLabelBounds) =>
77
+ a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y
78
+
79
+ const boundsIntersect = (a: DendrogramLabelBounds, b: DendrogramLabelBounds) =>
80
+ a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
81
+
82
+ const expandBounds = (bounds: DendrogramLabelBounds, amount: number): DendrogramLabelBounds => ({
83
+ x: bounds.x - amount,
84
+ y: bounds.y - amount,
85
+ width: bounds.width + amount * 2,
86
+ height: bounds.height + amount * 2
87
+ })
88
+
89
+ const buildBoundsTree = <T extends Bounded>(items: T[]): BoundsTree<T> | undefined => {
90
+ if (!items.length) return undefined
91
+ const bounds = combineBounds(items)
92
+ if (items.length <= INDEX_LEAF_SIZE) return { bounds, items }
93
+
94
+ const axis = bounds.width >= bounds.height ? 'x' : 'y'
95
+ const sorted = [...items].sort((a, b) => {
96
+ const aCenter = a.bounds[axis] + a.bounds[axis === 'x' ? 'width' : 'height'] / 2
97
+ const bCenter = b.bounds[axis] + b.bounds[axis === 'x' ? 'width' : 'height'] / 2
98
+ return aCenter - bCenter
99
+ })
100
+ const midpoint = Math.floor(sorted.length / 2)
101
+ return {
102
+ bounds,
103
+ left: buildBoundsTree(sorted.slice(0, midpoint)),
104
+ right: buildBoundsTree(sorted.slice(midpoint))
105
+ }
106
+ }
107
+
108
+ const queryBoundsTree = <T extends Bounded>(tree: BoundsTree<T> | undefined, bounds: DendrogramLabelBounds): T[] => {
109
+ if (!tree || !boundsMayIntersect(tree.bounds, bounds)) return []
110
+ if (tree.items) return tree.items.filter(item => boundsMayIntersect(item.bounds, bounds))
111
+ return [...queryBoundsTree(tree.left, bounds), ...queryBoundsTree(tree.right, bounds)]
112
+ }
113
+
114
+ class BoundsGrid<T extends Bounded> {
115
+ private cells = new Map<string, T[]>()
116
+ private globalItems: T[] = []
117
+ private items: T[] = []
118
+
119
+ add(item: T) {
120
+ this.items.push(item)
121
+ const range = this.getCellRange(item.bounds)
122
+ if (range.cellCount > MAX_GRID_CELLS_PER_ITEM) {
123
+ this.globalItems.push(item)
124
+ return
125
+ }
126
+ this.getCellKeys(range).forEach(key => {
127
+ const cell = this.cells.get(key)
128
+ if (cell) cell.push(item)
129
+ else this.cells.set(key, [item])
130
+ })
131
+ }
132
+
133
+ query(bounds: DendrogramLabelBounds) {
134
+ const range = this.getCellRange(bounds)
135
+ if (range.cellCount > MAX_GRID_CELLS_PER_ITEM) {
136
+ return this.items.filter(item => boundsMayIntersect(item.bounds, bounds))
137
+ }
138
+ const found = new Set<T>()
139
+ this.globalItems.forEach(item => found.add(item))
140
+ this.getCellKeys(range).forEach(key => this.cells.get(key)?.forEach(item => found.add(item)))
141
+ return [...found].filter(item => boundsMayIntersect(item.bounds, bounds))
142
+ }
143
+
144
+ private getCellRange(bounds: DendrogramLabelBounds) {
145
+ const minimumX = Math.floor(bounds.x / LABEL_GRID_SIZE)
146
+ const maximumX = Math.floor((bounds.x + bounds.width) / LABEL_GRID_SIZE)
147
+ const minimumY = Math.floor(bounds.y / LABEL_GRID_SIZE)
148
+ const maximumY = Math.floor((bounds.y + bounds.height) / LABEL_GRID_SIZE)
149
+ return {
150
+ minimumX,
151
+ maximumX,
152
+ minimumY,
153
+ maximumY,
154
+ cellCount: (maximumX - minimumX + 1) * (maximumY - minimumY + 1)
155
+ }
156
+ }
157
+
158
+ private getCellKeys(range: ReturnType<BoundsGrid<T>['getCellRange']>) {
159
+ const keys: string[] = []
160
+ for (let x = range.minimumX; x <= range.maximumX; x += 1) {
161
+ for (let y = range.minimumY; y <= range.maximumY; y += 1) keys.push(`${x}:${y}`)
162
+ }
163
+ return keys
164
+ }
165
+ }
166
+
167
+ const pointInBounds = (point: Point, bounds: DendrogramLabelBounds) =>
168
+ point.x >= bounds.x &&
169
+ point.x <= bounds.x + bounds.width &&
170
+ point.y >= bounds.y &&
171
+ point.y <= bounds.y + bounds.height
172
+
173
+ const segmentIntersectsBounds = (segment: Segment, bounds: DendrogramLabelBounds) => {
174
+ if (!boundsMayIntersect(segment.bounds, bounds)) return false
175
+ if (pointInBounds(segment.start, bounds) || pointInBounds(segment.end, bounds)) return true
176
+
177
+ const dx = segment.end.x - segment.start.x
178
+ const dy = segment.end.y - segment.start.y
179
+ const p = [-dx, dx, -dy, dy]
180
+ const q = [
181
+ segment.start.x - bounds.x,
182
+ bounds.x + bounds.width - segment.start.x,
183
+ segment.start.y - bounds.y,
184
+ bounds.y + bounds.height - segment.start.y
185
+ ]
186
+ let minimum = 0
187
+ let maximum = 1
188
+
189
+ for (let index = 0; index < p.length; index += 1) {
190
+ if (p[index] === 0) {
191
+ if (q[index] < 0) return false
192
+ continue
193
+ }
194
+ const ratio = q[index] / p[index]
195
+ if (p[index] < 0) minimum = Math.max(minimum, ratio)
196
+ else maximum = Math.min(maximum, ratio)
197
+ if (minimum > maximum) return false
198
+ }
199
+
200
+ return true
201
+ }
202
+
203
+ const midpoint = (a: Point, b: Point): Point => ({ x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 })
204
+
205
+ const distanceFromLine = (point: Point, start: Point, end: Point) => {
206
+ const dx = end.x - start.x
207
+ const dy = end.y - start.y
208
+ if (dx === 0 && dy === 0) return Math.hypot(point.x - start.x, point.y - start.y)
209
+ return Math.abs(dy * point.x - dx * point.y + end.x * start.y - end.y * start.x) / Math.hypot(dx, dy)
210
+ }
211
+
212
+ const addAdaptiveCurveSegments = (
213
+ start: Point,
214
+ firstControl: Point,
215
+ secondControl: Point,
216
+ end: Point,
217
+ segments: Segment[],
218
+ linkId: string,
219
+ tolerance: number,
220
+ depth = 0
221
+ ) => {
222
+ const flatness = Math.max(distanceFromLine(firstControl, start, end), distanceFromLine(secondControl, start, end))
223
+ if (flatness <= tolerance || depth >= MAX_CURVE_DEPTH) {
224
+ segments.push({ linkId, start, end, bounds: getBounds(start, end) })
225
+ return
226
+ }
227
+
228
+ const startToFirst = midpoint(start, firstControl)
229
+ const firstToSecond = midpoint(firstControl, secondControl)
230
+ const secondToEnd = midpoint(secondControl, end)
231
+ const leftControl = midpoint(startToFirst, firstToSecond)
232
+ const rightControl = midpoint(firstToSecond, secondToEnd)
233
+ const split = midpoint(leftControl, rightControl)
234
+ addAdaptiveCurveSegments(start, startToFirst, leftControl, split, segments, linkId, tolerance, depth + 1)
235
+ addAdaptiveCurveSegments(split, rightControl, secondToEnd, end, segments, linkId, tolerance, depth + 1)
236
+ }
237
+
238
+ const getLinkSegments = (
239
+ link: DendrogramLayoutLink,
240
+ orientation: DendrogramOrientation,
241
+ connectionType: ResolveOptions['connectionType'],
242
+ curveTolerance: number
243
+ ): Segment[] => {
244
+ const { source, target } = link
245
+ if (connectionType === 'line') {
246
+ return [{ linkId: link.id, start: source, end: target, bounds: getBounds(source, target) }]
247
+ }
248
+
249
+ if (connectionType === 'elbow') {
250
+ const points =
251
+ orientation === 'vertical'
252
+ ? [source, { x: source.x, y: (source.y + target.y) / 2 }, { x: target.x, y: (source.y + target.y) / 2 }, target]
253
+ : [source, { x: (source.x + target.x) / 2, y: source.y }, { x: (source.x + target.x) / 2, y: target.y }, target]
254
+ return points.slice(1).map((point, index) => ({
255
+ linkId: link.id,
256
+ start: points[index],
257
+ end: point,
258
+ bounds: getBounds(points[index], point)
259
+ }))
260
+ }
261
+
262
+ const firstControl =
263
+ orientation === 'vertical'
264
+ ? { x: source.x, y: (source.y + target.y) / 2 }
265
+ : { x: (source.x + target.x) / 2, y: source.y }
266
+ const secondControl =
267
+ orientation === 'vertical'
268
+ ? { x: target.x, y: (source.y + target.y) / 2 }
269
+ : { x: (source.x + target.x) / 2, y: target.y }
270
+ const segments: Segment[] = []
271
+ addAdaptiveCurveSegments(source, firstControl, secondControl, target, segments, link.id, curveTolerance)
272
+ return segments
273
+ }
274
+
275
+ const getSegments = (
276
+ links: DendrogramLayoutLink[],
277
+ orientation: DendrogramOrientation,
278
+ connectionType: ResolveOptions['connectionType'],
279
+ curveTolerance: number
280
+ ) => links.flatMap(link => getLinkSegments(link, orientation, connectionType, curveTolerance))
281
+
282
+ export const estimateDendrogramLabelWidth = (label: string) =>
283
+ Math.max(
284
+ 1,
285
+ Array.from(label).reduce((width, character) => {
286
+ if (/\s/.test(character)) return width + 5.5
287
+ if (/[ilI1.,'|!:;]/.test(character)) return width + 4.75
288
+ if (/[MW@#%&]/.test(character)) return width + 16
289
+ return width + (character.codePointAt(0)! > 0x7f ? 16 : 10)
290
+ }, 0)
291
+ )
292
+
293
+ const getCandidates = (
294
+ node: DendrogramLayoutNode,
295
+ width: number,
296
+ height: number,
297
+ nodeRadius: number,
298
+ gap: number,
299
+ orientation: DendrogramOrientation
300
+ ): DendrogramLabelPlacement[] => {
301
+ const definitions: Record<
302
+ DendrogramLabelAnchor,
303
+ Omit<DendrogramLabelPlacement, 'nodeId' | 'anchor' | 'dominantBaseline'>
304
+ > = {
305
+ top: {
306
+ x: 0,
307
+ y: -nodeRadius - gap - height,
308
+ textAnchor: 'middle',
309
+ bounds: { x: node.x - width / 2, y: node.y - nodeRadius - gap - height, width, height }
310
+ },
311
+ right: {
312
+ x: nodeRadius + gap,
313
+ y: -height / 2,
314
+ textAnchor: 'start',
315
+ bounds: { x: node.x + nodeRadius + gap, y: node.y - height / 2, width, height }
316
+ },
317
+ bottom: {
318
+ x: 0,
319
+ y: nodeRadius + gap,
320
+ textAnchor: 'middle',
321
+ bounds: { x: node.x - width / 2, y: node.y + nodeRadius + gap, width, height }
322
+ },
323
+ left: {
324
+ x: -nodeRadius - gap,
325
+ y: -height / 2,
326
+ textAnchor: 'end',
327
+ bounds: { x: node.x - nodeRadius - gap - width, y: node.y - height / 2, width, height }
328
+ }
329
+ }
330
+ const priority: DendrogramLabelAnchor[] =
331
+ orientation === 'horizontal' ? ['right', 'top', 'bottom', 'left'] : ['bottom', 'right', 'left', 'top']
332
+
333
+ return priority.map(anchor => ({
334
+ nodeId: node.id,
335
+ anchor,
336
+ dominantBaseline: 'hanging',
337
+ ...definitions[anchor]
338
+ }))
339
+ }
340
+
341
+ export const resolveDendrogramLabelsWithDiagnostics = (
342
+ nodes: DendrogramLayoutNode[],
343
+ links: DendrogramLayoutLink[],
344
+ options: ResolveOptions
345
+ ): { placements: DendrogramLabelPlacement[]; diagnostics: DendrogramLabelResolutionDiagnostics } => {
346
+ // A 16px SVG font has an approximately 18px rendered line box in supported browsers.
347
+ const labelHeight = options.labelHeight ?? 18
348
+ const labelGap = options.labelGap ?? 5
349
+ const nodeRadius = Math.max(2, options.nodeRadius)
350
+ const curveTolerance = Math.max(0.01, options.curveTolerance ?? CURVE_TOLERANCE)
351
+ const segments = getSegments(links, options.orientation, options.connectionType, curveTolerance)
352
+ const nodeBounds: NodeObstacle[] = nodes.map(node => ({
353
+ nodeId: node.id,
354
+ bounds: {
355
+ x: node.x - nodeRadius,
356
+ y: node.y - nodeRadius,
357
+ width: nodeRadius * 2,
358
+ height: nodeRadius * 2
359
+ }
360
+ }))
361
+ const nodeIndex = buildBoundsTree(nodeBounds)
362
+ const segmentIndex = buildBoundsTree(segments)
363
+ const labelIndex = new BoundsGrid<DendrogramLabelPlacement>()
364
+ const placements: DendrogramLabelPlacement[] = []
365
+ const diagnostics: DendrogramLabelResolutionDiagnostics = {
366
+ nodeChecks: 0,
367
+ linkChecks: 0,
368
+ linkConflicts: 0,
369
+ labelChecks: 0
370
+ }
371
+
372
+ nodes.forEach(node => {
373
+ const width = estimateDendrogramLabelWidth(node.label)
374
+ const candidates = getCandidates(node, width, labelHeight, nodeRadius, labelGap, options.orientation)
375
+ let bestCandidate = candidates[0]
376
+ let bestScore = Infinity
377
+
378
+ candidates.forEach((candidate, priority) => {
379
+ const linkCollisionBounds = expandBounds(candidate.bounds, curveTolerance + 1)
380
+ const nearbyNodes = queryBoundsTree(nodeIndex, candidate.bounds)
381
+ const nearbySegments = queryBoundsTree(segmentIndex, linkCollisionBounds)
382
+ const nearbyLabels = labelIndex.query(candidate.bounds)
383
+ diagnostics.nodeChecks += nearbyNodes.length
384
+ diagnostics.linkChecks += nearbySegments.length
385
+ diagnostics.labelChecks += nearbyLabels.length
386
+
387
+ const nodeConflicts = nearbyNodes.filter(
388
+ obstacle => obstacle.nodeId !== node.id && boundsIntersect(candidate.bounds, obstacle.bounds)
389
+ ).length
390
+ const linkConflicts = new Set(
391
+ nearbySegments
392
+ .filter(segment => segmentIntersectsBounds(segment, linkCollisionBounds))
393
+ .map(segment => segment.linkId)
394
+ ).size
395
+ diagnostics.linkConflicts += linkConflicts
396
+ const labelConflicts = nearbyLabels.filter(placement =>
397
+ boundsIntersect(candidate.bounds, placement.bounds)
398
+ ).length
399
+ const score = nodeConflicts * 10000 + labelConflicts * 10000 + linkConflicts * 100 + priority
400
+
401
+ if (score < bestScore) {
402
+ bestCandidate = candidate
403
+ bestScore = score
404
+ }
405
+ })
406
+
407
+ placements.push(bestCandidate)
408
+ labelIndex.add(bestCandidate)
409
+ })
410
+
411
+ return { placements, diagnostics }
412
+ }
413
+
414
+ export const resolveDendrogramLabels = (
415
+ nodes: DendrogramLayoutNode[],
416
+ links: DendrogramLayoutLink[],
417
+ options: ResolveOptions
418
+ ) => resolveDendrogramLabelsWithDiagnostics(nodes, links, options).placements
@@ -0,0 +1 @@
1
+ export { default } from './components/Dendrogram'
@@ -0,0 +1,66 @@
1
+ import type { Runtime } from '@cdc/core/types/Runtime'
2
+
3
+ export type DendrogramRawRow = Record<string, unknown>
4
+
5
+ export type DendrogramColumns = {
6
+ node?: string
7
+ parent?: string
8
+ style?: string
9
+ nodeColor?: string
10
+ }
11
+
12
+ export type DendrogramNode = {
13
+ id: string
14
+ parentId: string | null
15
+ label: string
16
+ style: 'solid' | 'dashed'
17
+ color?: string
18
+ }
19
+
20
+ export type DendrogramValidationCode =
21
+ | 'duplicate-node'
22
+ | 'missing-root'
23
+ | 'multiple-roots'
24
+ | 'missing-parent'
25
+ | 'self-parent'
26
+ | 'cycle'
27
+ | 'disconnected'
28
+
29
+ export type DendrogramValidationError = {
30
+ code: DendrogramValidationCode
31
+ message: string
32
+ }
33
+
34
+ export type DendrogramPreparedData = {
35
+ nodes: DendrogramNode[]
36
+ rootId?: string
37
+ rejectedRowCount: number
38
+ error?: DendrogramValidationError
39
+ }
40
+
41
+ export type DendrogramLayoutNode = DendrogramNode & {
42
+ depth: number
43
+ x: number
44
+ y: number
45
+ }
46
+
47
+ export type DendrogramLayoutLink = {
48
+ id: string
49
+ source: DendrogramLayoutNode
50
+ target: DendrogramLayoutNode
51
+ style: 'solid' | 'dashed'
52
+ }
53
+
54
+ export type DendrogramLayout = {
55
+ nodes: DendrogramLayoutNode[]
56
+ links: DendrogramLayoutLink[]
57
+ }
58
+
59
+ export type DendrogramOrientation = 'horizontal' | 'vertical'
60
+
61
+ export type DendrogramProps = {
62
+ data: unknown
63
+ width: number
64
+ height: number
65
+ runtime?: Runtime
66
+ }