@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,372 @@
1
+ import { useCallback, useContext, useEffect, useId, useMemo, useRef, useState } from 'react'
2
+ import { Graph } from '@visx/network'
3
+ import { Tooltip as ReactTooltip } from 'react-tooltip'
4
+ import 'react-tooltip/dist/react-tooltip.css'
5
+
6
+ import ConfigContext from '../../../ConfigContext'
7
+ import type { ChartContext } from '../../../types/ChartContext'
8
+ import { layoutNetwork, resolveNetworkNodeRadius } from '../helpers/layoutNetwork'
9
+ import { prepareNetworkData } from '../helpers/prepareNetworkData'
10
+ import { resolveNetworkLabels } from '../helpers/resolveNetworkLabels'
11
+ import type { NetworkLayoutLink, NetworkLayoutNode, NetworkProps } from '../types'
12
+ import Button from '@cdc/core/components/elements/Button'
13
+ import { sanitizePaletteColor } from '@cdc/core/helpers/palettes/colorValidation'
14
+ import '../network.css'
15
+
16
+ type NetworkView = {
17
+ rotation: number
18
+ scale: number
19
+ offsetX: number
20
+ offsetY: number
21
+ }
22
+
23
+ const LINK_KEY_HEIGHT = 32
24
+ const DEFAULT_LINK_STYLE_LABELS = { solid: 'Solid', dashed: 'Dashed' } as const
25
+
26
+ const Network = ({ data, width, height, runtime }: NetworkProps) => {
27
+ const { config, handleChartAriaLabels } = useContext<ChartContext>(ConfigContext)
28
+ const reactId = useId().replace(/:/g, '')
29
+ const network = config.network
30
+ const prepared = useMemo(
31
+ () => prepareNetworkData(data, network?.columns, network?.directed),
32
+ [data, network?.columns, network?.directed]
33
+ )
34
+ const parsedWidth = Number(width)
35
+ const parsedAuthoredHeight = Number(network?.height)
36
+ const parsedParentHeight = Number(height)
37
+ const chartWidth = Math.max(Number.isFinite(parsedWidth) ? parsedWidth : 640, 160)
38
+ const chartHeight = Math.max(
39
+ Number.isFinite(parsedAuthoredHeight)
40
+ ? parsedAuthoredHeight
41
+ : Number.isFinite(parsedParentHeight)
42
+ ? parsedParentHeight
43
+ : 500,
44
+ 160
45
+ )
46
+ const linkStyleColumn = network?.columns?.style
47
+ const hasMappedLinkStyle = Boolean(
48
+ linkStyleColumn &&
49
+ Array.isArray(data) &&
50
+ data.some(row => row && typeof row === 'object' && !Array.isArray(row) && linkStyleColumn in row)
51
+ )
52
+ const renderedLinkStyles = useMemo(
53
+ () =>
54
+ hasMappedLinkStyle && !config.legend?.hide
55
+ ? (['solid', 'dashed'] as const).filter(style => prepared.links.some(link => link.style === style))
56
+ : [],
57
+ [config.legend?.hide, hasMappedLinkStyle, prepared.links]
58
+ )
59
+ const plotHeight = chartHeight - (renderedLinkStyles.length ? LINK_KEY_HEIGHT : 0)
60
+ const layout = useMemo(
61
+ () =>
62
+ layoutNetwork(prepared, {
63
+ width: chartWidth,
64
+ height: plotHeight,
65
+ nodeRadius: network?.nodeRadius ?? 8,
66
+ linkDistance: network?.linkDistance ?? 90,
67
+ chargeStrength: network?.chargeStrength ?? -240
68
+ }),
69
+ [chartWidth, network, plotHeight, prepared]
70
+ )
71
+ const constrainedNodeRadius = resolveNetworkNodeRadius(network?.nodeRadius ?? 8, chartWidth, plotHeight)
72
+ const resolvedLabels = useMemo(
73
+ () => resolveNetworkLabels(layout.nodes, layout.links, { nodeRadius: constrainedNodeRadius }),
74
+ [constrainedNodeRadius, layout.links, layout.nodes]
75
+ )
76
+ const labelsByNodeId = useMemo(
77
+ () => new Map(resolvedLabels.map(placement => [placement.nodeId, placement])),
78
+ [resolvedLabels]
79
+ )
80
+ const contentPoints = useMemo(
81
+ () => [
82
+ ...layout.nodes.flatMap(node => [
83
+ { x: node.x - constrainedNodeRadius, y: node.y - constrainedNodeRadius },
84
+ { x: node.x + constrainedNodeRadius, y: node.y - constrainedNodeRadius },
85
+ { x: node.x + constrainedNodeRadius, y: node.y + constrainedNodeRadius },
86
+ { x: node.x - constrainedNodeRadius, y: node.y + constrainedNodeRadius }
87
+ ]),
88
+ ...resolvedLabels.flatMap(({ bounds }) => [
89
+ { x: bounds.x, y: bounds.y },
90
+ { x: bounds.x + bounds.width, y: bounds.y },
91
+ { x: bounds.x + bounds.width, y: bounds.y + bounds.height },
92
+ { x: bounds.x, y: bounds.y + bounds.height }
93
+ ])
94
+ ],
95
+ [constrainedNodeRadius, layout.nodes, resolvedLabels]
96
+ )
97
+ const constrainView = useCallback(
98
+ (nextView: NetworkView): NetworkView => {
99
+ if (!contentPoints.length) return { ...nextView, offsetX: 0, offsetY: 0 }
100
+ const normalizedRotation = Number.isFinite(nextView.rotation) ? ((nextView.rotation % 360) + 360) % 360 : 0
101
+ const radians = (normalizedRotation * Math.PI) / 180
102
+ const cosine = Math.cos(radians)
103
+ const sine = Math.sin(radians)
104
+ const centerX = chartWidth / 2
105
+ const centerY = plotHeight / 2
106
+ const transformedPoints = contentPoints.map(point => {
107
+ const x = point.x - centerX
108
+ const y = point.y - centerY
109
+ return {
110
+ x: centerX + nextView.scale * (cosine * x - sine * y),
111
+ y: centerY + nextView.scale * (sine * x + cosine * y)
112
+ }
113
+ })
114
+ const extents = transformedPoints.reduce(
115
+ (result, point) => ({
116
+ minX: Math.min(result.minX, point.x),
117
+ maxX: Math.max(result.maxX, point.x),
118
+ minY: Math.min(result.minY, point.y),
119
+ maxY: Math.max(result.maxY, point.y)
120
+ }),
121
+ { minX: Infinity, maxX: -Infinity, minY: Infinity, maxY: -Infinity }
122
+ )
123
+ const minX = extents.minX - 4
124
+ const maxX = extents.maxX + 4
125
+ const minY = extents.minY - 4
126
+ const maxY = extents.maxY + 4
127
+ const horizontalLimits = [chartWidth - maxX, -minX].sort((a, b) => a - b)
128
+ const verticalLimits = [plotHeight - maxY, -minY].sort((a, b) => a - b)
129
+
130
+ return {
131
+ ...nextView,
132
+ rotation: normalizedRotation,
133
+ offsetX: Math.min(horizontalLimits[1], Math.max(horizontalLimits[0], nextView.offsetX)),
134
+ offsetY: Math.min(verticalLimits[1], Math.max(verticalLimits[0], nextView.offsetY))
135
+ }
136
+ },
137
+ [chartWidth, contentPoints, plotHeight]
138
+ )
139
+ const getInitialView = useCallback(
140
+ () =>
141
+ constrainView({
142
+ rotation: Number.isFinite(Number(network?.rotation)) ? Number(network.rotation) : 0,
143
+ scale: Math.min(4, Math.max(0.25, Number.isFinite(Number(network?.scale)) ? Number(network.scale) : 1)),
144
+ offsetX: Number.isFinite(Number(network?.offsetX)) ? Number(network.offsetX) : 0,
145
+ offsetY: Number.isFinite(Number(network?.offsetY)) ? Number(network.offsetY) : 0
146
+ }),
147
+ [constrainView, network?.offsetX, network?.offsetY, network?.rotation, network?.scale]
148
+ )
149
+ const [view, setView] = useState(getInitialView)
150
+ const dragStart = useRef<{ x: number; y: number; offsetX: number; offsetY: number } | null>(null)
151
+
152
+ useEffect(() => {
153
+ setView(getInitialView())
154
+ }, [getInitialView])
155
+
156
+ if (config.visualizationType !== 'Network') return null
157
+
158
+ if (!network?.columns?.source || !network?.columns?.target) {
159
+ return <div className='network-chart__message'>Select source and target columns to display the network.</div>
160
+ }
161
+
162
+ if (!layout.links.length) {
163
+ return (
164
+ <div className='network-chart__message'>
165
+ No valid connections are available. Check the selected columns and remove blank endpoints, self-links, or
166
+ invalid weights.
167
+ {prepared.rejectedRowCount > 0 && ` ${prepared.rejectedRowCount} row(s) were rejected.`}
168
+ </div>
169
+ )
170
+ }
171
+
172
+ const instanceId = `${runtime?.uniqueId || 'chart'}-${reactId}`
173
+ const tooltipId = `cdc-open-viz-tooltip-${instanceId}-network`
174
+ const markerId = `network-arrow-${instanceId}`
175
+ const nodeRadius = constrainedNodeRadius
176
+ const nodeColor = sanitizePaletteColor(network.nodeColor) || '#005eaa'
177
+ const linkColor = sanitizePaletteColor(network.linkColor) || '#333333'
178
+ const getLinkStyleLabel = (style: (typeof renderedLinkStyles)[number]) => {
179
+ const authoredLabel: unknown = network.linkStyleLabels?.[style]
180
+ return typeof authoredLabel === 'string' && authoredLabel.trim()
181
+ ? authoredLabel.trim()
182
+ : DEFAULT_LINK_STYLE_LABELS[style]
183
+ }
184
+ const viewTransform = `translate(${chartWidth / 2 + view.offsetX} ${plotHeight / 2 + view.offsetY}) rotate(${
185
+ view.rotation
186
+ }) scale(${view.scale}) translate(${-chartWidth / 2} ${-plotHeight / 2})`
187
+ const zoomBy = (factor: number) =>
188
+ setView(current => constrainView({ ...current, scale: Math.min(4, Math.max(0.25, current.scale * factor)) }))
189
+ const resetView = () => setView(getInitialView())
190
+ const handlePointerDown = (event: React.PointerEvent<SVGSVGElement>) => {
191
+ event.currentTarget.setPointerCapture(event.pointerId)
192
+ dragStart.current = { x: event.clientX, y: event.clientY, offsetX: view.offsetX, offsetY: view.offsetY }
193
+ }
194
+ const handlePointerMove = (event: React.PointerEvent<SVGSVGElement>) => {
195
+ if (!dragStart.current) return
196
+ const bounds = event.currentTarget.getBoundingClientRect()
197
+ const scaleX = bounds.width ? chartWidth / bounds.width : 1
198
+ const scaleY = bounds.height ? plotHeight / bounds.height : 1
199
+ setView(current =>
200
+ constrainView({
201
+ ...current,
202
+ offsetX: dragStart.current!.offsetX + (event.clientX - dragStart.current!.x) * scaleX,
203
+ offsetY: dragStart.current!.offsetY + (event.clientY - dragStart.current!.y) * scaleY
204
+ })
205
+ )
206
+ }
207
+ const handlePointerUp = (event: React.PointerEvent<SVGSVGElement>) => {
208
+ dragStart.current = null
209
+ if (event.currentTarget.hasPointerCapture(event.pointerId))
210
+ event.currentTarget.releasePointerCapture(event.pointerId)
211
+ }
212
+
213
+ const LinkComponent = ({ link }: { link: NetworkLayoutLink }) => {
214
+ const endpoints = network.directed
215
+ ? `${link.source.id} to ${link.target.id}`
216
+ : `${link.source.id} and ${link.target.id}`
217
+ const label = `${endpoints}, ${link.style} link${prepared.hasWeights ? `, weight ${link.weight}` : ''}`
218
+ const deltaX = link.target.x - link.source.x
219
+ const deltaY = link.target.y - link.source.y
220
+ const distance = Math.hypot(deltaX, deltaY) || 1
221
+ const offsetX = (deltaX / distance) * nodeRadius
222
+ const offsetY = (deltaY / distance) * nodeRadius
223
+ const lineCoordinates = {
224
+ x1: link.source.x + offsetX,
225
+ y1: link.source.y + offsetY,
226
+ x2: link.target.x - offsetX,
227
+ y2: link.target.y - offsetY
228
+ }
229
+ return (
230
+ <g
231
+ className='network-chart__link-group'
232
+ tabIndex={0}
233
+ role='img'
234
+ aria-label={label}
235
+ data-tooltip-id={config.enableTooltips ? tooltipId : undefined}
236
+ data-tooltip-content={config.enableTooltips ? label : undefined}
237
+ >
238
+ <line
239
+ {...lineCoordinates}
240
+ className='network-chart__link-hitbox'
241
+ stroke='transparent'
242
+ strokeWidth={Math.max(link.strokeWidth, 12)}
243
+ pointerEvents='stroke'
244
+ />
245
+ <line
246
+ {...lineCoordinates}
247
+ aria-hidden='true'
248
+ className={`network-chart__link${link.style === 'dashed' ? ' network-chart__link--dashed' : ''}`}
249
+ stroke={linkColor}
250
+ strokeWidth={link.strokeWidth}
251
+ markerEnd={network.directed ? `url(#${markerId})` : undefined}
252
+ />
253
+ </g>
254
+ )
255
+ }
256
+
257
+ const NodeComponent = ({ node }: { node: NetworkLayoutNode }) => {
258
+ const placement = labelsByNodeId.get(node.id)
259
+ const label = `${node.id}, ${node.connectionCount} connection${node.connectionCount === 1 ? '' : 's'}`
260
+ return (
261
+ <g
262
+ className='network-chart__node'
263
+ tabIndex={0}
264
+ role='img'
265
+ aria-label={label}
266
+ data-tooltip-id={config.enableTooltips ? tooltipId : undefined}
267
+ data-tooltip-content={config.enableTooltips ? label : undefined}
268
+ >
269
+ <circle r={nodeRadius} fill={node.color || nodeColor} />
270
+ {placement && (
271
+ <text
272
+ className={`network-chart__label network-chart__label--${placement.anchor}`}
273
+ x={placement.x}
274
+ y={placement.y}
275
+ textAnchor={placement.textAnchor}
276
+ dominantBaseline={placement.dominantBaseline}
277
+ fill='currentColor'
278
+ >
279
+ {node.id}
280
+ </text>
281
+ )}
282
+ </g>
283
+ )
284
+ }
285
+
286
+ return (
287
+ <div className='network-chart__container' style={{ height: chartHeight }}>
288
+ {renderedLinkStyles.length > 0 && (
289
+ <div className='network-chart__link-key' role='group' aria-label='Link styles'>
290
+ {renderedLinkStyles.map(style => (
291
+ <div className='network-chart__link-key-item' key={style}>
292
+ <svg aria-hidden='true' width='36' height='12' viewBox='0 0 36 12'>
293
+ <line
294
+ className={`network-chart__link-key-line${style === 'dashed' ? ' network-chart__link--dashed' : ''}`}
295
+ x1='1'
296
+ x2='35'
297
+ y1='6'
298
+ y2='6'
299
+ stroke={linkColor}
300
+ strokeWidth='2'
301
+ />
302
+ </svg>
303
+ <span>{getLinkStyleLabel(style)}</span>
304
+ </div>
305
+ ))}
306
+ </div>
307
+ )}
308
+ <div className='network-chart__plot'>
309
+ {prepared.rejectedRowCount > 0 && (
310
+ <div className='network-chart__status' role='status'>
311
+ {prepared.rejectedRowCount} invalid row(s) excluded.
312
+ </div>
313
+ )}
314
+ <svg
315
+ className='network-chart'
316
+ width='100%'
317
+ height='100%'
318
+ viewBox={`0 0 ${chartWidth} ${plotHeight}`}
319
+ role='group'
320
+ aria-label={handleChartAriaLabels(config)}
321
+ onPointerDown={handlePointerDown}
322
+ onPointerMove={handlePointerMove}
323
+ onPointerUp={handlePointerUp}
324
+ onPointerCancel={handlePointerUp}
325
+ onLostPointerCapture={() => {
326
+ dragStart.current = null
327
+ }}
328
+ >
329
+ <defs>
330
+ <marker
331
+ id={markerId}
332
+ viewBox='0 0 10 10'
333
+ refX='10'
334
+ refY='5'
335
+ markerWidth='7'
336
+ markerHeight='7'
337
+ orient='auto-start-reverse'
338
+ >
339
+ <path d='M 0 0 L 10 5 L 0 10 z' fill={linkColor} />
340
+ </marker>
341
+ </defs>
342
+ <g className='network-chart__viewport' transform={viewTransform}>
343
+ <Graph<NetworkLayoutLink, NetworkLayoutNode>
344
+ graph={layout}
345
+ linkComponent={LinkComponent}
346
+ nodeComponent={NodeComponent}
347
+ />
348
+ </g>
349
+ </svg>
350
+ <div
351
+ className='network-chart__controls'
352
+ role='group'
353
+ aria-label='Network view controls'
354
+ data-html2canvas-ignore='true'
355
+ >
356
+ <Button type='button' variant='light' size='sm' onClick={() => zoomBy(1.2)} aria-label='Zoom in'>
357
+ +
358
+ </Button>
359
+ <Button type='button' variant='light' size='sm' onClick={() => zoomBy(1 / 1.2)} aria-label='Zoom out'>
360
+ −
361
+ </Button>
362
+ <Button type='button' variant='light' size='sm' onClick={resetView} aria-label='Reset view'>
363
+ Reset
364
+ </Button>
365
+ </div>
366
+ {config.enableTooltips && <ReactTooltip id={tooltipId} variant='light' className='network-chart__tooltip' />}
367
+ </div>
368
+ </div>
369
+ )
370
+ }
371
+
372
+ export default Network
@@ -0,0 +1,68 @@
1
+ import { layoutNetwork } from './layoutNetwork'
2
+ import { prepareNetworkData } from './prepareNetworkData'
3
+
4
+ describe('layoutNetwork', () => {
5
+ const prepared = prepareNetworkData(
6
+ [
7
+ { source: 'A', target: 'B', weight: 1 },
8
+ { source: 'B', target: 'C', weight: 5 },
9
+ { source: 'C', target: 'A', weight: 10 }
10
+ ],
11
+ { source: 'source', target: 'target', weight: 'weight' }
12
+ )
13
+ const options = { width: 480, height: 320, nodeRadius: 8, linkDistance: 80, chargeStrength: -200 }
14
+
15
+ it('produces stable coordinates within responsive bounds', () => {
16
+ const first = layoutNetwork(prepared, options)
17
+ const second = layoutNetwork(prepared, options)
18
+
19
+ expect(second).toEqual(first)
20
+ first.nodes.forEach(node => {
21
+ expect(node.x).toBeGreaterThanOrEqual(80)
22
+ expect(node.x).toBeLessThanOrEqual(400)
23
+ expect(node.y).toBeGreaterThanOrEqual(20)
24
+ expect(node.y).toBeLessThanOrEqual(300)
25
+ })
26
+ })
27
+
28
+ it('scales weighted link widths within fixed bounds', () => {
29
+ const result = layoutNetwork(prepared, options)
30
+ expect(Math.min(...result.links.map(link => link.strokeWidth))).toBeGreaterThanOrEqual(1.5)
31
+ expect(Math.max(...result.links.map(link => link.strokeWidth))).toBeLessThanOrEqual(8)
32
+ })
33
+
34
+ it('clamps authored layout values at their supported minimums while allowing zero charge', () => {
35
+ const result = layoutNetwork(prepared, {
36
+ ...options,
37
+ nodeRadius: 0,
38
+ linkDistance: 0,
39
+ chargeStrength: 0
40
+ })
41
+
42
+ result.nodes.forEach(node => {
43
+ expect(node.x).toBeGreaterThanOrEqual(80)
44
+ expect(node.x).toBeLessThanOrEqual(400)
45
+ })
46
+ expect(result.links).toHaveLength(3)
47
+ })
48
+
49
+ it('keeps nodes in bounds when the authored radius exceeds the viewport', () => {
50
+ const result = layoutNetwork(prepared, { ...options, width: 160, height: 160, nodeRadius: 500 })
51
+
52
+ result.nodes.forEach(node => {
53
+ expect(node.x).toBeGreaterThanOrEqual(80)
54
+ expect(node.x).toBeLessThanOrEqual(80)
55
+ expect(node.y).toBeGreaterThanOrEqual(20)
56
+ expect(node.y).toBeLessThanOrEqual(140)
57
+ })
58
+ })
59
+
60
+ it('uses the exact plot height after the link-style key reserves outer chart space', () => {
61
+ const result = layoutNetwork(prepared, { ...options, height: 128 })
62
+
63
+ result.nodes.forEach(node => {
64
+ expect(node.y).toBeGreaterThanOrEqual(20)
65
+ expect(node.y).toBeLessThanOrEqual(108)
66
+ })
67
+ })
68
+ })
@@ -0,0 +1,87 @@
1
+ import { forceCenter, forceCollide, forceLink, forceManyBody, forceSimulation } from 'd3-force'
2
+ import type { NetworkLayout, NetworkLayoutLink, NetworkLayoutNode, NetworkPreparedData } from '../types'
3
+
4
+ type LayoutOptions = {
5
+ width: number
6
+ height: number
7
+ nodeRadius: number
8
+ linkDistance: number
9
+ chargeStrength: number
10
+ }
11
+
12
+ const seededRandom = () => {
13
+ let seed = 0x2f6e2b1
14
+ return () => {
15
+ seed = (Math.imul(1664525, seed) + 1013904223) >>> 0
16
+ return seed / 4294967296
17
+ }
18
+ }
19
+
20
+ export const resolveNetworkNodeRadius = (radius: number, width: number, height: number) => {
21
+ const parsedRadius = Number(radius)
22
+ const requestedRadius = Math.max(2, Number.isFinite(parsedRadius) ? parsedRadius : 8)
23
+ const maxRadius = Math.max(2, Math.min(width / 2 - 72, height / 2 - 12))
24
+ return Math.min(requestedRadius, maxRadius)
25
+ }
26
+
27
+ export const layoutNetwork = (prepared: NetworkPreparedData, options: LayoutOptions): NetworkLayout => {
28
+ const width = Math.max(160, Number(options.width) || 640)
29
+ // The outer chart enforces its 160px minimum before reserving space for the link-style key.
30
+ const height = Math.max(1, Number(options.height) || 500)
31
+ const radius = resolveNetworkNodeRadius(options.nodeRadius, width, height)
32
+ const horizontalPadding = radius + 72
33
+ const verticalPadding = radius + 12
34
+ const usableWidth = Math.max(1, width - horizontalPadding * 2)
35
+ const usableHeight = Math.max(1, height - verticalPadding * 2)
36
+ const nodes: NetworkLayoutNode[] = prepared.nodes.map((node, index) => {
37
+ const angle = (Math.PI * 2 * index) / Math.max(prepared.nodes.length, 1) - Math.PI / 2
38
+ return {
39
+ ...node,
40
+ x: width / 2 + Math.cos(angle) * usableWidth * 0.32,
41
+ y: height / 2 + Math.sin(angle) * usableHeight * 0.32
42
+ }
43
+ })
44
+ const links = prepared.links.map(link => ({ ...link })) as unknown as NetworkLayoutLink[]
45
+
46
+ if (nodes.length) {
47
+ const simulation = forceSimulation(nodes)
48
+ .randomSource(seededRandom())
49
+ .force(
50
+ 'link',
51
+ forceLink<NetworkLayoutNode, NetworkLayoutLink>(links)
52
+ .id(node => node.id)
53
+ .distance(Math.max(20, Number.isFinite(Number(options.linkDistance)) ? Number(options.linkDistance) : 90))
54
+ )
55
+ .force(
56
+ 'charge',
57
+ forceManyBody().strength(
58
+ Number.isFinite(Number(options.chargeStrength)) ? Number(options.chargeStrength) : -240
59
+ )
60
+ )
61
+ .force('center', forceCenter(width / 2, height / 2))
62
+ .force('collide', forceCollide(radius + 5))
63
+ .stop()
64
+
65
+ for (let tick = 0; tick < 300; tick += 1) simulation.tick()
66
+ simulation.stop()
67
+ }
68
+
69
+ nodes.forEach(node => {
70
+ const x = Number(node.x)
71
+ const y = Number(node.y)
72
+ node.x = Math.min(width - horizontalPadding, Math.max(horizontalPadding, Number.isFinite(x) ? x : width / 2))
73
+ node.y = Math.min(height - verticalPadding, Math.max(verticalPadding, Number.isFinite(y) ? y : height / 2))
74
+ })
75
+
76
+ const weights = prepared.links.map(link => link.weight)
77
+ const minWeight = Math.min(...weights)
78
+ const maxWeight = Math.max(...weights)
79
+ const getStrokeWidth = (weight: number) =>
80
+ !prepared.hasWeights || minWeight === maxWeight ? 2 : 1.5 + ((weight - minWeight) / (maxWeight - minWeight)) * 6.5
81
+
82
+ links.forEach(link => {
83
+ link.strokeWidth = getStrokeWidth(link.weight)
84
+ })
85
+
86
+ return { nodes, links }
87
+ }
@@ -0,0 +1,116 @@
1
+ import { prepareNetworkData } from './prepareNetworkData'
2
+
3
+ describe('prepareNetworkData', () => {
4
+ it('aggregates duplicates and reverse pairs for an undirected network', () => {
5
+ const result = prepareNetworkData(
6
+ [
7
+ { from: 'A', to: 'B', amount: '1,200' },
8
+ { from: 'B', to: 'A', amount: 300 }
9
+ ],
10
+ { source: 'from', target: 'to', weight: 'amount' }
11
+ )
12
+
13
+ expect(result.links).toEqual([expect.objectContaining({ source: 'A', target: 'B', weight: 1500, rowCount: 2 })])
14
+ expect(result.nodes).toEqual([
15
+ { id: 'A', connectionCount: 1 },
16
+ { id: 'B', connectionCount: 1 }
17
+ ])
18
+ })
19
+
20
+ it('keeps reverse pairs distinct for a directed network', () => {
21
+ const result = prepareNetworkData(
22
+ [
23
+ { source: 'A', target: 'B' },
24
+ { source: 'B', target: 'A' }
25
+ ],
26
+ { source: 'source', target: 'target' },
27
+ true
28
+ )
29
+
30
+ expect(result.links).toHaveLength(2)
31
+ })
32
+
33
+ it('maps per-row link styles and lets dashed win when duplicate links aggregate', () => {
34
+ const result = prepareNetworkData(
35
+ [
36
+ { source: 'A', target: 'B', style: 'solid' },
37
+ { source: 'B', target: 'A', style: 'DASHED' },
38
+ { source: 'B', target: 'C', style: 'unknown' }
39
+ ],
40
+ { source: 'source', target: 'target', style: 'style' }
41
+ )
42
+
43
+ expect(result.links).toEqual([
44
+ expect.objectContaining({ source: 'A', target: 'B', style: 'dashed', weight: 2, rowCount: 2 }),
45
+ expect.objectContaining({ source: 'B', target: 'C', style: 'solid' })
46
+ ])
47
+ })
48
+
49
+ it('maps source-node colors and keeps the first valid color assigned to a node', () => {
50
+ const result = prepareNetworkData(
51
+ [
52
+ { source: 'A', target: 'B', nodeColor: '#005eaa' },
53
+ { source: 'B', target: 'C', nodeColor: '#4b830d' },
54
+ { source: 'B', target: 'D', nodeColor: '#712177' },
55
+ { source: 'C', target: 'D', nodeColor: 'not-a-color' }
56
+ ],
57
+ {
58
+ source: 'source',
59
+ target: 'target',
60
+ nodeColor: 'nodeColor'
61
+ }
62
+ )
63
+
64
+ expect(result.nodes).toEqual([
65
+ { id: 'A', connectionCount: 1, color: '#005eaa' },
66
+ { id: 'B', connectionCount: 3, color: '#4b830d' },
67
+ { id: 'C', connectionCount: 2 },
68
+ { id: 'D', connectionCount: 2 }
69
+ ])
70
+ })
71
+
72
+ it('rejects blank endpoints, self-links, and invalid selected weights', () => {
73
+ const result = prepareNetworkData(
74
+ [
75
+ { source: '', target: 'B', weight: 1 },
76
+ { source: 'A', target: 'A', weight: 1 },
77
+ { source: 'A', target: 'B', weight: 0 },
78
+ { source: 'B', target: 'C', weight: 2 }
79
+ ],
80
+ { source: 'source', target: 'target', weight: 'weight' }
81
+ )
82
+
83
+ expect(result.rejectedRowCount).toBe(3)
84
+ expect(result.links).toHaveLength(1)
85
+ })
86
+
87
+ it('counts malformed rows and rejects non-scalar endpoints and malformed comma weights', () => {
88
+ const result = prepareNetworkData(
89
+ [
90
+ null,
91
+ 'not-a-row',
92
+ ['A', 'B'],
93
+ { source: { id: 'A' }, target: 'B', weight: 1 },
94
+ { source: 'A', target: 'B', weight: '12,34' },
95
+ { source: 'A', target: 'B', weight: '1,234.5' }
96
+ ],
97
+ { source: 'source', target: 'target', weight: 'weight' }
98
+ )
99
+
100
+ expect(result.rejectedRowCount).toBe(5)
101
+ expect(result.links).toEqual([expect.objectContaining({ source: 'A', target: 'B', weight: 1234.5 })])
102
+ })
103
+
104
+ it('keeps endpoint pairs distinct when labels contain delimiter characters', () => {
105
+ const result = prepareNetworkData(
106
+ [
107
+ { source: 'A\u0000B', target: 'C' },
108
+ { source: 'A', target: 'B\u0000C' }
109
+ ],
110
+ { source: 'source', target: 'target' },
111
+ true
112
+ )
113
+
114
+ expect(result.links).toHaveLength(2)
115
+ })
116
+ })