@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,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
+ })