jbrowse-plugin-graphgenomeviewer 1.0.0 → 3.0.3

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 (283) hide show
  1. package/LICENSE +674 -0
  2. package/README.md +202 -24
  3. package/dist/chunks/AddTrackWorkflow-Y3OBHM33.js +2 -0
  4. package/dist/chunks/AddTrackWorkflow-Y3OBHM33.js.map +7 -0
  5. package/dist/chunks/GbzBaseSyntenyAdapter-EFIOI2SN.js +8 -0
  6. package/dist/chunks/GbzBaseSyntenyAdapter-EFIOI2SN.js.map +7 -0
  7. package/dist/chunks/GraphGenomeView-UD5WCV3Q.js +14 -0
  8. package/dist/chunks/GraphGenomeView-UD5WCV3Q.js.map +7 -0
  9. package/dist/chunks/MinigraphBubbleAdapter-B75VQK7T.js +2 -0
  10. package/dist/chunks/MinigraphBubbleAdapter-B75VQK7T.js.map +7 -0
  11. package/dist/chunks/RgfaTabixAdapter-IT7I5RR3.js +3 -0
  12. package/dist/chunks/RgfaTabixAdapter-IT7I5RR3.js.map +7 -0
  13. package/dist/chunks/bandage-layout-FIOZ7B3E.js +1660 -0
  14. package/dist/chunks/bandage-layout-FIOZ7B3E.js.map +7 -0
  15. package/dist/chunks/chunk-D7XFR2IV.js +2 -0
  16. package/dist/chunks/chunk-D7XFR2IV.js.map +7 -0
  17. package/dist/chunks/chunk-F4C3BEPV.js +2 -0
  18. package/dist/chunks/chunk-F4C3BEPV.js.map +7 -0
  19. package/dist/chunks/chunk-FCWXYIFC.js +2 -0
  20. package/dist/chunks/chunk-FCWXYIFC.js.map +7 -0
  21. package/dist/chunks/chunk-FY3BTKY4.js +2 -0
  22. package/dist/chunks/chunk-FY3BTKY4.js.map +7 -0
  23. package/dist/chunks/chunk-J4OLMLS5.js +2 -0
  24. package/dist/chunks/chunk-J4OLMLS5.js.map +7 -0
  25. package/dist/chunks/chunk-JCG7RLMB.js +2 -0
  26. package/dist/chunks/chunk-JCG7RLMB.js.map +7 -0
  27. package/dist/chunks/chunk-KWAXLUPQ.js +2 -0
  28. package/dist/chunks/chunk-KWAXLUPQ.js.map +7 -0
  29. package/dist/chunks/chunk-LGKM2PZH.js +2 -0
  30. package/dist/chunks/chunk-LGKM2PZH.js.map +7 -0
  31. package/dist/chunks/chunk-TN6FK5UD.js +2 -0
  32. package/dist/chunks/chunk-TN6FK5UD.js.map +7 -0
  33. package/dist/chunks/chunk-X3X74OGB.js +2 -0
  34. package/dist/chunks/chunk-X3X74OGB.js.map +7 -0
  35. package/dist/chunks/chunk-XEJ47DLD.js +2 -0
  36. package/dist/chunks/chunk-XEJ47DLD.js.map +7 -0
  37. package/dist/chunks/chunk-ZNGDWZ6Z.js +2 -0
  38. package/dist/chunks/chunk-ZNGDWZ6Z.js.map +7 -0
  39. package/dist/chunks/executeDiagonalize-XMPEDHVZ.js +2 -0
  40. package/dist/chunks/executeDiagonalize-XMPEDHVZ.js.map +7 -0
  41. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js +3 -0
  42. package/dist/jbrowse-plugin-graphgenomeviewer.esm.js.map +7 -0
  43. package/package.json +94 -49
  44. package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.test.ts +427 -0
  45. package/src/GbzBaseSyntenyAdapter/GbzBaseSyntenyAdapter.ts +461 -0
  46. package/src/GbzBaseSyntenyAdapter/configSchema.ts +181 -0
  47. package/src/GbzBaseSyntenyAdapter/index.ts +27 -0
  48. package/src/GbzBaseSyntenyAdapter/test_data/micb-kir3dl1.gbz.db +0 -0
  49. package/src/GbzBaseSyntenyAdapter/test_data/micb-kir3dl1.haplotype-index.db +0 -0
  50. package/src/GetSubgraph.test.ts +193 -0
  51. package/src/GetSubgraph.ts +93 -0
  52. package/src/GraphAddTrackWorkflow/AddTrackWorkflow.tsx +167 -0
  53. package/src/GraphAddTrackWorkflow/buildTrackConfig.test.ts +150 -0
  54. package/src/GraphAddTrackWorkflow/buildTrackConfig.ts +114 -0
  55. package/src/GraphAddTrackWorkflow/index.ts +20 -0
  56. package/src/GraphComputeLayout.ts +46 -0
  57. package/src/GraphGenomeView/anchoredNodes.ts +101 -0
  58. package/src/GraphGenomeView/bubbleSpreads.ts +102 -0
  59. package/src/GraphGenomeView/bubbles/bubbleHalos.test.ts +58 -0
  60. package/src/GraphGenomeView/bubbles/bubbleHalos.ts +149 -0
  61. package/src/GraphGenomeView/bubbles/bubbleRoutes.test.ts +141 -0
  62. package/src/GraphGenomeView/bubbles/bubblesFromGraph.test.ts +239 -0
  63. package/src/GraphGenomeView/bubbles/bubblesFromGraph.ts +210 -0
  64. package/src/GraphGenomeView/bubbles/classifyBubble.test.ts +172 -0
  65. package/src/GraphGenomeView/bubbles/classifyBubble.ts +138 -0
  66. package/src/GraphGenomeView/bubbles/popBubble.test.ts +73 -0
  67. package/src/GraphGenomeView/bubbles/popBubble.ts +26 -0
  68. package/src/GraphGenomeView/colorSchemes.ts +36 -0
  69. package/src/GraphGenomeView/components/BubbleHalos.tsx +162 -0
  70. package/src/GraphGenomeView/components/BubbleOverlay.tsx +181 -0
  71. package/src/GraphGenomeView/components/ColorSchemeSelect.tsx +41 -0
  72. package/src/GraphGenomeView/components/GenePins.tsx +107 -0
  73. package/src/GraphGenomeView/components/GraphCanvas.tsx +759 -0
  74. package/src/GraphGenomeView/components/GraphGenomeView.test.tsx +77 -0
  75. package/src/GraphGenomeView/components/GraphGenomeView.tsx +18 -17
  76. package/src/GraphGenomeView/components/GraphLoadStatus.tsx +84 -0
  77. package/src/GraphGenomeView/components/GraphSettingsDialog.tsx +327 -0
  78. package/src/GraphGenomeView/components/GraphStats.tsx +82 -0
  79. package/src/GraphGenomeView/components/GraphToolbar.tsx +84 -0
  80. package/src/GraphGenomeView/components/ImportForm.tsx +124 -0
  81. package/src/GraphGenomeView/components/LabelChip.tsx +65 -0
  82. package/src/GraphGenomeView/components/LayoutSelect.tsx +56 -0
  83. package/src/GraphGenomeView/components/RepeatSelect.tsx +51 -0
  84. package/src/GraphGenomeView/components/SettingsMenu.tsx +82 -0
  85. package/src/GraphGenomeView/components/SubgraphContextSelect.tsx +75 -0
  86. package/src/GraphGenomeView/components/SubgraphHaplotypesField.tsx +86 -0
  87. package/src/GraphGenomeView/components/WalkRowsOverlay.tsx +252 -0
  88. package/src/GraphGenomeView/components/WalkSelect.tsx +51 -0
  89. package/src/GraphGenomeView/components/overlayLabels.test.ts +56 -0
  90. package/src/GraphGenomeView/components/overlayLabels.ts +75 -0
  91. package/src/GraphGenomeView/deletionEdges.test.ts +198 -0
  92. package/src/GraphGenomeView/deletionEdges.ts +217 -0
  93. package/src/GraphGenomeView/genes/geneFeatures.ts +107 -0
  94. package/src/GraphGenomeView/genes/genePins.ts +130 -0
  95. package/src/GraphGenomeView/genes/genes.test.ts +153 -0
  96. package/src/GraphGenomeView/gfa/gfaConverter.test.ts +394 -0
  97. package/src/GraphGenomeView/gfa/gfaConverter.ts +235 -0
  98. package/src/GraphGenomeView/graphLabels.budget.test.ts +36 -0
  99. package/src/GraphGenomeView/graphLabels.test.ts +548 -0
  100. package/src/GraphGenomeView/graphLabels.ts +640 -0
  101. package/src/GraphGenomeView/index.ts +29 -7
  102. package/src/GraphGenomeView/laneRamp.test.ts +97 -0
  103. package/src/GraphGenomeView/laneRamp.ts +87 -0
  104. package/src/GraphGenomeView/launchKeys.test.ts +63 -0
  105. package/src/GraphGenomeView/layout/anchoredLayout.test.ts +213 -0
  106. package/src/GraphGenomeView/layout/anchoredLayout.ts +80 -0
  107. package/src/GraphGenomeView/layout/bubbleCrossing.test.ts +190 -0
  108. package/src/GraphGenomeView/layout/drawnScale.ts +199 -0
  109. package/src/GraphGenomeView/layout/mergeRuns.test.ts +100 -0
  110. package/src/GraphGenomeView/layout/mergeRuns.ts +203 -0
  111. package/src/GraphGenomeView/layout/orderedLayout.test.ts +212 -0
  112. package/src/GraphGenomeView/layout/orderedLayout.ts +396 -0
  113. package/src/GraphGenomeView/layout/orientToReference.test.ts +110 -0
  114. package/src/GraphGenomeView/layout/orientToReference.ts +79 -0
  115. package/src/GraphGenomeView/layout/placeOffReference.ts +147 -0
  116. package/src/GraphGenomeView/layout/referenceSeeds.test.ts +79 -0
  117. package/src/GraphGenomeView/layout/referenceSeeds.ts +89 -0
  118. package/src/GraphGenomeView/layout/rowSpacing.ts +5 -0
  119. package/src/GraphGenomeView/layout/sampleRowLayout.test.ts +318 -0
  120. package/src/GraphGenomeView/layout/sampleRowLayout.ts +134 -0
  121. package/src/GraphGenomeView/layout/variantMapLayout.test.ts +59 -0
  122. package/src/GraphGenomeView/layout/variantMapLayout.ts +20 -0
  123. package/src/GraphGenomeView/layout/walkRowLayout.ts +54 -0
  124. package/src/GraphGenomeView/layout/walkRows.test.ts +178 -0
  125. package/src/GraphGenomeView/layout/walkRows.ts +177 -0
  126. package/src/GraphGenomeView/layoutModes.ts +131 -0
  127. package/src/GraphGenomeView/model.test.ts +2696 -0
  128. package/src/GraphGenomeView/model.ts +2672 -94
  129. package/src/GraphGenomeView/nodeWidths.ts +67 -0
  130. package/src/GraphGenomeView/pathAnchoring.test.ts +252 -0
  131. package/src/GraphGenomeView/pathAnchoring.ts +164 -0
  132. package/src/GraphGenomeView/pathColors.test.ts +83 -0
  133. package/src/GraphGenomeView/pathColors.ts +122 -0
  134. package/src/GraphGenomeView/referenceSpan.test.ts +84 -0
  135. package/src/GraphGenomeView/referenceSpan.ts +133 -0
  136. package/src/GraphGenomeView/renderPasses.bench.ts +172 -0
  137. package/src/GraphGenomeView/renderPipeline.test.ts +226 -0
  138. package/src/GraphGenomeView/renderer/Canvas2DRenderer.test.ts +205 -0
  139. package/src/GraphGenomeView/renderer/Canvas2DRenderer.ts +246 -0
  140. package/src/GraphGenomeView/renderer/GeometryBuilder.test.ts +723 -0
  141. package/src/GraphGenomeView/renderer/GeometryBuilder.ts +882 -0
  142. package/src/GraphGenomeView/renderer/GraphRenderer.ts +13 -0
  143. package/src/GraphGenomeView/renderer/colorBits.ts +43 -0
  144. package/src/GraphGenomeView/renderer/recordingCanvas.ts +64 -0
  145. package/src/GraphGenomeView/renderer/types.ts +84 -0
  146. package/src/GraphGenomeView/renderer/walkFade.test.ts +100 -0
  147. package/src/GraphGenomeView/repeats/repeatFeatures.test.ts +114 -0
  148. package/src/GraphGenomeView/repeats/repeatFeatures.ts +202 -0
  149. package/src/GraphGenomeView/repeats/test_data/abca7_trgt_walks.json +575 -0
  150. package/src/GraphGenomeView/repeats/walkCalls.test.ts +127 -0
  151. package/src/GraphGenomeView/repeats/walkCalls.ts +132 -0
  152. package/src/GraphGenomeView/stress.test.ts +214 -0
  153. package/src/GraphGenomeView/subgraphLoad.test.ts +245 -0
  154. package/src/GraphGenomeView/types.ts +141 -0
  155. package/src/GraphGenomeView/util/SpatialIndex.test.ts +73 -0
  156. package/src/GraphGenomeView/util/SpatialIndex.ts +312 -0
  157. package/src/GraphGenomeView/util/edgeCurves.test.ts +93 -0
  158. package/src/GraphGenomeView/util/edgeCurves.ts +94 -0
  159. package/src/GraphGenomeView/util/geometry.test.ts +447 -0
  160. package/src/GraphGenomeView/util/geometry.ts +656 -0
  161. package/src/GraphGenomeView/util/hitDetection.test.ts +307 -0
  162. package/src/GraphGenomeView/util/hitDetection.ts +330 -0
  163. package/src/GraphGenomeView/util/wheelZoom.test.ts +40 -0
  164. package/src/GraphGenomeView/util/wheelZoom.ts +26 -0
  165. package/src/GraphGenomeView/walkHighlight.test.ts +54 -0
  166. package/src/GraphGenomeView/walkHighlight.ts +56 -0
  167. package/src/LaunchGraphGenomeView.ts +46 -0
  168. package/src/MinigraphBubbleAdapter/MinigraphBubbleAdapter.test.ts +139 -0
  169. package/src/MinigraphBubbleAdapter/MinigraphBubbleAdapter.ts +86 -0
  170. package/src/MinigraphBubbleAdapter/bubbleLine.ts +92 -0
  171. package/src/MinigraphBubbleAdapter/configSchema.ts +109 -0
  172. package/src/MinigraphBubbleAdapter/index.ts +22 -0
  173. package/src/MinigraphBubbleAdapter/test_data/hprc_bubbles_mhc.bed.gz +0 -0
  174. package/src/MinigraphBubbleAdapter/test_data/hprc_bubbles_mhc.bed.gz.tbi +0 -0
  175. package/src/RgfaTabixAdapter/RgfaTabixAdapter.test.ts +285 -0
  176. package/src/RgfaTabixAdapter/RgfaTabixAdapter.ts +194 -0
  177. package/src/RgfaTabixAdapter/bubbleTier.test.ts +84 -0
  178. package/src/RgfaTabixAdapter/configSchema.ts +142 -0
  179. package/src/RgfaTabixAdapter/index.ts +24 -0
  180. package/src/RgfaTabixAdapter/rgfaBed.test.ts +158 -0
  181. package/src/RgfaTabixAdapter/rgfaBed.ts +156 -0
  182. package/src/RgfaTabixAdapter/test_data/bubble_tier_chrY.links.bed.gz +0 -0
  183. package/src/RgfaTabixAdapter/test_data/bubble_tier_chrY.links.bed.gz.tbi +0 -0
  184. package/src/RgfaTabixAdapter/test_data/bubble_tier_chrY.segs.bed.gz +0 -0
  185. package/src/RgfaTabixAdapter/test_data/bubble_tier_chrY.segs.bed.gz.tbi +0 -0
  186. package/src/RgfaTabixAdapter/test_data/pggb_ecoli.links.bed.gz +0 -0
  187. package/src/RgfaTabixAdapter/test_data/pggb_ecoli.links.bed.gz.tbi +0 -0
  188. package/src/RgfaTabixAdapter/test_data/pggb_ecoli.segs.bed.gz +0 -0
  189. package/src/RgfaTabixAdapter/test_data/pggb_ecoli.segs.bed.gz.tbi +0 -0
  190. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.links.bed.gz +0 -0
  191. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.links.bed.gz.tbi +0 -0
  192. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.segs.bed.gz +0 -0
  193. package/src/RgfaTabixAdapter/test_data/rgfa_ecoli.segs.bed.gz.tbi +0 -0
  194. package/src/alleleProjection/projectAlleles.test.ts +75 -0
  195. package/src/alleleProjection/projectAlleles.ts +190 -0
  196. package/src/bandage/README.md +121 -0
  197. package/src/bandage/bandage-layout.d.ts +14 -0
  198. package/src/bandage/bandage-layout.js +0 -0
  199. package/src/bandage/native/CMakeLists.txt +45 -0
  200. package/src/bandage/native/include/graph.h +188 -0
  201. package/src/bandage/native/include/graphlayout.h +74 -0
  202. package/src/bandage/native/include/settings.h +86 -0
  203. package/src/bandage/native/include/types.h +56 -0
  204. package/src/bandage/native/src/bindings.cpp +164 -0
  205. package/src/bandage/native/src/graphlayout.cpp +621 -0
  206. package/src/bandage/resizableDecode.test.ts +58 -0
  207. package/src/gfa-core/gfaParser.bench.ts +55 -0
  208. package/src/gfa-core/gfaParser.test.ts +168 -0
  209. package/src/gfa-core/gfaParser.ts +237 -0
  210. package/src/gfa-core/index.ts +10 -0
  211. package/src/hoverSync/GraphNodeHighlight.tsx +62 -0
  212. package/src/hoverSync/graphViewHighlights.test.ts +158 -0
  213. package/src/hoverSync/graphViewHighlights.ts +119 -0
  214. package/src/hoverSync/index.test.tsx +36 -0
  215. package/src/hoverSync/index.tsx +19 -0
  216. package/src/hoverSync/lgvHover.test.ts +202 -0
  217. package/src/hoverSync/lgvHover.ts +136 -0
  218. package/src/index.ts +46 -15
  219. package/src/launchFromGraph/contributors.test.ts +197 -0
  220. package/src/launchFromGraph/contributors.ts +0 -0
  221. package/src/launchFromGraph/graphMenuItems.ts +211 -0
  222. package/src/launchFromGraph/launchFromGraph.test.ts +489 -0
  223. package/src/launchFromGraph/launchFromGraph.ts +265 -0
  224. package/src/launchFromGraph/launchTracks.test.ts +72 -0
  225. package/src/launchFromGraph/launchTracks.ts +50 -0
  226. package/src/launchFromGraph/linearViewTarget.test.ts +82 -0
  227. package/src/launchFromGraph/linearViewTarget.ts +75 -0
  228. package/src/launchFromGraph/syntenyTracks.ts +47 -0
  229. package/src/launchSubgraph/index.ts +152 -0
  230. package/src/launchSubgraph/launchSubgraph.test.ts +264 -0
  231. package/src/launchSubgraph/launchSubgraphView.ts +128 -0
  232. package/src/launchSubgraph/linearViewMenuItems.test.ts +176 -0
  233. package/src/launchSubgraph/linearViewMenuItems.ts +107 -0
  234. package/src/launchSubgraph/multiWayLaunch.test.ts +289 -0
  235. package/src/launchSubgraph/subgraphMenuItems.ts +73 -0
  236. package/src/launchSubgraph/subgraphTracks.test.ts +165 -0
  237. package/src/launchSubgraph/subgraphTracks.ts +151 -0
  238. package/src/launchSubgraph/testEnv.ts +258 -0
  239. package/src/loadBandage.ts +22 -0
  240. package/src/panSNTabix.test.ts +72 -0
  241. package/src/panSNTabix.ts +149 -0
  242. package/src/pansn.ts +106 -0
  243. package/src/pluginName.ts +3 -0
  244. package/src/synteny/ComparativeAdapterBase.ts +77 -0
  245. package/src/synteny/SyntenyFeature.ts +116 -0
  246. package/src/synteny/clipFeatureToRegion.test.ts +329 -0
  247. package/src/synteny/clipFeatureToRegion.ts +167 -0
  248. package/src/version.ts +1 -0
  249. package/dist/GraphGenomeView/components/FeatureDialog.d.ts +0 -5
  250. package/dist/GraphGenomeView/components/FeatureDialog.js +0 -18
  251. package/dist/GraphGenomeView/components/FeatureDialog.js.map +0 -1
  252. package/dist/GraphGenomeView/components/GraphGenomeView.d.ts +0 -6
  253. package/dist/GraphGenomeView/components/GraphGenomeView.js +0 -17
  254. package/dist/GraphGenomeView/components/GraphGenomeView.js.map +0 -1
  255. package/dist/GraphGenomeView/components/GraphPanel.d.ts +0 -6
  256. package/dist/GraphGenomeView/components/GraphPanel.js +0 -17
  257. package/dist/GraphGenomeView/components/GraphPanel.js.map +0 -1
  258. package/dist/GraphGenomeView/components/Header.d.ts +0 -6
  259. package/dist/GraphGenomeView/components/Header.js +0 -59
  260. package/dist/GraphGenomeView/components/Header.js.map +0 -1
  261. package/dist/GraphGenomeView/components/SettingsDialog.d.ts +0 -7
  262. package/dist/GraphGenomeView/components/SettingsDialog.js +0 -24
  263. package/dist/GraphGenomeView/components/SettingsDialog.js.map +0 -1
  264. package/dist/GraphGenomeView/index.d.ts +0 -2
  265. package/dist/GraphGenomeView/index.js +0 -15
  266. package/dist/GraphGenomeView/index.js.map +0 -1
  267. package/dist/GraphGenomeView/model.d.ts +0 -80
  268. package/dist/GraphGenomeView/model.js +0 -96
  269. package/dist/GraphGenomeView/model.js.map +0 -1
  270. package/dist/GraphGenomeView/util.d.ts +0 -1
  271. package/dist/GraphGenomeView/util.js +0 -8
  272. package/dist/GraphGenomeView/util.js.map +0 -1
  273. package/dist/index.d.ts +0 -8
  274. package/dist/index.js +0 -24
  275. package/dist/index.js.map +0 -1
  276. package/dist/jbrowse-plugin-graphgenomeviewer.umd.production.min.js +0 -67
  277. package/dist/jbrowse-plugin-graphgenomeviewer.umd.production.min.js.map +0 -7
  278. package/src/GraphGenomeView/components/FeatureDialog.tsx +0 -51
  279. package/src/GraphGenomeView/components/GraphPanel.tsx +0 -37
  280. package/src/GraphGenomeView/components/Header.tsx +0 -90
  281. package/src/GraphGenomeView/components/SettingsDialog.tsx +0 -71
  282. package/src/GraphGenomeView/util.ts +0 -7
  283. package/src/declare.d.ts +0 -1
