@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,101 @@
1
+ import type { NetworkColumns, NetworkLink, NetworkPreparedData, NetworkRawRow } from '../types'
2
+ import { sanitizePaletteColor } from '@cdc/core/helpers/palettes/colorValidation'
3
+
4
+ const getLabel = (row: NetworkRawRow, column?: string) => {
5
+ if (!column) return ''
6
+ const value = row[column]
7
+ if (value === null || typeof value === 'undefined') return ''
8
+ if (!['string', 'number'].includes(typeof value)) return ''
9
+ return String(value).trim()
10
+ }
11
+
12
+ const getWeight = (row: NetworkRawRow, column?: string) => {
13
+ if (!column) return 1
14
+ const rawValue = row[column]
15
+ if (typeof rawValue === 'string') {
16
+ const trimmedValue = rawValue.trim()
17
+ if (!/^(?:\d+|\d{1,3}(?:,\d{3})+)(?:\.\d+)?$/.test(trimmedValue)) return null
18
+ }
19
+ const value = typeof rawValue === 'string' ? rawValue.replaceAll(',', '').trim() : rawValue
20
+ const weight = Number(value)
21
+ return Number.isFinite(weight) && weight > 0 ? weight : null
22
+ }
23
+
24
+ const getStyle = (row: NetworkRawRow, column?: string): NetworkLink['style'] => {
25
+ if (!column) return 'solid'
26
+ return String(row[column] ?? '')
27
+ .trim()
28
+ .toLowerCase() === 'dashed'
29
+ ? 'dashed'
30
+ : 'solid'
31
+ }
32
+
33
+ const getNodeColor = (row: NetworkRawRow, column?: string) => {
34
+ if (!column) return ''
35
+ return sanitizePaletteColor(row[column])
36
+ }
37
+
38
+ export const prepareNetworkData = (
39
+ data: unknown,
40
+ columns: NetworkColumns = {},
41
+ directed = false
42
+ ): NetworkPreparedData => {
43
+ const rawRows = Array.isArray(data) ? data : []
44
+ const rows = rawRows.filter(row => row && typeof row === 'object' && !Array.isArray(row)) as NetworkRawRow[]
45
+ const linksByPair = new Map<string, NetworkLink>()
46
+ const nodeColors = new Map<string, string>()
47
+ let rejectedRowCount = rawRows.length - rows.length
48
+
49
+ rows.forEach(row => {
50
+ const source = getLabel(row, columns.source)
51
+ const target = getLabel(row, columns.target)
52
+ const weight = getWeight(row, columns.weight)
53
+ const style = getStyle(row, columns.style)
54
+
55
+ if (!source || !target || source === target || weight === null) {
56
+ rejectedRowCount += 1
57
+ return
58
+ }
59
+
60
+ const [normalizedSource, normalizedTarget] = !directed && source > target ? [target, source] : [source, target]
61
+ const id = JSON.stringify([normalizedSource, normalizedTarget])
62
+ const existing = linksByPair.get(id)
63
+
64
+ if (existing) {
65
+ const aggregatedWeight = existing.weight + weight
66
+ if (!Number.isFinite(aggregatedWeight)) {
67
+ rejectedRowCount += 1
68
+ return
69
+ }
70
+ existing.weight = aggregatedWeight
71
+ existing.rowCount += 1
72
+ if (style === 'dashed') existing.style = 'dashed'
73
+ } else {
74
+ linksByPair.set(id, {
75
+ id,
76
+ source: normalizedSource,
77
+ target: normalizedTarget,
78
+ weight,
79
+ rowCount: 1,
80
+ style
81
+ })
82
+ }
83
+
84
+ const nodeColor = getNodeColor(row, columns.nodeColor)
85
+ if (nodeColor && !nodeColors.has(source)) nodeColors.set(source, nodeColor)
86
+ })
87
+
88
+ const links = Array.from(linksByPair.values()).sort((a, b) => a.id.localeCompare(b.id))
89
+ const connectionCounts = new Map<string, number>()
90
+ links.forEach(link => {
91
+ connectionCounts.set(link.source, (connectionCounts.get(link.source) || 0) + 1)
92
+ connectionCounts.set(link.target, (connectionCounts.get(link.target) || 0) + 1)
93
+ })
94
+
95
+ const nodes = Array.from(connectionCounts, ([id, connectionCount]) => {
96
+ const color = nodeColors.get(id)
97
+ return color ? { id, connectionCount, color } : { id, connectionCount }
98
+ }).sort((a, b) => a.id.localeCompare(b.id))
99
+
100
+ return { nodes, links, rejectedRowCount, hasWeights: Boolean(columns.weight) }
101
+ }
@@ -0,0 +1,112 @@
1
+ import type { NetworkLayoutLink, NetworkLayoutNode } from '../types'
2
+ import { estimateNetworkLabelWidth, resolveNetworkLabels } from './resolveNetworkLabels'
3
+
4
+ const makeNode = (id: string, x: number, y: number): NetworkLayoutNode => ({
5
+ id,
6
+ connectionCount: 1,
7
+ x,
8
+ y
9
+ })
10
+
11
+ const makeLink = (source: NetworkLayoutNode, target: NetworkLayoutNode, strokeWidth = 2): NetworkLayoutLink => ({
12
+ id: `${source.id}-${target.id}`,
13
+ source,
14
+ target,
15
+ weight: 1,
16
+ rowCount: 1,
17
+ style: 'solid',
18
+ strokeWidth
19
+ })
20
+
21
+ describe('resolveNetworkLabels', () => {
22
+ it('moves a label away from a straight connector', () => {
23
+ const labeled = makeNode('A long label', 40, 40)
24
+ const target = makeNode('Target', 160, 40)
25
+
26
+ const placements = resolveNetworkLabels([labeled, target], [makeLink(labeled, target)], { nodeRadius: 6 })
27
+
28
+ expect(placements[0].anchor).toBe('top')
29
+ })
30
+
31
+ it('avoids another node when a conflict-free candidate is available', () => {
32
+ const labeled = makeNode('Long label', 40, 40)
33
+ const blocker = makeNode('Blocker', 85, 40)
34
+
35
+ const placements = resolveNetworkLabels([labeled, blocker], [], { nodeRadius: 15 })
36
+
37
+ expect(placements[0].anchor).toBe('top')
38
+ })
39
+
40
+ it('avoids previously placed label bounds', () => {
41
+ const first = makeNode('Long first label', 40, 40)
42
+ const second = makeNode('Long second label', 40, 50)
43
+
44
+ const placements = resolveNetworkLabels([first, second], [], { nodeRadius: 2 })
45
+
46
+ expect(placements[0].anchor).toBe('right')
47
+ expect(placements[1].anchor).not.toBe('right')
48
+ })
49
+
50
+ it('uses the lowest-conflict stable priority when every candidate is blocked', () => {
51
+ const target = makeNode('Target', 100, 100)
52
+ const blockers = [
53
+ makeNode('top', 100, 70),
54
+ makeNode('right', 145, 100),
55
+ makeNode('bottom', 100, 130),
56
+ makeNode('left', 55, 100)
57
+ ]
58
+ const options = { nodeRadius: 20, labelHeight: 16, labelGap: 0 }
59
+
60
+ const first = resolveNetworkLabels([target, ...blockers], [], options)
61
+ const second = resolveNetworkLabels([target, ...blockers], [], options)
62
+
63
+ expect(second).toEqual(first)
64
+ expect(first[0].anchor).toBe('right')
65
+ })
66
+
67
+ it('accounts for weighted connector stroke width', () => {
68
+ const labeled = makeNode('Wide line', 50, 83)
69
+ const source = makeNode('Source', 0, 100)
70
+ const target = makeNode('Target', 200, 100)
71
+ const thinPlacement = resolveNetworkLabels([labeled, source, target], [makeLink(source, target, 2)], {
72
+ nodeRadius: 6
73
+ })
74
+ const weightedPlacement = resolveNetworkLabels([labeled, source, target], [makeLink(source, target, 16)], {
75
+ nodeRadius: 6
76
+ })
77
+
78
+ expect(thinPlacement[0].anchor).toBe('right')
79
+ expect(weightedPlacement[0].anchor).not.toBe('right')
80
+ })
81
+
82
+ it('uses conservative deterministic estimates for long and Unicode labels', () => {
83
+ const longLabel = 'Network label '.repeat(40)
84
+
85
+ expect(estimateNetworkLabelWidth('MMMM')).toBeGreaterThan(estimateNetworkLabelWidth('iiii'))
86
+ expect(estimateNetworkLabelWidth('健康')).toBeGreaterThanOrEqual(24)
87
+ expect(estimateNetworkLabelWidth('🙂')).toBeGreaterThanOrEqual(12)
88
+ expect(estimateNetworkLabelWidth(longLabel)).toBe(estimateNetworkLabelWidth(longLabel))
89
+ expect(resolveNetworkLabels([makeNode(longLabel, 20, 20)], [], { nodeRadius: 4 })[0].bounds.width).toBe(
90
+ estimateNetworkLabelWidth(longLabel)
91
+ )
92
+ })
93
+
94
+ it('does not mutate nodes, links, or options', () => {
95
+ const source = makeNode('Source', 20, 20)
96
+ const target = makeNode('Target', 160, 20)
97
+ const nodes = [source, target]
98
+ const links = [makeLink(source, target, 8)]
99
+ const options = { nodeRadius: 6, labelHeight: 16, labelGap: 5 }
100
+ const before = structuredClone({ nodes, links, options })
101
+
102
+ resolveNetworkLabels(nodes, links, options)
103
+
104
+ expect({ nodes, links, options }).toEqual(before)
105
+ })
106
+
107
+ it('reserves the rendered line box for the 16px label font', () => {
108
+ const [placement] = resolveNetworkLabels([makeNode('Label', 20, 20)], [], { nodeRadius: 4 })
109
+
110
+ expect(placement.bounds.height).toBe(18)
111
+ })
112
+ })
@@ -0,0 +1,206 @@
1
+ import type { NetworkLayoutLink, NetworkLayoutNode } from '../types'
2
+
3
+ export type NetworkLabelAnchor = 'top' | 'right' | 'bottom' | 'left'
4
+
5
+ export type NetworkLabelBounds = {
6
+ x: number
7
+ y: number
8
+ width: number
9
+ height: number
10
+ }
11
+
12
+ export type NetworkLabelPlacement = {
13
+ nodeId: string
14
+ anchor: NetworkLabelAnchor
15
+ x: number
16
+ y: number
17
+ textAnchor: 'start' | 'middle' | 'end'
18
+ dominantBaseline: 'hanging'
19
+ bounds: NetworkLabelBounds
20
+ }
21
+
22
+ type ResolveOptions = {
23
+ nodeRadius: number
24
+ labelHeight?: number
25
+ labelGap?: number
26
+ }
27
+
28
+ type Point = { x: number; y: number }
29
+
30
+ // A 16px SVG font has an approximately 18px rendered line box in supported browsers.
31
+ const LABEL_HEIGHT = 18
32
+ const LABEL_GAP = 5
33
+ const LINK_CLEARANCE = 1
34
+
35
+ const boundsMayIntersect = (a: NetworkLabelBounds, b: NetworkLabelBounds) =>
36
+ a.x <= b.x + b.width && a.x + a.width >= b.x && a.y <= b.y + b.height && a.y + a.height >= b.y
37
+
38
+ const boundsIntersect = (a: NetworkLabelBounds, b: NetworkLabelBounds) =>
39
+ a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y
40
+
41
+ const expandBounds = (bounds: NetworkLabelBounds, amount: number): NetworkLabelBounds => ({
42
+ x: bounds.x - amount,
43
+ y: bounds.y - amount,
44
+ width: bounds.width + amount * 2,
45
+ height: bounds.height + amount * 2
46
+ })
47
+
48
+ const getSegmentBounds = (start: Point, end: Point, padding: number): NetworkLabelBounds =>
49
+ expandBounds(
50
+ {
51
+ x: Math.min(start.x, end.x),
52
+ y: Math.min(start.y, end.y),
53
+ width: Math.abs(end.x - start.x),
54
+ height: Math.abs(end.y - start.y)
55
+ },
56
+ padding
57
+ )
58
+
59
+ const pointInBounds = (point: Point, bounds: NetworkLabelBounds) =>
60
+ point.x >= bounds.x &&
61
+ point.x <= bounds.x + bounds.width &&
62
+ point.y >= bounds.y &&
63
+ point.y <= bounds.y + bounds.height
64
+
65
+ const segmentIntersectsBounds = (start: Point, end: Point, bounds: NetworkLabelBounds) => {
66
+ if (!boundsMayIntersect(getSegmentBounds(start, end, 0), bounds)) return false
67
+ if (pointInBounds(start, bounds) || pointInBounds(end, bounds)) return true
68
+
69
+ const dx = end.x - start.x
70
+ const dy = end.y - start.y
71
+ const p = [-dx, dx, -dy, dy]
72
+ const q = [
73
+ start.x - bounds.x,
74
+ bounds.x + bounds.width - start.x,
75
+ start.y - bounds.y,
76
+ bounds.y + bounds.height - start.y
77
+ ]
78
+ let minimum = 0
79
+ let maximum = 1
80
+
81
+ for (let index = 0; index < p.length; index += 1) {
82
+ if (p[index] === 0) {
83
+ if (q[index] < 0) return false
84
+ continue
85
+ }
86
+ const ratio = q[index] / p[index]
87
+ if (p[index] < 0) minimum = Math.max(minimum, ratio)
88
+ else maximum = Math.min(maximum, ratio)
89
+ if (minimum > maximum) return false
90
+ }
91
+
92
+ return true
93
+ }
94
+
95
+ export const estimateNetworkLabelWidth = (label: string) =>
96
+ Math.max(
97
+ 1,
98
+ Array.from(label).reduce((width, character) => {
99
+ if (/\s/.test(character)) return width + 5.5
100
+ if (/[ilI1.,'|!:;]/.test(character)) return width + 4.75
101
+ if (/[MW@#%&]/.test(character)) return width + 16
102
+ return width + (character.codePointAt(0)! > 0x7f ? 16 : 10)
103
+ }, 0)
104
+ )
105
+
106
+ const getCandidates = (
107
+ node: NetworkLayoutNode,
108
+ width: number,
109
+ height: number,
110
+ nodeRadius: number,
111
+ gap: number
112
+ ): NetworkLabelPlacement[] => {
113
+ const definitions: Record<
114
+ NetworkLabelAnchor,
115
+ Omit<NetworkLabelPlacement, 'nodeId' | 'anchor' | 'dominantBaseline'>
116
+ > = {
117
+ right: {
118
+ x: nodeRadius + gap,
119
+ y: -height / 2,
120
+ textAnchor: 'start',
121
+ bounds: { x: node.x + nodeRadius + gap, y: node.y - height / 2, width, height }
122
+ },
123
+ top: {
124
+ x: 0,
125
+ y: -nodeRadius - gap - height,
126
+ textAnchor: 'middle',
127
+ bounds: { x: node.x - width / 2, y: node.y - nodeRadius - gap - height, width, height }
128
+ },
129
+ bottom: {
130
+ x: 0,
131
+ y: nodeRadius + gap,
132
+ textAnchor: 'middle',
133
+ bounds: { x: node.x - width / 2, y: node.y + nodeRadius + gap, width, height }
134
+ },
135
+ left: {
136
+ x: -nodeRadius - gap,
137
+ y: -height / 2,
138
+ textAnchor: 'end',
139
+ bounds: { x: node.x - nodeRadius - gap - width, y: node.y - height / 2, width, height }
140
+ }
141
+ }
142
+
143
+ return (['right', 'top', 'bottom', 'left'] as const).map(anchor => ({
144
+ nodeId: node.id,
145
+ anchor,
146
+ dominantBaseline: 'hanging',
147
+ ...definitions[anchor]
148
+ }))
149
+ }
150
+
151
+ export const resolveNetworkLabels = (
152
+ nodes: NetworkLayoutNode[],
153
+ links: NetworkLayoutLink[],
154
+ options: ResolveOptions
155
+ ): NetworkLabelPlacement[] => {
156
+ const nodeRadius = Math.max(2, Number.isFinite(options.nodeRadius) ? options.nodeRadius : 2)
157
+ const labelHeight = Math.max(1, Number.isFinite(options.labelHeight) ? options.labelHeight! : LABEL_HEIGHT)
158
+ const labelGap = Math.max(0, Number.isFinite(options.labelGap) ? options.labelGap! : LABEL_GAP)
159
+ const nodeObstacles = nodes.map(node => ({
160
+ nodeId: node.id,
161
+ bounds: {
162
+ x: node.x - nodeRadius,
163
+ y: node.y - nodeRadius,
164
+ width: nodeRadius * 2,
165
+ height: nodeRadius * 2
166
+ }
167
+ }))
168
+ const linkObstacles = links.map(link => {
169
+ const padding = Math.max(0, Number.isFinite(link.strokeWidth) ? link.strokeWidth / 2 : 0) + LINK_CLEARANCE
170
+ return {
171
+ start: { x: link.source.x, y: link.source.y },
172
+ end: { x: link.target.x, y: link.target.y },
173
+ padding,
174
+ bounds: getSegmentBounds(link.source, link.target, padding)
175
+ }
176
+ })
177
+ const placements: NetworkLabelPlacement[] = []
178
+
179
+ nodes.forEach(node => {
180
+ const candidates = getCandidates(node, estimateNetworkLabelWidth(node.id), labelHeight, nodeRadius, labelGap)
181
+ let bestCandidate = candidates[0]
182
+ let bestScore = Infinity
183
+
184
+ candidates.forEach((candidate, priority) => {
185
+ const nodeConflicts = nodeObstacles.filter(
186
+ obstacle => obstacle.nodeId !== node.id && boundsIntersect(candidate.bounds, obstacle.bounds)
187
+ ).length
188
+ const labelConflicts = placements.filter(placement => boundsIntersect(candidate.bounds, placement.bounds)).length
189
+ const linkConflicts = linkObstacles.filter(
190
+ link =>
191
+ boundsMayIntersect(candidate.bounds, link.bounds) &&
192
+ segmentIntersectsBounds(link.start, link.end, expandBounds(candidate.bounds, link.padding))
193
+ ).length
194
+ const score = nodeConflicts * 10000 + labelConflicts * 10000 + linkConflicts * 100 + priority
195
+
196
+ if (score < bestScore) {
197
+ bestCandidate = candidate
198
+ bestScore = score
199
+ }
200
+ })
201
+
202
+ placements.push(bestCandidate)
203
+ })
204
+
205
+ return placements
206
+ }
@@ -0,0 +1,3 @@
1
+ import Network from './components/Network'
2
+
3
+ export default Network
@@ -0,0 +1,107 @@
1
+ .network-chart__container {
2
+ display: flex;
3
+ flex-direction: column;
4
+ position: relative;
5
+ }
6
+
7
+ .network-chart__plot {
8
+ flex: 1 1 auto;
9
+ min-height: 0;
10
+ position: relative;
11
+ }
12
+
13
+ .network-chart {
14
+ cursor: grab;
15
+ display: block;
16
+ overflow: hidden;
17
+ touch-action: none;
18
+ }
19
+
20
+ .network-chart:active {
21
+ cursor: grabbing;
22
+ }
23
+
24
+ .network-chart__controls {
25
+ align-items: center;
26
+ bottom: 2em;
27
+ display: flex;
28
+ gap: 0.25rem;
29
+ left: 1em;
30
+ position: absolute;
31
+ z-index: 4;
32
+ }
33
+
34
+ .network-chart__controls .cove-button {
35
+ min-height: 2rem;
36
+ min-width: 2rem;
37
+ }
38
+
39
+ .network-chart__status {
40
+ background: rgb(255 255 255 / 90%);
41
+ color: #666;
42
+ left: 0;
43
+ padding: 0.25rem 0.5rem;
44
+ position: absolute;
45
+ top: 0;
46
+ z-index: 1;
47
+ }
48
+
49
+ .network-chart__link,
50
+ .network-chart__link-group,
51
+ .network-chart__node {
52
+ outline: none;
53
+ }
54
+
55
+ .network-chart__link-group:focus-visible,
56
+ .network-chart__node:focus-visible {
57
+ filter: drop-shadow(0 0 2px #fff) drop-shadow(0 0 4px #005ea8);
58
+ }
59
+
60
+ .network-chart__link--dashed {
61
+ stroke-dasharray: 12 8;
62
+ }
63
+
64
+ .network-chart__link-key-line {
65
+ fill: none;
66
+ }
67
+
68
+ .network-chart__label {
69
+ font-size: 16px;
70
+ pointer-events: none;
71
+ }
72
+
73
+ .network-chart__link-key {
74
+ align-items: center;
75
+ display: flex;
76
+ flex: 0 0 32px;
77
+ font-size: 12px;
78
+ gap: 1rem;
79
+ justify-content: flex-end;
80
+ min-width: 0;
81
+ overflow-x: auto;
82
+ overflow-y: hidden;
83
+ padding: 0 0.5rem;
84
+ white-space: nowrap;
85
+ }
86
+
87
+ .network-chart__link-key-item {
88
+ align-items: center;
89
+ display: flex;
90
+ flex: 0 0 auto;
91
+ gap: 0.35rem;
92
+ }
93
+
94
+ .network-chart__message {
95
+ color: #666;
96
+ padding: 2rem;
97
+ text-align: center;
98
+ }
99
+
100
+ .network-chart__tooltip {
101
+ --rt-opacity: 1;
102
+
103
+ background: #fff;
104
+ border: 1px solid #bdbdbd;
105
+ box-shadow: 0 3px 10px rgb(0 0 0 / 20%);
106
+ color: var(--cool-gray-90, #1b1b1b);
107
+ }
@@ -0,0 +1,56 @@
1
+ import type { Runtime } from '@cdc/core/types/Runtime'
2
+
3
+ export type NetworkRawRow = Record<string, unknown>
4
+
5
+ export type NetworkColumns = {
6
+ source?: string
7
+ target?: string
8
+ weight?: string
9
+ style?: string
10
+ nodeColor?: string
11
+ }
12
+
13
+ export type NetworkNode = {
14
+ id: string
15
+ connectionCount: number
16
+ color?: string
17
+ }
18
+
19
+ export type NetworkLink = {
20
+ id: string
21
+ source: string
22
+ target: string
23
+ weight: number
24
+ rowCount: number
25
+ style: 'solid' | 'dashed'
26
+ }
27
+
28
+ export type NetworkPreparedData = {
29
+ nodes: NetworkNode[]
30
+ links: NetworkLink[]
31
+ rejectedRowCount: number
32
+ hasWeights: boolean
33
+ }
34
+
35
+ export type NetworkLayoutNode = NetworkNode & {
36
+ x: number
37
+ y: number
38
+ }
39
+
40
+ export type NetworkLayoutLink = Omit<NetworkLink, 'source' | 'target'> & {
41
+ source: NetworkLayoutNode
42
+ target: NetworkLayoutNode
43
+ strokeWidth: number
44
+ }
45
+
46
+ export type NetworkLayout = {
47
+ nodes: NetworkLayoutNode[]
48
+ links: NetworkLayoutLink[]
49
+ }
50
+
51
+ export type NetworkProps = {
52
+ data: unknown
53
+ width: number
54
+ height: number
55
+ runtime?: Runtime
56
+ }
@@ -8,6 +8,7 @@ import ConfigContext from '../ConfigContext'
8
8
  import { getContrastColor } from '@cdc/core/helpers/cove/accessibility'
9
9
  import { APP_FONT_COLOR } from '@cdc/core/helpers/constants'
10
10
  import { getTextWidth } from '@cdc/core/helpers/getTextWidth'
11
+ import { getSeriesValueLabel } from '@cdc/core/helpers/getSeriesName'
11
12
  import { isMobileFontViewport } from '@cdc/core/helpers/viewports'
12
13
 
13
14
  const PairedBarChart = ({ width, height, originalWidth, yAxisWidth }) => {
@@ -28,9 +29,8 @@ const PairedBarChart = ({ width, height, originalWidth, yAxisWidth }) => {
28
29
  const offset = 1.02 // Offset of the left bar from the Axis
29
30
 
30
31
  const groupOne = {
31
- parentKey: config.dataDescription?.seriesKey,
32
32
  dataKey: config.series[0].dataKey,
33
- dataKeyLabel: config.runtime.seriesLabels[config.series[0].dataKey] || config.series[0].dataKey,
33
+ dataKeyLabel: getSeriesValueLabel(config.series[0].dataKey, config),
34
34
  color: colorScale(config.runtime.seriesLabels[config.series[0].dataKey]),
35
35
  max: Math.max.apply(
36
36
  Math,
@@ -40,9 +40,8 @@ const PairedBarChart = ({ width, height, originalWidth, yAxisWidth }) => {
40
40
  }
41
41
 
42
42
  const groupTwo = {
43
- parentKey: config.dataDescription?.seriesKey,
44
43
  dataKey: config.series[1].dataKey,
45
- dataKeyLabel: config.runtime.seriesLabels[config.series[1].dataKey] || config.series[1].dataKey,
44
+ dataKeyLabel: getSeriesValueLabel(config.series[1].dataKey, config),
46
45
  color: colorScale(config.runtime.seriesLabels[config.series[1].dataKey]),
47
46
  max: Math.max.apply(
48
47
  Math,
@@ -60,21 +59,17 @@ const PairedBarChart = ({ width, height, originalWidth, yAxisWidth }) => {
60
59
  groupOne.labelColor = groupOne.color ? getContrastColor(APP_FONT_COLOR, groupOne.color) : APP_FONT_COLOR
61
60
  groupTwo.labelColor = groupTwo.color ? getContrastColor(APP_FONT_COLOR, groupTwo.color) : APP_FONT_COLOR
62
61
 
63
- const label = config.yAxis.label ? `${config.yAxis.label}: ` : ''
64
-
65
62
  const dataTipOne = d => {
66
63
  return `<p>
67
- ${config.dataDescription?.seriesKey}: ${groupOne.dataKeyLabel}<br/>
68
64
  ${config.xAxis.dataKey}: ${d[config.xAxis.dataKey]}<br/>
69
- ${label}${formatNumber(d[groupOne.dataKey], 'left')}
65
+ ${groupOne.dataKeyLabel}: ${formatNumber(d[groupOne.dataKey], 'left')}
70
66
  </p>`
71
67
  }
72
68
 
73
69
  const dataTipTwo = d => {
74
70
  return `<p>
75
- ${config.dataDescription?.seriesKey}: ${groupTwo.dataKeyLabel}<br/>
76
71
  ${config.xAxis.dataKey}: ${d[config.xAxis.dataKey]}<br/>
77
- ${label}${formatNumber(d[groupTwo.dataKey], 'left')}
72
+ ${groupTwo.dataKeyLabel}: ${formatNumber(d[groupTwo.dataKey], 'left')}
78
73
  </p>`
79
74
  }
80
75