@@ -0,0 +1,723 @@
1
+ import { packAbgr } from '@jbrowse/core/util/colorBits'
2
+
3
+ import {
4
+ REFERENCE_RAMP_MAX_HUE,
5
+ buildGeometry,
6
+ computeReferenceRamp,
7
+ endTangent,
8
+ hslToRgb,
9
+ } from './GeometryBuilder'
10
+
11
+ import type { RenderBatch } from './types'
12
+
13
+ // the colour a node is drawn in, off its first stroke
14
+ function nodeColor(batch: RenderBatch, id: string) {
15
+ return batch.nodeStrokes[batch.nodeStrokeRuns.get(id)!.start]!.color
16
+ }
17
+
18
+ // isotropic: one scale for both axes, which is every layout but the row ones
19
+ const iso = (scale = 1) => ({ scaleX: scale, scaleY: scale })
20
+
21
+ const simpleGraph = {
22
+ name: 'test',
23
+ nodes: [
24
+ { id: 'A+', name: 'A', length: 100, depth: 1 },
25
+ { id: 'B+', name: 'B', length: 200, depth: 2 },
26
+ ],
27
+ edges: [{ from: 'A+', to: 'B+' }],
28
+ }
29
+
30
+ const simpleNodeById = new Map(simpleGraph.nodes.map(n => [n.id, n]))
31
+
32
+ const simplePositions = {
33
+ 'A+': [
34
+ { x: 0, y: 0 },
35
+ { x: 10, y: 0 },
36
+ ],
37
+ 'B+': [
38
+ { x: 20, y: 0 },
39
+ { x: 30, y: 0 },
40
+ ],
41
+ }
42
+
43
+ test('produces non-empty geometry for simple graph', () => {
44
+ const batch = buildGeometry({
45
+ axis: iso(),
46
+ nodePositions: simplePositions,
47
+ graph: simpleGraph,
48
+ nodeById: simpleNodeById,
49
+ colorScheme: 'uniform',
50
+ contigThickness: 5,
51
+ connectorThickness: 1.5,
52
+ drawPaths: false,
53
+ })
54
+
55
+ // one stroke per node, along the node's own polyline, at half its width
56
+ expect(batch.nodeStrokes).toHaveLength(2)
57
+ expect(batch.nodeStrokes[0]!.points).toBe(simplePositions['A+'])
58
+ expect(batch.nodeStrokes[0]!.thickness).toBe(2.5)
59
+ expect(batch.edgeCurves.length).toBeGreaterThan(0)
60
+ })
61
+
62
+ test('produces different geometry for different color schemes', () => {
63
+ const opts = {
64
+ nodePositions: simplePositions,
65
+ graph: simpleGraph,
66
+ nodeById: simpleNodeById,
67
+ contigThickness: 5,
68
+ connectorThickness: 1.5,
69
+ drawPaths: false,
70
+ axis: iso(),
71
+ }
72
+
73
+ const uniformBatch = buildGeometry({
74
+ ...opts,
75
+ colorScheme: 'uniform' as const,
76
+ })
77
+ const depthBatch = buildGeometry({ ...opts, colorScheme: 'depth' as const })
78
+
79
+ expect(uniformBatch.nodeStrokes).toHaveLength(depthBatch.nodeStrokes.length)
80
+ expect(nodeColor(uniformBatch, 'B+')).not.toBe(nodeColor(depthBatch, 'B+'))
81
+ })
82
+
83
+ test('keys the strokes by node and by edge', () => {
84
+ const batch = buildGeometry({
85
+ nodePositions: simplePositions,
86
+ graph: simpleGraph,
87
+ nodeById: simpleNodeById,
88
+ colorScheme: 'uniform',
89
+ axis: iso(),
90
+ contigThickness: 5,
91
+ connectorThickness: 1.5,
92
+ drawPaths: false,
93
+ })
94
+
95
+ expect(batch.nodeStrokeRuns.get('A+')).toEqual({ start: 0, count: 1 })
96
+ expect(batch.nodeStrokeRuns.get('B+')).toEqual({ start: 1, count: 1 })
97
+ expect(batch.edgeCurveRuns.get(0)).toEqual({ start: 0, count: 1 })
98
+ })
99
+
100
+ test('handles empty node positions gracefully', () => {
101
+ const batch = buildGeometry({
102
+ nodePositions: {},
103
+ axis: iso(),
104
+ graph: simpleGraph,
105
+ nodeById: simpleNodeById,
106
+ colorScheme: 'uniform',
107
+ contigThickness: 5,
108
+ connectorThickness: 1.5,
109
+ drawPaths: false,
110
+ })
111
+
112
+ expect(batch.nodeStrokes).toHaveLength(0)
113
+ expect(batch.edgeCurves).toHaveLength(0)
114
+ })
115
+
116
+ test('handles graph with paths and drawPaths', () => {
117
+ const graphWithPaths = {
118
+ ...simpleGraph,
119
+ edges: [{ from: 'A+', to: 'B+', pathIds: ['p1'] }],
120
+ paths: [{ name: 'p1', nodeIds: ['A+', 'B+'] }],
121
+ }
122
+
123
+ const batch = buildGeometry({
124
+ nodePositions: simplePositions,
125
+ axis: iso(),
126
+ graph: graphWithPaths,
127
+ nodeById: simpleNodeById,
128
+ colorScheme: 'uniform',
129
+ contigThickness: 5,
130
+ connectorThickness: 1.5,
131
+ drawPaths: true,
132
+ })
133
+
134
+ expect(batch.nodeStrokes.length).toBeGreaterThan(0)
135
+ // one stroke per path crossing the edge, not one for the edge
136
+ expect(batch.edgeCurves).toHaveLength(1)
137
+ expect(batch.edgeCurveRuns.get(0)).toEqual({ start: 0, count: 1 })
138
+ })
139
+
140
+ // A three-path graph where the middle node is the one B skips: A and C walk
141
+ // A+ -> B+ , B walks around it. That absence is what the stripes have to show.
142
+ const carriageGraph = {
143
+ ...simpleGraph,
144
+ edges: [{ from: 'A+', to: 'B+', pathIds: ['pA', 'pB', 'pC'] }],
145
+ paths: [
146
+ { name: 'pA', nodeIds: ['A+', 'B+'] },
147
+ { name: 'pB', nodeIds: ['A+'] },
148
+ { name: 'pC', nodeIds: ['A+', 'B+'] },
149
+ ],
150
+ }
151
+
152
+ const carriageOpts = {
153
+ nodePositions: simplePositions,
154
+ axis: iso(),
155
+ graph: carriageGraph,
156
+ nodeById: simpleNodeById,
157
+ colorScheme: 'uniform' as const,
158
+ contigThickness: 12,
159
+ connectorThickness: 1.5,
160
+ }
161
+
162
+ // distinct colors across a node's strokes
163
+ function nodeColors(batch: RenderBatch, nodeId: string) {
164
+ const run = batch.nodeStrokeRuns.get(nodeId)!
165
+ return new Set(
166
+ batch.nodeStrokes
167
+ .slice(run.start, run.start + run.count)
168
+ .map(stroke => stroke.color),
169
+ )
170
+ }
171
+
172
+ test('drawPaths stripes a node once per path that visits it', () => {
173
+ const batch = buildGeometry({ ...carriageOpts, drawPaths: true })
174
+
175
+ // A+ is on all three paths, B+ on two: the count of colors down the node is
176
+ // its carriage, which is the whole point of striping the node rather than
177
+ // only the edge
178
+ expect(nodeColors(batch, 'A+').size).toBe(3)
179
+ expect(nodeColors(batch, 'B+').size).toBe(2)
180
+ // and B+'s two are the same two colors A+ has, not a re-numbered pair: the
181
+ // slot is fixed by the path's position in the legend, so a gap lands in the
182
+ // same place on every node
183
+ expect(
184
+ [...nodeColors(batch, 'B+')].every(c => nodeColors(batch, 'A+').has(c)),
185
+ ).toBe(true)
186
+ })
187
+
188
+ test('stripes divide the node width rather than inflating it', () => {
189
+ const striped = buildGeometry({ ...carriageOpts, drawPaths: true })
190
+ const plain = buildGeometry({ ...carriageOpts, drawPaths: false })
191
+
192
+ const halfWidth = (batch: RenderBatch) =>
193
+ Math.max(...batch.nodeStrokes.map(stroke => stroke.thickness))
194
+ // three stripes of a third the width each, laid across the same 12 units
195
+ expect(halfWidth(striped)).toBe(halfWidth(plain) / 3)
196
+ })
197
+
198
+ test('a node keeps its scheme color when a stripe would be sub-pixel', () => {
199
+ // contigThickness is screen px, so a thin node over three paths gives a
200
+ // one-px stripe each: aliasing between neighbours rather than three colors
201
+ const thin = buildGeometry({
202
+ ...carriageOpts,
203
+ contigThickness: 3,
204
+ drawPaths: true,
205
+ })
206
+ expect(nodeColors(thin, 'A+').size).toBe(1)
207
+ })
208
+
209
+ test('the stripe offset is in world units, so zoom does not fan them apart', () => {
210
+ const yAt = (batch: RenderBatch, nodeId: string) => {
211
+ const run = batch.nodeStrokeRuns.get(nodeId)!
212
+ const ys = batch.nodeStrokes
213
+ .slice(run.start, run.start + run.count)
214
+ .flatMap(stroke => stroke.points.map(p => p.y))
215
+ return Math.max(...ys) - Math.min(...ys)
216
+ }
217
+ // the stripes are laid across a width stated in screen px, so in world units
218
+ // they must spread twice as far when the view is drawn at half the zoom
219
+ const near = buildGeometry({ ...carriageOpts, drawPaths: true, axis: iso() })
220
+ const far = buildGeometry({
221
+ ...carriageOpts,
222
+ drawPaths: true,
223
+ axis: iso(0.5),
224
+ })
225
+ expect(yAt(far, 'A+')).toBeCloseTo(yAt(near, 'A+') * 2, 5)
226
+ })
227
+
228
+ test('a many-path graph is not striped at all', () => {
229
+ const manyPaths = {
230
+ ...simpleGraph,
231
+ paths: Array.from({ length: 40 }, (_, i) => ({
232
+ name: `p${i}`,
233
+ nodeIds: ['A+', 'B+'],
234
+ })),
235
+ }
236
+ const batch = buildGeometry({
237
+ ...carriageOpts,
238
+ graph: manyPaths,
239
+ drawPaths: true,
240
+ })
241
+ // forty colors down one node is not a reading, so the node keeps its own
242
+ expect(nodeColors(batch, 'A+').size).toBe(1)
243
+ })
244
+
245
+ test('viewport culling skips off-screen nodes', () => {
246
+ const batch = buildGeometry({
247
+ axis: iso(),
248
+ nodePositions: simplePositions,
249
+ graph: simpleGraph,
250
+ nodeById: simpleNodeById,
251
+ colorScheme: 'uniform',
252
+ contigThickness: 5,
253
+ connectorThickness: 1.5,
254
+ drawPaths: false,
255
+ viewportBounds: { minX: -5, minY: -5, maxX: 15, maxY: 5 },
256
+ })
257
+
258
+ expect(batch.nodeStrokeRuns.has('A+')).toBe(true)
259
+ expect(batch.nodeStrokeRuns.has('B+')).toBe(false)
260
+ })
261
+
262
+ // The reference-anchored layouts put x in bp, so a backbone segment is routinely
263
+ // wider than the window with both of its endpoints outside it. Culling on
264
+ // endpoint containment dropped exactly the segment those layouts exist to show.
265
+ test('viewport culling keeps a node spanning the whole viewport', () => {
266
+ const nodes = [
267
+ { id: 'backbone+', name: 'backbone', length: 50_000, depth: 1 },
268
+ ]
269
+ const batch = buildGeometry({
270
+ nodePositions: {
271
+ 'backbone+': [
272
+ { x: 0, y: 0 },
273
+ { x: 50_000, y: 0 },
274
+ ],
275
+ },
276
+ graph: { name: 'test', nodes, edges: [] },
277
+ nodeById: new Map(nodes.map(n => [n.id, n])),
278
+ colorScheme: 'uniform',
279
+ axis: iso(),
280
+ contigThickness: 5,
281
+ connectorThickness: 1.5,
282
+ drawPaths: false,
283
+ viewportBounds: { minX: 20_000, minY: -100, maxX: 21_000, maxY: 100 },
284
+ })
285
+
286
+ expect(batch.nodeStrokeRuns.has('backbone+')).toBe(true)
287
+ })
288
+
289
+ test('node-length color scheme produces distinct colors for different lengths', () => {
290
+ const nodes = [
291
+ { id: 'short+', name: 'short', length: 10, depth: 1 },
292
+ { id: 'long+', name: 'long', length: 10000, depth: 1 },
293
+ ]
294
+ const positions = {
295
+ 'short+': [
296
+ { x: 0, y: 0 },
297
+ { x: 10, y: 0 },
298
+ ],
299
+ 'long+': [
300
+ { x: 20, y: 0 },
301
+ { x: 30, y: 0 },
302
+ ],
303
+ }
304
+ const graph = { name: 'test', nodes, edges: [] }
305
+ const nodeById = new Map(nodes.map(n => [n.id, n]))
306
+
307
+ const batch = buildGeometry({
308
+ nodePositions: positions,
309
+ graph,
310
+ nodeById,
311
+ colorScheme: 'node-length',
312
+ axis: iso(),
313
+ contigThickness: 5,
314
+ connectorThickness: 1.5,
315
+ drawPaths: false,
316
+ })
317
+
318
+ expect(nodeColor(batch, 'short+')).not.toBe(nodeColor(batch, 'long+'))
319
+ })
320
+
321
+ test('rainbow color scheme produces distinct colors for nodes at different indices', () => {
322
+ const nodes = [
323
+ { id: 'A+', name: 'A', length: 100, depth: 1 },
324
+ { id: 'B+', name: 'B', length: 100, depth: 1 },
325
+ { id: 'C+', name: 'C', length: 100, depth: 1 },
326
+ ]
327
+ const positions = {
328
+ 'A+': [
329
+ { x: 0, y: 0 },
330
+ { x: 10, y: 0 },
331
+ ],
332
+ 'B+': [
333
+ { x: 20, y: 0 },
334
+ { x: 30, y: 0 },
335
+ ],
336
+ 'C+': [
337
+ { x: 40, y: 0 },
338
+ { x: 50, y: 0 },
339
+ ],
340
+ }
341
+ const graph = { name: 'test', nodes, edges: [] }
342
+ const nodeById = new Map(nodes.map(n => [n.id, n]))
343
+
344
+ const batch = buildGeometry({
345
+ nodePositions: positions,
346
+ graph,
347
+ nodeById,
348
+ colorScheme: 'rainbow',
349
+ axis: iso(),
350
+ contigThickness: 5,
351
+ connectorThickness: 1.5,
352
+ drawPaths: false,
353
+ })
354
+
355
+ const colors = ['A+', 'B+', 'C+'].map(id => nodeColor(batch, id))
356
+ expect(new Set(colors).size).toBe(3)
357
+ })
358
+
359
+ test('builds geometry for a self-loop edge', () => {
360
+ const graph = {
361
+ name: 'test',
362
+ nodes: [{ id: 'A+', name: 'A', length: 100, depth: 1 }],
363
+ edges: [{ from: 'A+', to: 'A+' }],
364
+ }
365
+ const batch = buildGeometry({
366
+ nodePositions: {
367
+ 'A+': [
368
+ { x: 0, y: 0 },
369
+ { x: 10, y: 0 },
370
+ ],
371
+ },
372
+ graph,
373
+ nodeById: new Map(graph.nodes.map(n => [n.id, n])),
374
+ colorScheme: 'uniform',
375
+ contigThickness: 5,
376
+ connectorThickness: 1.5,
377
+ drawPaths: false,
378
+ axis: iso(),
379
+ })
380
+ expect(batch.nodeStrokes).toHaveLength(1)
381
+ // a self loop is two curves, so it strokes as one two-segment path
382
+ expect(batch.edgeCurves[0]!.curves).toHaveLength(2)
383
+ expect(batch.edgeCurveRuns.has(0)).toBe(true)
384
+ })
385
+
386
+ test('skips edges that reference missing node positions', () => {
387
+ const graph = {
388
+ name: 'test',
389
+ nodes: [{ id: 'A+', name: 'A', length: 100, depth: 1 }],
390
+ edges: [{ from: 'A+', to: 'missing+' }],
391
+ }
392
+ const batch = buildGeometry({
393
+ nodePositions: {
394
+ 'A+': [
395
+ { x: 0, y: 0 },
396
+ { x: 10, y: 0 },
397
+ ],
398
+ },
399
+ graph,
400
+ nodeById: new Map(graph.nodes.map(n => [n.id, n])),
401
+ colorScheme: 'uniform',
402
+ contigThickness: 5,
403
+ connectorThickness: 1.5,
404
+ drawPaths: false,
405
+ axis: iso(),
406
+ })
407
+ // node still builds; the dangling edge is silently dropped
408
+ expect(batch.nodeStrokes).toHaveLength(1)
409
+ expect(batch.edgeCurves).toHaveLength(0)
410
+ expect(batch.edgeCurveRuns.size).toBe(0)
411
+ })
412
+
413
+ // Arrowheads used to take their angle from the last two points of the
414
+ // tessellated edge mesh. The mesh is gone, so the angle comes from the curve's
415
+ // analytic end tangent instead — which has to agree with the direction the
416
+ // tessellation was reporting, or every arrowhead points somewhere else.
417
+ describe('endTangent', () => {
418
+ // direction of a cubic just short of its endpoint, i.e. what walking the
419
+ // tessellated points to the end and taking the last step measured
420
+ function numericEndAngle(c: {
421
+ x0: number
422
+ y0: number
423
+ cx0: number
424
+ cy0: number
425
+ cx1: number
426
+ cy1: number
427
+ x1: number
428
+ y1: number
429
+ }) {
430
+ const at = (t: number) => {
431
+ const u = 1 - t
432
+ return {
433
+ x:
434
+ u ** 3 * c.x0 +
435
+ 3 * u * u * t * c.cx0 +
436
+ 3 * u * t * t * c.cx1 +
437
+ t ** 3 * c.x1,
438
+ y:
439
+ u ** 3 * c.y0 +
440
+ 3 * u * u * t * c.cy0 +
441
+ 3 * u * t * t * c.cy1 +
442
+ t ** 3 * c.y1,
443
+ }
444
+ }
445
+ // a short secant, which is what the last tessellated step was; small enough
446
+ // that its own error stays well under the tolerance below
447
+ const a = at(1 - 1e-6)
448
+ const b = at(1)
449
+ return Math.atan2(b.y - a.y, b.x - a.x)
450
+ }
451
+
452
+ const curves = [
453
+ { x0: 0, y0: 0, cx0: 30, cy0: 0, cx1: 70, cy1: 40, x1: 100, y1: 40 },
454
+ { x0: 0, y0: 0, cx0: 0, cy0: 80, cx1: 100, cy1: 80, x1: 100, y1: 0 },
455
+ { x0: 5, y0: 5, cx0: -20, cy0: 60, cx1: -60, cy1: -30, x1: -100, y1: 10 },
456
+ ]
457
+
458
+ test.each(curves)('matches the numeric tangent of %j', curve => {
459
+ expect(endTangent(curve)).toBeCloseTo(numericEndAngle(curve), 4)
460
+ })
461
+
462
+ // Degenerate control point: the derivative at t=1 vanishes, so there is no
463
+ // tangent to read and the chord is the only direction available.
464
+ test('falls back to the chord when the control point is on the endpoint', () => {
465
+ expect(
466
+ endTangent({
467
+ x0: 0,
468
+ y0: 0,
469
+ cx0: 0,
470
+ cy0: 0,
471
+ cx1: 10,
472
+ cy1: 10,
473
+ x1: 10,
474
+ y1: 10,
475
+ }),
476
+ ).toBeCloseTo(Math.atan2(10, 10), 6)
477
+ })
478
+ })
479
+
480
+ describe('the reference-position ramp', () => {
481
+ // Two backbone segments at either end of a 1000 bp window and one allele
482
+ // bridging them, which is the shape every graph-over-linear figure has.
483
+ const nodes = [
484
+ {
485
+ id: 'left+',
486
+ name: 'left',
487
+ length: 100,
488
+ depth: 2,
489
+ stable: { refName: 'chr', start: 0, rank: 0 },
490
+ },
491
+ {
492
+ id: 'right+',
493
+ name: 'right',
494
+ length: 100,
495
+ depth: 2,
496
+ stable: { refName: 'chr', start: 900, rank: 0 },
497
+ },
498
+ {
499
+ id: 'alt+',
500
+ name: 'alt',
501
+ length: 5000,
502
+ depth: 1,
503
+ stable: { refName: 'other', start: 7, rank: 1 },
504
+ },
505
+ // a second allele, branching from the far end of the window rather than the
506
+ // middle of it, so a position-dependent colouring would paint it differently
507
+ {
508
+ id: 'alt2+',
509
+ name: 'alt2',
510
+ length: 400,
511
+ depth: 1,
512
+ stable: { refName: 'other', start: 900, rank: 1 },
513
+ },
514
+ { id: 'loose+', name: 'loose', length: 10, depth: 1 },
515
+ ]
516
+ const graph = {
517
+ name: 'test',
518
+ nodes,
519
+ edges: [
520
+ { from: 'left+', to: 'alt+' },
521
+ { from: 'alt+', to: 'right+' },
522
+ { from: 'right+', to: 'alt2+' },
523
+ ],
524
+ }
525
+ const positions = Object.fromEntries(
526
+ nodes.map((n, i) => [
527
+ n.id,
528
+ [
529
+ { x: i * 20, y: 0 },
530
+ { x: i * 20 + 10, y: 0 },
531
+ ],
532
+ ]),
533
+ )
534
+
535
+ function colorsFor(colorDomain?: { start: number; end: number }) {
536
+ const batch = buildGeometry({
537
+ nodePositions: positions,
538
+ graph,
539
+ nodeById: new Map(nodes.map(n => [n.id, n])),
540
+ colorScheme: 'reference-position',
541
+ // what the model holds against the graph and hands over; see BuildOptions
542
+ referenceRamp: computeReferenceRamp(graph, colorDomain),
543
+ axis: iso(),
544
+ contigThickness: 5,
545
+ connectorThickness: 1.5,
546
+ drawPaths: false,
547
+ })
548
+ return Object.fromEntries(nodes.map(n => [n.id, nodeColor(batch, n.id)]))
549
+ }
550
+
551
+ // The contract a linear track's `color` jexl reproduces. Written out as the
552
+ // arithmetic rather than as a captured constant, so an edit to the ramp fails
553
+ // here instead of silently desynchronising the two panels.
554
+ function expectedColor(mid: number, start: number, span: number) {
555
+ const frac = Math.max(0, Math.min(1, (mid - start) / span))
556
+ const [r, g, b] = hslToRgb(frac * REFERENCE_RAMP_MAX_HUE, 0.7, 0.5)
557
+ return packAbgr(
558
+ Math.round(r * 255),
559
+ Math.round(g * 255),
560
+ Math.round(b * 255),
561
+ 255,
562
+ )
563
+ }
564
+
565
+ test('paints a backbone segment the hue of its own midpoint', () => {
566
+ const colors = colorsFor({ start: 0, end: 1000 })
567
+ expect(colors['left+']).toBe(expectedColor(50, 0, 1000))
568
+ expect(colors['right+']).toBe(expectedColor(950, 0, 1000))
569
+ })
570
+
571
+ // The ramp is the reference path's colouring, so an allele is taken off it
572
+ // entirely: a hue here — even a pale one — says the allele IS the reference at
573
+ // that position, which is the opposite of what it is.
574
+ test('paints an allele off the ramp, in the flat alternative colour', () => {
575
+ const colors = colorsFor({ start: 0, end: 1000 })
576
+ expect(colors['alt+']).toBe(packAbgr(60, 65, 72, 255))
577
+ })
578
+
579
+ // Every allele the same colour, wherever it branches: the ramp is not what
580
+ // distinguishes them, and a reader looking for one is reading position off a
581
+ // node that has none.
582
+ test('paints two alleles at different positions the same colour', () => {
583
+ const colors = colorsFor({ start: 0, end: 1000 })
584
+ expect(colors['alt+']).toBe(colors['alt2+'])
585
+ expect(colors['alt+']).not.toBe(expectedColor(500, 0, 1000))
586
+ })
587
+
588
+ test('greys a node with no reference position at all', () => {
589
+ expect(colorsFor({ start: 0, end: 1000 })['loose+']).toBe(
590
+ packAbgr(160, 160, 160, 255),
591
+ )
592
+ })
593
+
594
+ // A whole-file import states no region, so the ramp spans what it drew. The
595
+ // two backbone segments then take the ends of the ramp rather than sitting in
596
+ // the middle of a window nothing declared.
597
+ test('falls back to the drawn extent with no region', () => {
598
+ const colors = colorsFor()
599
+ expect(colors['left+']).toBe(expectedColor(50, 50, 900))
600
+ expect(colors['right+']).toBe(expectedColor(950, 50, 900))
601
+ })
602
+ })
603
+
604
+ // In both anchored layouts consecutive segments meet at exactly one point, so
605
+ // every backbone link has a zero-length chord. The ribbon fan used to be
606
+ // perpendicular to that chord and gave up when there was none, which dropped the
607
+ // edge outright: no curve, no arrowhead, nothing to hover, along the whole
608
+ // backbone, and the abutting nodes hid it. Only reachable with drawPaths on,
609
+ // which is why it survived — the plain path draws the edge either way.
610
+ describe('abutting nodes under drawPaths', () => {
611
+ const abutting = {
612
+ name: 'abutting',
613
+ nodes: [
614
+ { id: 'A+', name: 'A', length: 100, depth: 1 },
615
+ { id: 'B+', name: 'B', length: 100, depth: 1 },
616
+ ],
617
+ edges: [{ from: 'A+', to: 'B+', pathIds: ['p1', 'p2'] }],
618
+ paths: [
619
+ { name: 'p1', nodeIds: ['A+', 'B+'] },
620
+ { name: 'p2', nodeIds: ['A+', 'B+'] },
621
+ ],
622
+ }
623
+ const nodeById = new Map(abutting.nodes.map(n => [n.id, n]))
624
+ const positions = {
625
+ 'A+': [
626
+ { x: 0, y: 0 },
627
+ { x: 100, y: 0 },
628
+ ],
629
+ 'B+': [
630
+ { x: 100, y: 0 },
631
+ { x: 200, y: 0 },
632
+ ],
633
+ }
634
+
635
+ function build(drawPaths: boolean) {
636
+ return buildGeometry({
637
+ axis: iso(),
638
+ nodePositions: positions,
639
+ graph: abutting,
640
+ nodeById,
641
+ colorScheme: 'uniform',
642
+ contigThickness: 10,
643
+ connectorThickness: 4,
644
+ drawPaths,
645
+ })
646
+ }
647
+
648
+ test('draws one ribbon per path where a plain edge draws one curve', () => {
649
+ expect(build(false).edgeCurves).toHaveLength(1)
650
+ expect(build(true).edgeCurves).toHaveLength(2)
651
+ })
652
+
653
+ test('fans them across the row, since the nodes run along it', () => {
654
+ const ys = build(true).edgeCurves.map(c => c.curves[0]!.y0)
655
+ // the node's own direction stands in for the missing chord, so the two
656
+ // ribbons straddle y = 0 rather than landing on top of each other
657
+ expect(ys).toEqual([-1.5, 1.5])
658
+ })
659
+
660
+ test('keys the ribbons under the edge, so they stay hoverable', () => {
661
+ expect(build(true).edgeCurveRuns.get(0)).toEqual({ start: 0, count: 2 })
662
+ })
663
+ })
664
+
665
+ // An arrowhead states which way a link is read, which belongs to the edge and
666
+ // not to each path crossing it. One head per ribbon said it once per haplotype:
667
+ // five stacked heads in five colours at each joint, which is the confetti that
668
+ // made drawPaths unusable on a force layout and reached the anchored ones the
669
+ // moment their abutting edges started drawing again.
670
+ describe('one arrowhead per edge, whatever crosses it', () => {
671
+ const graph = {
672
+ name: 'ribbons',
673
+ nodes: [
674
+ { id: 'A+', name: 'A', length: 100, depth: 1 },
675
+ { id: 'B+', name: 'B', length: 100, depth: 1 },
676
+ ],
677
+ edges: [{ from: 'A+', to: 'B+', pathIds: ['p1', 'p2', 'p3'] }],
678
+ paths: [
679
+ { name: 'p1', nodeIds: ['A+', 'B+'] },
680
+ { name: 'p2', nodeIds: ['A+', 'B+'] },
681
+ { name: 'p3', nodeIds: ['A+', 'B+'] },
682
+ ],
683
+ }
684
+ const nodeById = new Map(graph.nodes.map(n => [n.id, n]))
685
+ const positions = {
686
+ 'A+': [
687
+ { x: 0, y: 0 },
688
+ { x: 100, y: 0 },
689
+ ],
690
+ 'B+': [
691
+ { x: 200, y: 0 },
692
+ { x: 300, y: 0 },
693
+ ],
694
+ }
695
+
696
+ function build(drawPaths: boolean) {
697
+ return buildGeometry({
698
+ axis: iso(),
699
+ nodePositions: positions,
700
+ graph,
701
+ nodeById,
702
+ colorScheme: 'uniform',
703
+ contigThickness: 10,
704
+ connectorThickness: 4,
705
+ drawPaths,
706
+ })
707
+ }
708
+
709
+ const heads = (b: ReturnType<typeof build>) => b.arrows.length
710
+
711
+ test('three ribbons still leave one head', () => {
712
+ expect(heads(build(false))).toBe(1)
713
+ expect(build(true).edgeCurves).toHaveLength(3)
714
+ expect(heads(build(true))).toBe(1)
715
+ })
716
+
717
+ test('and it is the edge colour, not a haplotype it would have to pick', () => {
718
+ const plain = build(false)
719
+ const ribboned = build(true)
720
+ const colorOf = (b: ReturnType<typeof build>) => b.arrows[0]!.color
721
+ expect(colorOf(ribboned)).toBe(colorOf(plain))
722
+ })
723
+ })