@graphty/graph-io 0.0.0 → 0.2.0

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 (339) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +250 -28
  3. package/dist/chunks/children-CL3Cy0ez.js +238 -0
  4. package/dist/chunks/children-CL3Cy0ez.js.map +1 -0
  5. package/dist/chunks/escape-DyI8JofU.js +938 -0
  6. package/dist/chunks/escape-DyI8JofU.js.map +1 -0
  7. package/dist/chunks/importer-CQnJuWJw.js +2987 -0
  8. package/dist/chunks/importer-CQnJuWJw.js.map +1 -0
  9. package/dist/chunks/importer-CpCpfbxr.js +2015 -0
  10. package/dist/chunks/importer-CpCpfbxr.js.map +1 -0
  11. package/dist/chunks/importer-DbnGYr3_.js +2342 -0
  12. package/dist/chunks/importer-DbnGYr3_.js.map +1 -0
  13. package/dist/chunks/importer-GozH8DkN.js +3050 -0
  14. package/dist/chunks/importer-GozH8DkN.js.map +1 -0
  15. package/dist/chunks/records-CGpxszm1.js +605 -0
  16. package/dist/chunks/records-CGpxszm1.js.map +1 -0
  17. package/dist/chunks/text-CajMdVFy.js +189 -0
  18. package/dist/chunks/text-CajMdVFy.js.map +1 -0
  19. package/dist/chunks/writer-DxSKC7TL.js +2842 -0
  20. package/dist/chunks/writer-DxSKC7TL.js.map +1 -0
  21. package/dist/csv.d.ts +1 -0
  22. package/dist/csv.js +1702 -0
  23. package/dist/csv.js.map +1 -0
  24. package/dist/dot.d.ts +1 -0
  25. package/dist/dot.js +8 -0
  26. package/dist/dot.js.map +1 -0
  27. package/dist/gexf.d.ts +1 -0
  28. package/dist/gexf.js +3466 -0
  29. package/dist/gexf.js.map +1 -0
  30. package/dist/gml.d.ts +1 -0
  31. package/dist/gml.js +2647 -0
  32. package/dist/gml.js.map +1 -0
  33. package/dist/graph-io.d.ts +1 -0
  34. package/dist/graph-io.js +790 -0
  35. package/dist/graph-io.js.map +1 -0
  36. package/dist/graphml.d.ts +1 -0
  37. package/dist/graphml.js +8 -0
  38. package/dist/graphml.js.map +1 -0
  39. package/dist/json.d.ts +1 -0
  40. package/dist/json.js +11 -0
  41. package/dist/json.js.map +1 -0
  42. package/dist/neo4j.d.ts +1 -0
  43. package/dist/neo4j.js +2046 -0
  44. package/dist/neo4j.js.map +1 -0
  45. package/dist/pajek.d.ts +1 -0
  46. package/dist/pajek.js +8 -0
  47. package/dist/pajek.js.map +1 -0
  48. package/dist/src/children.d.ts +134 -0
  49. package/dist/src/children.d.ts.map +1 -0
  50. package/dist/src/children.js +274 -0
  51. package/dist/src/children.js.map +1 -0
  52. package/dist/src/common/attributes.d.ts +229 -0
  53. package/dist/src/common/attributes.d.ts.map +1 -0
  54. package/dist/src/common/attributes.js +368 -0
  55. package/dist/src/common/attributes.js.map +1 -0
  56. package/dist/src/common/codes.d.ts +105 -0
  57. package/dist/src/common/codes.d.ts.map +1 -0
  58. package/dist/src/common/codes.js +107 -0
  59. package/dist/src/common/codes.js.map +1 -0
  60. package/dist/src/common/declared-types.d.ts +84 -0
  61. package/dist/src/common/declared-types.d.ts.map +1 -0
  62. package/dist/src/common/declared-types.js +326 -0
  63. package/dist/src/common/declared-types.js.map +1 -0
  64. package/dist/src/common/direction.d.ts +206 -0
  65. package/dist/src/common/direction.d.ts.map +1 -0
  66. package/dist/src/common/direction.js +370 -0
  67. package/dist/src/common/direction.js.map +1 -0
  68. package/dist/src/common/escape.d.ts +92 -0
  69. package/dist/src/common/escape.d.ts.map +1 -0
  70. package/dist/src/common/escape.js +212 -0
  71. package/dist/src/common/escape.js.map +1 -0
  72. package/dist/src/common/export.d.ts +249 -0
  73. package/dist/src/common/export.d.ts.map +1 -0
  74. package/dist/src/common/export.js +594 -0
  75. package/dist/src/common/export.js.map +1 -0
  76. package/dist/src/common/format.d.ts +59 -0
  77. package/dist/src/common/format.d.ts.map +1 -0
  78. package/dist/src/common/format.js +106 -0
  79. package/dist/src/common/format.js.map +1 -0
  80. package/dist/src/common/ids.d.ts +83 -0
  81. package/dist/src/common/ids.d.ts.map +1 -0
  82. package/dist/src/common/ids.js +158 -0
  83. package/dist/src/common/ids.js.map +1 -0
  84. package/dist/src/common/input.d.ts +100 -0
  85. package/dist/src/common/input.d.ts.map +1 -0
  86. package/dist/src/common/input.js +335 -0
  87. package/dist/src/common/input.js.map +1 -0
  88. package/dist/src/common/lists.d.ts +34 -0
  89. package/dist/src/common/lists.d.ts.map +1 -0
  90. package/dist/src/common/lists.js +185 -0
  91. package/dist/src/common/lists.js.map +1 -0
  92. package/dist/src/common/options.d.ts +108 -0
  93. package/dist/src/common/options.d.ts.map +1 -0
  94. package/dist/src/common/options.js +265 -0
  95. package/dist/src/common/options.js.map +1 -0
  96. package/dist/src/common/report.d.ts +187 -0
  97. package/dist/src/common/report.d.ts.map +1 -0
  98. package/dist/src/common/report.js +274 -0
  99. package/dist/src/common/report.js.map +1 -0
  100. package/dist/src/common/temporal.d.ts +71 -0
  101. package/dist/src/common/temporal.d.ts.map +1 -0
  102. package/dist/src/common/temporal.js +266 -0
  103. package/dist/src/common/temporal.js.map +1 -0
  104. package/dist/src/common/text.d.ts +104 -0
  105. package/dist/src/common/text.d.ts.map +1 -0
  106. package/dist/src/common/text.js +255 -0
  107. package/dist/src/common/text.js.map +1 -0
  108. package/dist/src/common/weights.d.ts +77 -0
  109. package/dist/src/common/weights.d.ts.map +1 -0
  110. package/dist/src/common/weights.js +156 -0
  111. package/dist/src/common/weights.js.map +1 -0
  112. package/dist/src/common/writer.d.ts +51 -0
  113. package/dist/src/common/writer.d.ts.map +1 -0
  114. package/dist/src/common/writer.js +108 -0
  115. package/dist/src/common/writer.js.map +1 -0
  116. package/dist/src/common/xml.d.ts +245 -0
  117. package/dist/src/common/xml.d.ts.map +1 -0
  118. package/dist/src/common/xml.js +942 -0
  119. package/dist/src/common/xml.js.map +1 -0
  120. package/dist/src/formats/csv/exporter.d.ts +70 -0
  121. package/dist/src/formats/csv/exporter.d.ts.map +1 -0
  122. package/dist/src/formats/csv/exporter.js +682 -0
  123. package/dist/src/formats/csv/exporter.js.map +1 -0
  124. package/dist/src/formats/csv/header.d.ts +66 -0
  125. package/dist/src/formats/csv/header.d.ts.map +1 -0
  126. package/dist/src/formats/csv/header.js +152 -0
  127. package/dist/src/formats/csv/header.js.map +1 -0
  128. package/dist/src/formats/csv/importer.d.ts +82 -0
  129. package/dist/src/formats/csv/importer.d.ts.map +1 -0
  130. package/dist/src/formats/csv/importer.js +849 -0
  131. package/dist/src/formats/csv/importer.js.map +1 -0
  132. package/dist/src/formats/csv/index.d.ts +60 -0
  133. package/dist/src/formats/csv/index.d.ts.map +1 -0
  134. package/dist/src/formats/csv/index.js +63 -0
  135. package/dist/src/formats/csv/index.js.map +1 -0
  136. package/dist/src/formats/csv/records.d.ts +188 -0
  137. package/dist/src/formats/csv/records.d.ts.map +1 -0
  138. package/dist/src/formats/csv/records.js +702 -0
  139. package/dist/src/formats/csv/records.js.map +1 -0
  140. package/dist/src/formats/csv/values.d.ts +105 -0
  141. package/dist/src/formats/csv/values.d.ts.map +1 -0
  142. package/dist/src/formats/csv/values.js +192 -0
  143. package/dist/src/formats/csv/values.js.map +1 -0
  144. package/dist/src/formats/dot/exporter.d.ts +52 -0
  145. package/dist/src/formats/dot/exporter.d.ts.map +1 -0
  146. package/dist/src/formats/dot/exporter.js +836 -0
  147. package/dist/src/formats/dot/exporter.js.map +1 -0
  148. package/dist/src/formats/dot/importer.d.ts +102 -0
  149. package/dist/src/formats/dot/importer.d.ts.map +1 -0
  150. package/dist/src/formats/dot/importer.js +1291 -0
  151. package/dist/src/formats/dot/importer.js.map +1 -0
  152. package/dist/src/formats/dot/index.d.ts +7 -0
  153. package/dist/src/formats/dot/index.d.ts.map +1 -0
  154. package/dist/src/formats/dot/index.js +7 -0
  155. package/dist/src/formats/dot/index.js.map +1 -0
  156. package/dist/src/formats/dot/names.d.ts +29 -0
  157. package/dist/src/formats/dot/names.d.ts.map +1 -0
  158. package/dist/src/formats/dot/names.js +28 -0
  159. package/dist/src/formats/dot/names.js.map +1 -0
  160. package/dist/src/formats/dot/tokenizer.d.ts +114 -0
  161. package/dist/src/formats/dot/tokenizer.d.ts.map +1 -0
  162. package/dist/src/formats/dot/tokenizer.js +341 -0
  163. package/dist/src/formats/dot/tokenizer.js.map +1 -0
  164. package/dist/src/formats/gexf/exporter.d.ts +56 -0
  165. package/dist/src/formats/gexf/exporter.d.ts.map +1 -0
  166. package/dist/src/formats/gexf/exporter.js +1395 -0
  167. package/dist/src/formats/gexf/exporter.js.map +1 -0
  168. package/dist/src/formats/gexf/importer.d.ts +73 -0
  169. package/dist/src/formats/gexf/importer.d.ts.map +1 -0
  170. package/dist/src/formats/gexf/importer.js +1880 -0
  171. package/dist/src/formats/gexf/importer.js.map +1 -0
  172. package/dist/src/formats/gexf/index.d.ts +96 -0
  173. package/dist/src/formats/gexf/index.d.ts.map +1 -0
  174. package/dist/src/formats/gexf/index.js +97 -0
  175. package/dist/src/formats/gexf/index.js.map +1 -0
  176. package/dist/src/formats/gexf/schema.d.ts +135 -0
  177. package/dist/src/formats/gexf/schema.d.ts.map +1 -0
  178. package/dist/src/formats/gexf/schema.js +323 -0
  179. package/dist/src/formats/gexf/schema.js.map +1 -0
  180. package/dist/src/formats/gml/exporter.d.ts +69 -0
  181. package/dist/src/formats/gml/exporter.d.ts.map +1 -0
  182. package/dist/src/formats/gml/exporter.js +1093 -0
  183. package/dist/src/formats/gml/exporter.js.map +1 -0
  184. package/dist/src/formats/gml/importer.d.ts +66 -0
  185. package/dist/src/formats/gml/importer.d.ts.map +1 -0
  186. package/dist/src/formats/gml/importer.js +1331 -0
  187. package/dist/src/formats/gml/importer.js.map +1 -0
  188. package/dist/src/formats/gml/index.d.ts +85 -0
  189. package/dist/src/formats/gml/index.d.ts.map +1 -0
  190. package/dist/src/formats/gml/index.js +88 -0
  191. package/dist/src/formats/gml/index.js.map +1 -0
  192. package/dist/src/formats/gml/syntax.d.ts +186 -0
  193. package/dist/src/formats/gml/syntax.d.ts.map +1 -0
  194. package/dist/src/formats/gml/syntax.js +467 -0
  195. package/dist/src/formats/gml/syntax.js.map +1 -0
  196. package/dist/src/formats/graphml/constants.d.ts +169 -0
  197. package/dist/src/formats/graphml/constants.d.ts.map +1 -0
  198. package/dist/src/formats/graphml/constants.js +165 -0
  199. package/dist/src/formats/graphml/constants.js.map +1 -0
  200. package/dist/src/formats/graphml/exporter.d.ts +34 -0
  201. package/dist/src/formats/graphml/exporter.d.ts.map +1 -0
  202. package/dist/src/formats/graphml/exporter.js +1176 -0
  203. package/dist/src/formats/graphml/exporter.js.map +1 -0
  204. package/dist/src/formats/graphml/importer.d.ts +31 -0
  205. package/dist/src/formats/graphml/importer.d.ts.map +1 -0
  206. package/dist/src/formats/graphml/importer.js +1607 -0
  207. package/dist/src/formats/graphml/importer.js.map +1 -0
  208. package/dist/src/formats/graphml/index.d.ts +8 -0
  209. package/dist/src/formats/graphml/index.d.ts.map +1 -0
  210. package/dist/src/formats/graphml/index.js +8 -0
  211. package/dist/src/formats/graphml/index.js.map +1 -0
  212. package/dist/src/formats/graphml/tree.d.ts +72 -0
  213. package/dist/src/formats/graphml/tree.d.ts.map +1 -0
  214. package/dist/src/formats/graphml/tree.js +290 -0
  215. package/dist/src/formats/graphml/tree.js.map +1 -0
  216. package/dist/src/formats/json/dialect.d.ts +125 -0
  217. package/dist/src/formats/json/dialect.d.ts.map +1 -0
  218. package/dist/src/formats/json/dialect.js +262 -0
  219. package/dist/src/formats/json/dialect.js.map +1 -0
  220. package/dist/src/formats/json/exporter.d.ts +89 -0
  221. package/dist/src/formats/json/exporter.d.ts.map +1 -0
  222. package/dist/src/formats/json/exporter.js +1358 -0
  223. package/dist/src/formats/json/exporter.js.map +1 -0
  224. package/dist/src/formats/json/importer.d.ts +108 -0
  225. package/dist/src/formats/json/importer.d.ts.map +1 -0
  226. package/dist/src/formats/json/importer.js +1838 -0
  227. package/dist/src/formats/json/importer.js.map +1 -0
  228. package/dist/src/formats/json/index.d.ts +8 -0
  229. package/dist/src/formats/json/index.d.ts.map +1 -0
  230. package/dist/src/formats/json/index.js +8 -0
  231. package/dist/src/formats/json/index.js.map +1 -0
  232. package/dist/src/formats/neo4j/exporter.d.ts +68 -0
  233. package/dist/src/formats/neo4j/exporter.d.ts.map +1 -0
  234. package/dist/src/formats/neo4j/exporter.js +1055 -0
  235. package/dist/src/formats/neo4j/exporter.js.map +1 -0
  236. package/dist/src/formats/neo4j/header.d.ts +52 -0
  237. package/dist/src/formats/neo4j/header.d.ts.map +1 -0
  238. package/dist/src/formats/neo4j/header.js +131 -0
  239. package/dist/src/formats/neo4j/header.js.map +1 -0
  240. package/dist/src/formats/neo4j/importer.d.ts +73 -0
  241. package/dist/src/formats/neo4j/importer.d.ts.map +1 -0
  242. package/dist/src/formats/neo4j/importer.js +932 -0
  243. package/dist/src/formats/neo4j/importer.js.map +1 -0
  244. package/dist/src/formats/neo4j/index.d.ts +79 -0
  245. package/dist/src/formats/neo4j/index.d.ts.map +1 -0
  246. package/dist/src/formats/neo4j/index.js +83 -0
  247. package/dist/src/formats/neo4j/index.js.map +1 -0
  248. package/dist/src/formats/pajek/exporter.d.ts +58 -0
  249. package/dist/src/formats/pajek/exporter.d.ts.map +1 -0
  250. package/dist/src/formats/pajek/exporter.js +825 -0
  251. package/dist/src/formats/pajek/exporter.js.map +1 -0
  252. package/dist/src/formats/pajek/importer.d.ts +88 -0
  253. package/dist/src/formats/pajek/importer.d.ts.map +1 -0
  254. package/dist/src/formats/pajek/importer.js +1047 -0
  255. package/dist/src/formats/pajek/importer.js.map +1 -0
  256. package/dist/src/formats/pajek/index.d.ts +7 -0
  257. package/dist/src/formats/pajek/index.d.ts.map +1 -0
  258. package/dist/src/formats/pajek/index.js +7 -0
  259. package/dist/src/formats/pajek/index.js.map +1 -0
  260. package/dist/src/formats/pajek/syntax.d.ts +112 -0
  261. package/dist/src/formats/pajek/syntax.d.ts.map +1 -0
  262. package/dist/src/formats/pajek/syntax.js +269 -0
  263. package/dist/src/formats/pajek/syntax.js.map +1 -0
  264. package/dist/src/index.d.ts +35 -0
  265. package/dist/src/index.d.ts.map +1 -0
  266. package/dist/src/index.js +39 -0
  267. package/dist/src/index.js.map +1 -0
  268. package/dist/src/registry.d.ts +207 -0
  269. package/dist/src/registry.d.ts.map +1 -0
  270. package/dist/src/registry.js +481 -0
  271. package/dist/src/registry.js.map +1 -0
  272. package/dist/src/sniff.d.ts +104 -0
  273. package/dist/src/sniff.d.ts.map +1 -0
  274. package/dist/src/sniff.js +357 -0
  275. package/dist/src/sniff.js.map +1 -0
  276. package/dist/src/types.d.ts +238 -0
  277. package/dist/src/types.d.ts.map +1 -0
  278. package/dist/src/types.js +29 -0
  279. package/dist/src/types.js.map +1 -0
  280. package/dist/tsconfig.build.tsbuildinfo +1 -0
  281. package/package.json +122 -7
  282. package/src/children.ts +335 -0
  283. package/src/common/attributes.ts +520 -0
  284. package/src/common/codes.ts +153 -0
  285. package/src/common/declared-types.ts +374 -0
  286. package/src/common/direction.ts +518 -0
  287. package/src/common/escape.ts +231 -0
  288. package/src/common/export.ts +817 -0
  289. package/src/common/format.ts +111 -0
  290. package/src/common/ids.ts +176 -0
  291. package/src/common/input.ts +378 -0
  292. package/src/common/lists.ts +196 -0
  293. package/src/common/options.ts +377 -0
  294. package/src/common/report.ts +352 -0
  295. package/src/common/temporal.ts +302 -0
  296. package/src/common/text.ts +294 -0
  297. package/src/common/weights.ts +202 -0
  298. package/src/common/writer.ts +123 -0
  299. package/src/common/xml.ts +1053 -0
  300. package/src/formats/csv/exporter.ts +894 -0
  301. package/src/formats/csv/header.ts +172 -0
  302. package/src/formats/csv/importer.ts +1104 -0
  303. package/src/formats/csv/index.ts +88 -0
  304. package/src/formats/csv/records.ts +813 -0
  305. package/src/formats/csv/values.ts +224 -0
  306. package/src/formats/dot/exporter.ts +1014 -0
  307. package/src/formats/dot/importer.ts +1549 -0
  308. package/src/formats/dot/index.ts +7 -0
  309. package/src/formats/dot/names.ts +40 -0
  310. package/src/formats/dot/tokenizer.ts +384 -0
  311. package/src/formats/gexf/exporter.ts +1696 -0
  312. package/src/formats/gexf/importer.ts +2333 -0
  313. package/src/formats/gexf/index.ts +142 -0
  314. package/src/formats/gexf/schema.ts +361 -0
  315. package/src/formats/gml/exporter.ts +1404 -0
  316. package/src/formats/gml/importer.ts +1591 -0
  317. package/src/formats/gml/index.ts +128 -0
  318. package/src/formats/gml/syntax.ts +545 -0
  319. package/src/formats/graphml/constants.ts +225 -0
  320. package/src/formats/graphml/exporter.ts +1458 -0
  321. package/src/formats/graphml/importer.ts +2027 -0
  322. package/src/formats/graphml/index.ts +8 -0
  323. package/src/formats/graphml/tree.ts +318 -0
  324. package/src/formats/json/dialect.ts +317 -0
  325. package/src/formats/json/exporter.ts +1616 -0
  326. package/src/formats/json/importer.ts +2271 -0
  327. package/src/formats/json/index.ts +8 -0
  328. package/src/formats/neo4j/exporter.ts +1287 -0
  329. package/src/formats/neo4j/header.ts +156 -0
  330. package/src/formats/neo4j/importer.ts +1220 -0
  331. package/src/formats/neo4j/index.ts +116 -0
  332. package/src/formats/pajek/exporter.ts +1000 -0
  333. package/src/formats/pajek/importer.ts +1311 -0
  334. package/src/formats/pajek/index.ts +7 -0
  335. package/src/formats/pajek/syntax.ts +307 -0
  336. package/src/index.ts +244 -0
  337. package/src/registry.ts +617 -0
  338. package/src/sniff.ts +397 -0
  339. package/src/types.ts +262 -0
@@ -0,0 +1,1616 @@
1
+ /**
2
+ * The JSON exporter (design section 8.5): one GraphExporter with a `dialect` option writing
3
+ * NetworkX node-link (the default), d3 (`links`, optional index endpoints), JSON Graph Format v2,
4
+ * Cytoscape.js elements, graphology serialisation or vis.js. The dialect defaults to the one the
5
+ * importer recorded under `meta.extra.json` so a JSON file re-exported as JSON keeps its shape,
6
+ * and to node-link for a snapshot from any other source.
7
+ *
8
+ * check() runs the generic capability pre-flight against the dialect's table and appends the
9
+ * dialect-specific notes (non-finite numbers written as null, JGF id stringification / collisions
10
+ * / node order, direction dropped by Cytoscape and vis, mutual pairs expanded, reserved key
11
+ * collisions, positions written as plain attributes, a Cytoscape `parents` column) before anything
12
+ * is written; the same column plan drives write(), so the notes and the output agree.
13
+ *
14
+ * Every column without a structural slot in the dialect is written as a plain attribute under its
15
+ * column name; an f32 value is written as the shortest decimal that round-trips through
16
+ * Math.fround (design section 3.7); a multi-component value is an array; a list is an array; a json
17
+ * value is its JSON text; an unset row writes no key (design section 5.3). Non-finite numbers,
18
+ * which JSON cannot carry, become null and are reported.
19
+ */
20
+
21
+ import { type Column, GraphFormatError, type GraphSnapshot, INVALID_INDEX, type NodeId } from "@graphty/graph-format";
22
+
23
+ import { type PairFolding, pairFolding } from "../../common/direction.js";
24
+ import { checkCapabilities, countMixedEdges, LOSS } from "../../common/export.js";
25
+ import { formatF32 } from "../../common/format.js";
26
+ import { type ResolvedExportOptions, resolveExportOptions } from "../../common/options.js";
27
+ import { explicitWeights } from "../../common/weights.js";
28
+ import { encodeChunks, joinText } from "../../common/writer.js";
29
+ import { type CommonExportOptions, type ExportCapabilities, type GraphExporter, type LossNote } from "../../types.js";
30
+ import {
31
+ CYTOSCAPE_ELEMENT_KEYS,
32
+ DIALECT_DEFAULT_DIRECTED,
33
+ dialectCapabilities,
34
+ isJsonDialect,
35
+ JSON_DIALECTS,
36
+ type JsonDialect,
37
+ type JsonShapeMeta,
38
+ shapeMetaOf,
39
+ SUFFIX,
40
+ } from "./dialect.js";
41
+
42
+ /** The format-specific options of the JSON exporter. */
43
+ export interface JsonExportOptions {
44
+ /** The dialect to write; default: the dialect the importer recorded, else "node-link". */
45
+ dialect?: JsonDialect | undefined;
46
+ /** Spaces per indentation level; 0 (default) writes compact JSON. */
47
+ indent?: number | undefined;
48
+ /** node-link / d3: the key of the edge array; default: the recorded key, else "edges" (d3: "links"). */
49
+ edgesKey?: string | undefined;
50
+ /** node-link / d3 / vis: the node id key; default: the recorded key, else "id". */
51
+ nodeIdKey?: string | undefined;
52
+ /** node-link / d3: write endpoints as node array positions; default: the recorded flag, else false. */
53
+ indexLinks?: boolean | undefined;
54
+ /** node-link / d3 / vis: the source key; default: the recorded key, else "source" (vis: "from"). */
55
+ sourceKey?: string | undefined;
56
+ /** node-link / d3 / vis: the target key; default: the recorded key, else "target" (vis: "to"). */
57
+ targetKey?: string | undefined;
58
+ /** The key the weight is written under; default: the key the JSON importer read it from, else "weight". */
59
+ weightKey?: string | undefined;
60
+ }
61
+
62
+ /**
63
+ * The LossNote codes of the JSON exporter's check(): the dialect-specific ones and, aliased, the
64
+ * shared ones it records (`LOSS` holds the rest of the generic pre-flight's codes). A key is the
65
+ * code without its severity prefix.
66
+ */
67
+ export const JSON_LOSS = Object.freeze({
68
+ /** Non-finite numbers (columns, weights) are written as null. */
69
+ NONFINITE_AS_NULL: "W_NONFINITE_AS_NULL",
70
+ /** Cytoscape, vis and d3 carry no direction; the file re-imports with the dialect's default direction. */
71
+ DIRECTION_DROPPED: "W_DIRECTION_DROPPED",
72
+ /** GEXF mutual pairs are written as two directed edges. */
73
+ MUTUAL_EXPANDED: LOSS.MUTUAL_EXPANDED,
74
+ /** JGF keys its nodes by string; numeric ids re-import as text unless ids: "canonical". */
75
+ NUMERIC_IDS_STRINGIFIED: "W_NUMERIC_IDS_STRINGIFIED",
76
+ /** JGF: two ids have the same text; export() throws E_INVALID_ID. */
77
+ ID_TEXT_COLLISION: LOSS.ID_TEXT_COLLISION,
78
+ /** JGF: integer-like id keys are enumerated first and ascending by JSON parsers; node order changes on re-import. */
79
+ NODE_ORDER: "W_NODE_ORDER",
80
+ /** A column named like a reserved key of the dialect (id, source, target, ...) is skipped. */
81
+ RESERVED_KEY: "W_RESERVED_KEY",
82
+ /** A plain edge column named like the weight key reads back as THE weight (or is skipped when weights are written). */
83
+ WEIGHT_KEY_CLASH: LOSS.WEIGHT_KEY_CLASH,
84
+ /** A position column without a slot (every dialect but Cytoscape) is a plain array attribute; the role is lost. */
85
+ POSITIONS_DROPPED: LOSS.POSITIONS,
86
+ /** Cytoscape positions are 2D; non-zero z values are dropped. */
87
+ POSITION_Z_DROPPED: "W_POSITION_Z_DROPPED",
88
+ /** Cytoscape has a single parent; a `parents` list column cannot be written. */
89
+ PARENTS_DROPPED: LOSS.PARENTS,
90
+ /** node-link / d3 have no edge id slot; the id column is written as a plain attribute. */
91
+ EDGE_IDS_DROPPED: LOSS.EDGE_IDS_DROPPED,
92
+ /** An f64 column of integral values reads back as i32 (JSON declares no types). */
93
+ INTEGRAL_F64_AS_I32: LOSS.INTEGRAL_F64,
94
+ /** A role column the dialect has no slot for is a plain attribute; the role is lost. */
95
+ ROLE_DROPPED: LOSS.ROLE,
96
+ /** A column without a set cell is not written (JSON declares no columns). */
97
+ EMPTY_COLUMN_DROPPED: LOSS.EMPTY_COLUMN,
98
+ });
99
+
100
+ /** The roles each dialect has a slot for (every other role column is a plain attribute, reported by checkCapabilities()). */
101
+ const SLOT_ROLES: Readonly<Record<JsonDialect, ReadonlySet<string>>> = Object.freeze({
102
+ "node-link": new Set<string>(),
103
+ d3: new Set<string>(),
104
+ jgf: new Set(["label", "kind"]),
105
+ cytoscape: new Set(["classes"]),
106
+ graphology: new Set<string>(),
107
+ vis: new Set<string>(),
108
+ });
109
+
110
+ /** The element domains. */
111
+ type Domain = "node" | "edge" | "graph";
112
+
113
+ /** A note-recording callback. */
114
+ type NoteFn = (code: string, message: string, column?: string | null, count?: number | null) => void;
115
+
116
+ /** The roles the pre-flight treats structurally; never written as attributes. */
117
+ const STRUCTURAL_ROLES: ReadonlySet<string> = new Set([
118
+ "directed",
119
+ "pair",
120
+ "mutual",
121
+ "weight",
122
+ "timeText",
123
+ "originalId",
124
+ ]);
125
+
126
+ /** The roles no JSON dialect can carry; skipped (checkCapabilities reports them). */
127
+ const TEMPORAL_ROLES: ReadonlySet<string> = new Set(["start", "end", "timestamp", "timestamps", "spells", "open"]);
128
+
129
+ /** The dialects whose attributes live in a nested dict (data / metadata / attributes). */
130
+ const NESTED_DIALECTS: ReadonlySet<JsonDialect> = new Set(["jgf", "cytoscape", "graphology"]);
131
+
132
+ /** A mutable counter of the non-finite numbers written as null. */
133
+ interface Counter {
134
+ count: number;
135
+ }
136
+
137
+ /** How one column is written. */
138
+ type Slot = "attribute" | "element" | "position" | "cytoscapePosition" | "parent" | "classes" | "label" | "relation";
139
+
140
+ /** One column's place in the output. */
141
+ interface ColumnPlan {
142
+ readonly column: Column;
143
+ /** The output key (attribute or element-level key). */
144
+ readonly key: string;
145
+ readonly slot: Slot;
146
+ }
147
+
148
+ /** The resolved exporter options. */
149
+ interface Resolved {
150
+ readonly common: ResolvedExportOptions;
151
+ readonly dialect: JsonDialect;
152
+ readonly indent: number;
153
+ readonly edgesKey: string;
154
+ readonly nodeIdKey: string | null;
155
+ readonly indexLinks: boolean;
156
+ readonly sourceKey: string;
157
+ readonly targetKey: string;
158
+ readonly weightKey: string;
159
+ readonly shape: JsonShapeMeta;
160
+ }
161
+
162
+ /** Everything write() needs, computed once by plan(). */
163
+ interface Plan {
164
+ readonly resolved: Resolved;
165
+ readonly notes: LossNote[];
166
+ readonly nodes: readonly ColumnPlan[];
167
+ readonly edges: readonly ColumnPlan[];
168
+ readonly graph: readonly ColumnPlan[];
169
+ /** The id-role edge column, or null. */
170
+ readonly edgeIds: Column | null;
171
+ /** The weight of an edge as JSON text, or null when the edge has no explicit weight. */
172
+ readonly weights: (e: number) => string | null;
173
+ /** The pair folding (design section 3.6): mirrors skipped, source directions. */
174
+ readonly folding: PairFolding;
175
+ /** The file-level direction. */
176
+ readonly directed: boolean;
177
+ /** Whether the file declares a multigraph. */
178
+ readonly multigraph: boolean;
179
+ }
180
+
181
+ /**
182
+ * Resolve the options against the snapshot's recorded shape.
183
+ * @param snapshot - the snapshot
184
+ * @param options - the caller's options
185
+ * @returns the resolved options; E_UNSUPPORTED for a bad value
186
+ */
187
+ function resolve(snapshot: GraphSnapshot, options: (JsonExportOptions & CommonExportOptions) | undefined): Resolved {
188
+ const o = options ?? {};
189
+ const common = resolveExportOptions(options);
190
+ const shape = shapeMetaOf(snapshot.meta);
191
+ let dialect: JsonDialect;
192
+ if (o.dialect === undefined) {
193
+ dialect = shape.dialect ?? "node-link";
194
+ } else if (isJsonDialect(o.dialect)) {
195
+ ({ dialect } = o);
196
+ } else {
197
+ throw unsupported("dialect", o.dialect, JSON_DIALECTS);
198
+ }
199
+ const indent = o.indent ?? 0;
200
+ if (!Number.isInteger(indent) || indent < 0 || indent > 16) {
201
+ throw unsupported("indent", indent, ["an integer 0..16"]);
202
+ }
203
+ const sameDialect = shape.dialect === dialect;
204
+ const recorded = <T>(value: T | undefined, fallback: T): T =>
205
+ sameDialect && value !== undefined ? value : fallback;
206
+ const edgesKey =
207
+ keyOption("edgesKey", o.edgesKey) ?? recorded(shape.edgesKey, dialect === "d3" ? "links" : "edges");
208
+ let nodeIdKey: string | null;
209
+ if (o.nodeIdKey !== undefined) {
210
+ nodeIdKey = keyOption("nodeIdKey", o.nodeIdKey);
211
+ } else if (sameDialect && shape.nodeIdKey !== undefined) {
212
+ ({ nodeIdKey } = shape);
213
+ } else {
214
+ nodeIdKey = "id";
215
+ }
216
+ let indexLinks = o.indexLinks ?? recorded(shape.indexLinks, false);
217
+ if (typeof indexLinks !== "boolean") {
218
+ throw unsupported("indexLinks", indexLinks, ["true", "false"]);
219
+ }
220
+ if (nodeIdKey === null) {
221
+ // positional nodes have no id to write; endpoints must be positions
222
+ indexLinks = true;
223
+ }
224
+ const sourceKey =
225
+ keyOption("sourceKey", o.sourceKey) ?? recorded(shape.sourceKey, dialect === "vis" ? "from" : "source");
226
+ const targetKey =
227
+ keyOption("targetKey", o.targetKey) ?? recorded(shape.targetKey, dialect === "vis" ? "to" : "target");
228
+ const { weightOrigin } = snapshot.meta;
229
+ const weightKey =
230
+ keyOption("weightKey", o.weightKey) ??
231
+ (weightOrigin !== null && weightOrigin.format === "json" && weightOrigin.id !== null
232
+ ? weightOrigin.id
233
+ : "weight");
234
+ return { common, dialect, indent, edgesKey, nodeIdKey, indexLinks, sourceKey, targetKey, weightKey, shape };
235
+ }
236
+
237
+ /**
238
+ * Check a key-valued option.
239
+ * @param name - the option name
240
+ * @param value - the caller's value
241
+ * @returns the key, or null when absent
242
+ */
243
+ function keyOption(name: string, value: unknown): string | null {
244
+ if (value === undefined) {
245
+ return null;
246
+ }
247
+ if (typeof value !== "string" || value.length === 0) {
248
+ throw unsupported(name, value, ["a non-empty key"]);
249
+ }
250
+ return value;
251
+ }
252
+
253
+ /**
254
+ * The E_UNSUPPORTED error of a bad option.
255
+ * @param name - the option name
256
+ * @param found - the value
257
+ * @param supported - what is accepted
258
+ * @returns the error
259
+ */
260
+ function unsupported(name: string, found: unknown, supported: readonly string[]): GraphFormatError {
261
+ const shown = typeof found === "string" ? JSON.stringify(found) : String(found);
262
+ return new GraphFormatError("E_UNSUPPORTED", `option ${name}: ${shown} is not one of ${supported.join(", ")}`, {
263
+ option: name,
264
+ found: typeof found === "string" || typeof found === "number" ? found : typeof found,
265
+ supported: [...supported],
266
+ });
267
+ }
268
+
269
+ // ============================================================ value formatting
270
+
271
+ /**
272
+ * The JSON text of a finite or non-finite number; non-finite values become null and are counted.
273
+ * @param value - the number
274
+ * @param f32 - whether the value came from an f32 store (shortest fround-round-trip text)
275
+ * @param nonfinite - the counter of null-ed values
276
+ * @returns the JSON text
277
+ */
278
+ function numberText(value: number, f32: boolean, nonfinite: Counter): string {
279
+ if (!Number.isFinite(value)) {
280
+ nonfinite.count++;
281
+ return "null";
282
+ }
283
+ if (f32) {
284
+ return formatF32(value);
285
+ }
286
+ return Object.is(value, -0) ? "0" : String(value);
287
+ }
288
+
289
+ /**
290
+ * The JSON text of any cell value: a number per its dtype, a boolean, a string, an array (a
291
+ * multi-component subarray or a list row), or a json value.
292
+ * @param value - the value
293
+ * @param f32 - whether numbers come from an f32 store
294
+ * @param nonfinite - the counter of null-ed values
295
+ * @returns the JSON text
296
+ */
297
+ function valueText(value: unknown, f32: boolean, nonfinite: Counter): string {
298
+ switch (typeof value) {
299
+ case "number":
300
+ return numberText(value, f32, nonfinite);
301
+ case "boolean":
302
+ return value ? "true" : "false";
303
+ case "string":
304
+ return JSON.stringify(value);
305
+ case "undefined":
306
+ return "null";
307
+ default:
308
+ break;
309
+ }
310
+ if (value === null) {
311
+ return "null";
312
+ }
313
+ if (Array.isArray(value) || ArrayBuffer.isView(value)) {
314
+ const items = Array.from(value as ArrayLike<unknown>, (item) => valueText(item, f32, nonfinite));
315
+ return `[${items.join(",")}]`;
316
+ }
317
+ const text = JSON.stringify(value);
318
+ return text === undefined ? "null" : text;
319
+ }
320
+
321
+ /**
322
+ * The JSON text of one set cell.
323
+ * @param column - the column
324
+ * @param row - the row
325
+ * @param ids - the snapshot's id map (for refersTo node columns)
326
+ * @param nonfinite - the counter of null-ed values
327
+ * @returns the JSON text
328
+ */
329
+ function cellText(column: Column, row: number, ids: GraphSnapshot["ids"], nonfinite: Counter): string {
330
+ switch (column.dtype) {
331
+ case "list":
332
+ return valueText(column.sliceOf(row), column.child.dtype === "f32", nonfinite);
333
+ case "json":
334
+ return valueText(column.values[row], false, nonfinite);
335
+ case "u32":
336
+ if (column.meta.refersTo === "node" && column.meta.components === 1) {
337
+ const index = column.data[row];
338
+ return index === INVALID_INDEX ? "null" : JSON.stringify(ids.idOf(index));
339
+ }
340
+ return valueText(column.value(row), false, nonfinite);
341
+ case "f32":
342
+ return valueText(column.value(row), true, nonfinite);
343
+ default:
344
+ return valueText(column.value(row), false, nonfinite);
345
+ }
346
+ }
347
+
348
+ /**
349
+ * How many cells of a column hold a non-finite number (f32 / f64 columns, their components and
350
+ * lists of them).
351
+ * @param column - the column
352
+ * @returns the count
353
+ */
354
+ function countNonFinite(column: Column): number {
355
+ let data: ArrayLike<number>;
356
+ switch (column.dtype) {
357
+ case "f32":
358
+ case "f64":
359
+ ({ data } = column);
360
+ break;
361
+ case "list":
362
+ if (column.child.dtype !== "f32" && column.child.dtype !== "f64") {
363
+ return 0;
364
+ }
365
+ ({ data } = column.child);
366
+ break;
367
+ default:
368
+ return 0;
369
+ }
370
+ let count = 0;
371
+ for (let i = 0; i < data.length; i++) {
372
+ if (!Number.isFinite(data[i])) {
373
+ count++;
374
+ }
375
+ }
376
+ return count;
377
+ }
378
+
379
+ // ============================================================ the writer
380
+
381
+ /**
382
+ * Emits JSON tokens with optional indentation, accumulating text parts the generator yields one
383
+ * element at a time.
384
+ */
385
+ class JsonWriter {
386
+ private parts: string[] = [];
387
+
388
+ private depth = 0;
389
+
390
+ private readonly firstAtDepth: boolean[] = [];
391
+
392
+ private readonly indent: number;
393
+
394
+ /**
395
+ * Create a writer.
396
+ * @param indent - spaces per level; 0 for compact output
397
+ */
398
+ constructor(indent: number) {
399
+ this.indent = indent;
400
+ }
401
+
402
+ /**
403
+ * Open an object or an array.
404
+ * @param bracket - "{" or "["
405
+ */
406
+ open(bracket: "{" | "["): void {
407
+ this.parts.push(bracket);
408
+ this.depth++;
409
+ this.firstAtDepth.push(true);
410
+ }
411
+
412
+ /**
413
+ * Close the innermost object or array.
414
+ * @param bracket - "}" or "]"
415
+ */
416
+ close(bracket: "}" | "]"): void {
417
+ const wasEmpty = this.firstAtDepth.pop() ?? true;
418
+ this.depth--;
419
+ if (!wasEmpty) {
420
+ this.parts.push(this.newline());
421
+ }
422
+ this.parts.push(bracket);
423
+ }
424
+
425
+ /**
426
+ * Start an object member: the separator and the quoted key.
427
+ * @param name - the key
428
+ */
429
+ key(name: string): void {
430
+ this.separator();
431
+ this.parts.push(JSON.stringify(name), this.indent > 0 ? ": " : ":");
432
+ }
433
+
434
+ /** Start an array element: the separator only. */
435
+ item(): void {
436
+ this.separator();
437
+ }
438
+
439
+ /**
440
+ * Append raw JSON text (a value).
441
+ * @param text - the text
442
+ */
443
+ raw(text: string): void {
444
+ this.parts.push(text);
445
+ }
446
+
447
+ /**
448
+ * Write a member with a value in one call.
449
+ * @param name - the key
450
+ * @param text - the value's JSON text
451
+ */
452
+ member(name: string, text: string): void {
453
+ this.key(name);
454
+ this.parts.push(text);
455
+ }
456
+
457
+ /**
458
+ * Hand out the text accumulated since the last take.
459
+ * @returns the text
460
+ */
461
+ take(): string {
462
+ const text = this.parts.length === 1 ? this.parts[0] : this.parts.join("");
463
+ this.parts = [];
464
+ return text;
465
+ }
466
+
467
+ /** The comma and newline before a member or element. */
468
+ private separator(): void {
469
+ const top = this.firstAtDepth.length - 1;
470
+ if (this.firstAtDepth[top]) {
471
+ this.firstAtDepth[top] = false;
472
+ } else {
473
+ this.parts.push(",");
474
+ }
475
+ this.parts.push(this.newline());
476
+ }
477
+
478
+ /**
479
+ * A newline plus the current indentation, or nothing in compact mode.
480
+ * @returns the text
481
+ */
482
+ private newline(): string {
483
+ return this.indent > 0 ? `\n${" ".repeat(this.depth * this.indent)}` : "";
484
+ }
485
+ }
486
+
487
+ // ============================================================ planning
488
+
489
+ /** What plan() and its helpers share. */
490
+ interface PlanContext {
491
+ readonly snapshot: GraphSnapshot;
492
+ readonly resolved: Resolved;
493
+ readonly dialect: JsonDialect;
494
+ readonly caps: ExportCapabilities;
495
+ readonly notes: LossNote[];
496
+ readonly note: NoteFn;
497
+ /** The reserved output keys per table. */
498
+ readonly reserved: Readonly<Record<Domain, ReadonlySet<string>>>;
499
+ readonly nonfinite: Counter;
500
+ }
501
+
502
+ /**
503
+ * Build the column plan and the notes for one dialect.
504
+ * @param snapshot - the snapshot
505
+ * @param resolved - the resolved options
506
+ * @returns the plan
507
+ */
508
+ function plan(snapshot: GraphSnapshot, resolved: Resolved): Plan {
509
+ const { dialect } = resolved;
510
+ const caps = dialectCapabilities(dialect);
511
+ // the generic position and edge-id notes are replaced by planTable()'s (both are written as
512
+ // plain attributes and read back without their role)
513
+ const notes = checkCapabilities(snapshot, caps, resolved.common, {
514
+ roles: SLOT_ROLES[dialect],
515
+ positionDtype: "f32",
516
+ }).filter((n) => n.code !== LOSS.POSITIONS && n.code !== LOSS.EDGE_IDS_DROPPED);
517
+ const note: NoteFn = (code, message, column = null, count = null): void => {
518
+ notes.push(Object.freeze({ code, message, column, count }));
519
+ };
520
+ const ctx: PlanContext = {
521
+ snapshot,
522
+ resolved,
523
+ dialect,
524
+ caps,
525
+ notes,
526
+ note,
527
+ reserved: reservedKeys(snapshot, resolved),
528
+ nonfinite: { count: 0 },
529
+ };
530
+ const nodes = planTable(ctx, snapshot.nodes, "node");
531
+ const edges = planTable(ctx, snapshot.edges, "edge");
532
+ const graph = caps.graphAttributes ? planTable(ctx, snapshot.graph, "graph") : [];
533
+ withdrawSlotNotes(notes, [...nodes, ...edges]);
534
+ const weights = planWeights(ctx, edges);
535
+ if (ctx.nonfinite.count > 0) {
536
+ note(
537
+ JSON_LOSS.NONFINITE_AS_NULL,
538
+ `${ctx.nonfinite.count} non-finite number(s) are written as null`,
539
+ null,
540
+ ctx.nonfinite.count,
541
+ );
542
+ }
543
+ const { folding, directed } = planDirection(ctx);
544
+ const multigraph = snapshot.flags.multigraph || snapshot.meta.declaredMultigraph === true;
545
+ if (dialect === "jgf") {
546
+ jgfIdNotes(snapshot, note);
547
+ }
548
+ const edgeIds = snapshot.edges.byRole("id");
549
+ if (dialect === "cytoscape" && edgeIds !== null && edgeIds.nullCount > 0) {
550
+ note(
551
+ LOSS.EDGE_IDS_GENERATED,
552
+ `${edgeIds.nullCount} edge(s) have no id; canonical e<index> ids are generated for them`,
553
+ edgeIds.meta.name,
554
+ edgeIds.nullCount,
555
+ );
556
+ }
557
+ return { resolved, notes, nodes, edges, graph, edgeIds, weights, folding, directed, multigraph };
558
+ }
559
+
560
+ /**
561
+ * Withdraw the generic list note of a Cytoscape classes column: the slot writes the list as the
562
+ * `classes` string and the importer reads it back as the list it was.
563
+ * @param notes - the notes so far
564
+ * @param plans - the node and edge plans
565
+ */
566
+ function withdrawSlotNotes(notes: LossNote[], plans: readonly ColumnPlan[]): void {
567
+ for (const plan of plans) {
568
+ if (plan.slot !== "classes") {
569
+ continue;
570
+ }
571
+ const at = notes.findIndex((n) => n.code === LOSS.LIST && n.column === plan.column.meta.name);
572
+ if (at >= 0) {
573
+ notes.splice(at, 1);
574
+ }
575
+ }
576
+ }
577
+
578
+ /**
579
+ * The output keys each dialect reserves for its structure (a column of that name cannot be
580
+ * written as an attribute): the id and endpoint keys, and the weight key of a weighted snapshot.
581
+ * @param snapshot - the snapshot
582
+ * @param resolved - the resolved options
583
+ * @returns the reserved keys per table
584
+ */
585
+ function reservedKeys(snapshot: GraphSnapshot, resolved: Resolved): Readonly<Record<Domain, ReadonlySet<string>>> {
586
+ const { dialect } = resolved;
587
+ const node = new Set<string>();
588
+ const edge = new Set<string>(snapshot.flags.weighted ? [resolved.weightKey] : []);
589
+ const graph = new Set<string>();
590
+ switch (dialect) {
591
+ case "node-link":
592
+ case "d3":
593
+ if (resolved.nodeIdKey !== null) {
594
+ node.add(resolved.nodeIdKey);
595
+ }
596
+ edge.add(resolved.sourceKey).add(resolved.targetKey);
597
+ break;
598
+ case "vis":
599
+ node.add(resolved.nodeIdKey ?? "id");
600
+ edge.add(resolved.sourceKey).add(resolved.targetKey);
601
+ break;
602
+ case "cytoscape":
603
+ node.add("id").add("parent");
604
+ edge.add("id").add("source").add("target");
605
+ break;
606
+ case "jgf":
607
+ case "graphology":
608
+ break;
609
+ default: {
610
+ const name: string = dialect;
611
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown dialect ${name}`, { option: "dialect", found: name });
612
+ }
613
+ }
614
+ return { node, edge, graph };
615
+ }
616
+
617
+ /**
618
+ * Classify the columns of one table: the slot each is written into and what it loses on the way
619
+ * (JSON declares no types, so the notes name what the importer's inference reads back).
620
+ * @param ctx - the plan context
621
+ * @param table - the table
622
+ * @param domain - its domain
623
+ * @returns the plans of the written columns, in declaration order
624
+ */
625
+ function planTable(ctx: PlanContext, table: Iterable<Column>, domain: Domain): ColumnPlan[] {
626
+ const { dialect, note } = ctx;
627
+ const plans: ColumnPlan[] = [];
628
+ const reserved = ctx.reserved[domain];
629
+ const used = new Set<string>();
630
+ for (const column of table) {
631
+ const { meta } = column;
632
+ const { role } = meta;
633
+ const setRows = column.length - column.nullCount;
634
+ if (role !== null && (STRUCTURAL_ROLES.has(role) || TEMPORAL_ROLES.has(role))) {
635
+ continue;
636
+ }
637
+ if (domain !== "graph") {
638
+ ctx.nonfinite.count += countNonFinite(column);
639
+ }
640
+ const slotted = planSlot(ctx, column, domain);
641
+ if (slotted !== null) {
642
+ if (slotted !== SKIPPED) {
643
+ plans.push(slotted);
644
+ }
645
+ continue;
646
+ }
647
+ let key = meta.name;
648
+ let slot: Slot = "attribute";
649
+ if (NESTED_DIALECTS.has(dialect) && domain !== "graph") {
650
+ if (key.endsWith(SUFFIX.element)) {
651
+ key = key.slice(0, -SUFFIX.element.length);
652
+ slot = "element";
653
+ } else if (dialect === "cytoscape" && CYTOSCAPE_ELEMENT_KEYS.has(key)) {
654
+ slot = "element";
655
+ } else if (key.endsWith(SUFFIX.data)) {
656
+ key = key.slice(0, -SUFFIX.data.length);
657
+ }
658
+ }
659
+ if (slot === "attribute" && reserved.has(key)) {
660
+ note(
661
+ JSON_LOSS.RESERVED_KEY,
662
+ `${domain} column "${meta.name}" cannot be written: "${key}" is a reserved key of ${dialect}`,
663
+ meta.name,
664
+ setRows,
665
+ );
666
+ continue;
667
+ }
668
+ const slotKey = `${slot}:${key}`;
669
+ if (used.has(slotKey)) {
670
+ note(
671
+ JSON_LOSS.RESERVED_KEY,
672
+ `${domain} column "${meta.name}" cannot be written: key "${key}" is already used by another column`,
673
+ meta.name,
674
+ setRows,
675
+ );
676
+ continue;
677
+ }
678
+ used.add(slotKey);
679
+ if (setRows === 0 && domain !== "graph") {
680
+ note(
681
+ LOSS.EMPTY_COLUMN,
682
+ `${domain} column "${meta.name}" has no set cell and is not written: JSON writes values, never declarations`,
683
+ meta.name,
684
+ 0,
685
+ );
686
+ }
687
+ inferenceNotes(ctx, column, domain, setRows);
688
+ plans.push({ column, key, slot });
689
+ }
690
+ return plans;
691
+ }
692
+
693
+ /** planSlot()'s answer for a role column handled (or dropped) without a plan of its own. */
694
+ const SKIPPED = Symbol("skipped");
695
+
696
+ /**
697
+ * The structural slot of a role column in the dialect, when it has one: Cytoscape's parent,
698
+ * position and classes, JGF's label and relation; the id-role edge column is written
699
+ * structurally (data.id / key / id) where the dialect has an edge id and as a plain attribute
700
+ * elsewhere; a position column without a slot is a plain array attribute.
701
+ * @param ctx - the plan context
702
+ * @param column - the column
703
+ * @param domain - its domain
704
+ * @returns the plan, SKIPPED for a column written structurally or dropped, null for a plain attribute
705
+ */
706
+ function planSlot(ctx: PlanContext, column: Column, domain: Domain): ColumnPlan | typeof SKIPPED | null {
707
+ const { dialect, note } = ctx;
708
+ const { meta } = column;
709
+ const { role } = meta;
710
+ const setRows = column.length - column.nullCount;
711
+ if (role === "parent" || role === "parents") {
712
+ if (dialect === "cytoscape" && domain === "node") {
713
+ if (role === "parent" && column.dtype === "u32" && meta.refersTo === "node") {
714
+ return { column, key: "parent", slot: "parent" };
715
+ }
716
+ note(
717
+ LOSS.PARENTS,
718
+ `node column "${meta.name}" (${role}) cannot be written: Cytoscape has a single parent`,
719
+ meta.name,
720
+ setRows,
721
+ );
722
+ }
723
+ return SKIPPED;
724
+ }
725
+ if (role === "position" && domain === "node") {
726
+ if (dialect === "cytoscape" && (column.dtype === "f32" || column.dtype === "f64") && meta.components >= 2) {
727
+ const z = countNonZeroZ(column);
728
+ if (z > 0) {
729
+ note(
730
+ JSON_LOSS.POSITION_Z_DROPPED,
731
+ `${z} position(s) have a non-zero z; Cytoscape positions are 2D`,
732
+ meta.name,
733
+ z,
734
+ );
735
+ }
736
+ return { column, key: "position", slot: "cytoscapePosition" };
737
+ }
738
+ note(
739
+ LOSS.POSITIONS,
740
+ `node column "${meta.name}" (position) is written as a plain array attribute; its role is lost on re-import`,
741
+ meta.name,
742
+ setRows,
743
+ );
744
+ return { column, key: meta.name, slot: "position" };
745
+ }
746
+ if (role === "id" && domain === "edge") {
747
+ if (dialect === "node-link" || dialect === "d3") {
748
+ note(
749
+ LOSS.EDGE_IDS_DROPPED,
750
+ `edge column "${meta.name}" (id) is written as a plain attribute; ${dialect} has no edge id`,
751
+ meta.name,
752
+ setRows,
753
+ );
754
+ return null;
755
+ }
756
+ return SKIPPED;
757
+ }
758
+ if (dialect === "cytoscape" && role === "classes" && column.dtype === "list" && column.child.dtype === "string") {
759
+ return { column, key: "classes", slot: "classes" };
760
+ }
761
+ if (dialect === "jgf" && role === "label" && (column.dtype === "string" || column.dtype === "dict")) {
762
+ return { column, key: "label", slot: "label" };
763
+ }
764
+ if (
765
+ dialect === "jgf" &&
766
+ role === "kind" &&
767
+ domain === "edge" &&
768
+ (column.dtype === "string" || column.dtype === "dict")
769
+ ) {
770
+ return { column, key: "relation", slot: "relation" };
771
+ }
772
+ return null;
773
+ }
774
+
775
+ /**
776
+ * What the importer's inference (design section 5.1: JSON numbers are i32 when integral, else
777
+ * f64; strings are strings; arrays and objects are json) changes about a written attribute
778
+ * column beyond what checkCapabilities() already reported for its dtype: an f64 column whose set
779
+ * values are all integers reads back as i32.
780
+ * @param ctx - the plan context
781
+ * @param column - the column
782
+ * @param domain - its domain
783
+ * @param setRows - the set rows
784
+ */
785
+ function inferenceNotes(ctx: PlanContext, column: Column, domain: Domain, setRows: number): void {
786
+ if (column.dtype !== "f64" || column.meta.components > 1 || setRows === 0) {
787
+ return;
788
+ }
789
+ const { data } = column;
790
+ for (let r = 0; r < column.length; r++) {
791
+ if (column.isSet(r) && !Number.isInteger(data[r])) {
792
+ return;
793
+ }
794
+ }
795
+ ctx.note(
796
+ LOSS.INTEGRAL_F64,
797
+ `${domain} column "${column.meta.name}" is f64 with integral values only; JSON declares no types and it reads back as i32`,
798
+ column.meta.name,
799
+ setRows,
800
+ );
801
+ }
802
+
803
+ /**
804
+ * The explicit weights and their JSON text (non-finite values become null and are counted).
805
+ * @param ctx - the plan context
806
+ * @param edges - the edge column plans (for the weight-key clash note)
807
+ * @returns the weight text function
808
+ */
809
+ function planWeights(ctx: PlanContext, edges: readonly ColumnPlan[]): (e: number) => string | null {
810
+ const { snapshot, nonfinite } = ctx;
811
+ const weights = explicitWeights(snapshot);
812
+ if (!weights.weighted) {
813
+ const clash = edges.find((p) => p.slot === "attribute" && p.key === ctx.resolved.weightKey);
814
+ if (clash !== undefined) {
815
+ ctx.note(
816
+ LOSS.WEIGHT_KEY_CLASH,
817
+ `edge column "${clash.column.meta.name}" is written under "${clash.key}", the key the importer reads THE weight from; it reads back as the weight, not as a column`,
818
+ clash.column.meta.name,
819
+ clash.column.length - clash.column.nullCount,
820
+ );
821
+ }
822
+ return (): null => null;
823
+ }
824
+ for (let e = 0; e < snapshot.edgeCount; e++) {
825
+ if (weights.isExplicit(e) && !Number.isFinite(weights.value(e))) {
826
+ nonfinite.count++;
827
+ }
828
+ }
829
+ return (e: number): string | null => {
830
+ if (!weights.isExplicit(e)) {
831
+ return null;
832
+ }
833
+ return Number.isFinite(weights.value(e)) ? weights.text(e) : "null";
834
+ };
835
+ }
836
+
837
+ /**
838
+ * The direction decisions: the pair folding (mutual pairs are written as two directed edges and
839
+ * reported), the file-level direction under the onMixedDirection policy of a dialect without a
840
+ * per-edge flag, and the note of the dialects that carry no direction at all.
841
+ * @param ctx - the plan context
842
+ * @returns the folding and the file-level direction
843
+ */
844
+ function planDirection(ctx: PlanContext): { readonly folding: PairFolding; readonly directed: boolean } {
845
+ const { snapshot, dialect, caps, note } = ctx;
846
+ const folding = pairFolding(snapshot);
847
+ if (folding.mutualCount > 0) {
848
+ note(
849
+ LOSS.MUTUAL_EXPANDED,
850
+ `${folding.mutualCount} mutual pair(s) are written as two directed edges; the mutual mark is lost`,
851
+ null,
852
+ folding.mutualCount,
853
+ );
854
+ }
855
+ let { directed } = snapshot;
856
+ if (
857
+ !caps.mixedDirection &&
858
+ countMixedEdges(snapshot) > 0 &&
859
+ ctx.resolved.common.onMixedDirection === "undirected"
860
+ ) {
861
+ directed = false;
862
+ }
863
+ if (dialect === "cytoscape" || dialect === "vis" || dialect === "d3") {
864
+ const assumed = DIALECT_DEFAULT_DIRECTED[dialect];
865
+ if (directed !== assumed) {
866
+ note(
867
+ JSON_LOSS.DIRECTION_DROPPED,
868
+ `${dialect} has no direction flag; the ${directed ? "directed" : "undirected"} graph re-imports as ${assumed ? "directed" : "undirected"} unless defaultDirected is passed`,
869
+ );
870
+ }
871
+ }
872
+ return { folding, directed };
873
+ }
874
+
875
+ /**
876
+ * How many rows of a position column have a non-zero third component.
877
+ * @param column - an f32 / f64 column with at least two components
878
+ * @returns the count
879
+ */
880
+ function countNonZeroZ(column: Column): number {
881
+ if ((column.dtype !== "f32" && column.dtype !== "f64") || column.meta.components < 3) {
882
+ return 0;
883
+ }
884
+ const { components } = column.meta;
885
+ let count = 0;
886
+ for (let r = 0; r < column.length; r++) {
887
+ if (column.isSet(r) && column.data[r * components + 2] !== 0) {
888
+ count++;
889
+ }
890
+ }
891
+ return count;
892
+ }
893
+
894
+ /**
895
+ * The JGF id notes: numeric ids become string keys, colliding texts are refused, and integer-like
896
+ * keys are enumerated first and ascending by every JSON parser so the node order may change.
897
+ * @param snapshot - the snapshot
898
+ * @param note - the recorder
899
+ */
900
+ function jgfIdNotes(
901
+ snapshot: GraphSnapshot,
902
+ note: (code: string, message: string, column?: string | null, count?: number | null) => void,
903
+ ): void {
904
+ const { ids } = snapshot;
905
+ const seen = new Set<string>();
906
+ let numeric = 0;
907
+ let collisions = 0;
908
+ let indexLike = 0;
909
+ let lastIndex = -1;
910
+ let ordered = true;
911
+ let sawOther = false;
912
+ for (let i = 0; i < ids.size; i++) {
913
+ const id = ids.idOf(i);
914
+ const text = String(id);
915
+ if (typeof id === "number") {
916
+ numeric++;
917
+ }
918
+ if (seen.has(text)) {
919
+ collisions++;
920
+ }
921
+ seen.add(text);
922
+ if (isArrayIndexKey(text)) {
923
+ indexLike++;
924
+ const n = Number(text);
925
+ if (sawOther || n <= lastIndex) {
926
+ ordered = false;
927
+ }
928
+ lastIndex = n;
929
+ } else {
930
+ sawOther = true;
931
+ }
932
+ }
933
+ if (numeric > 0) {
934
+ note(
935
+ JSON_LOSS.NUMERIC_IDS_STRINGIFIED,
936
+ `${numeric} numeric node id(s) become JGF object keys (strings); pass ids: "canonical" on re-import`,
937
+ null,
938
+ numeric,
939
+ );
940
+ }
941
+ if (collisions > 0) {
942
+ note(
943
+ JSON_LOSS.ID_TEXT_COLLISION,
944
+ `${collisions} node id(s) share their text with another id; export() will throw`,
945
+ null,
946
+ collisions,
947
+ );
948
+ }
949
+ if (!ordered) {
950
+ note(
951
+ JSON_LOSS.NODE_ORDER,
952
+ `${indexLike} integer-like node id(s) are enumerated first and ascending by JSON parsers; node order changes on re-import`,
953
+ null,
954
+ indexLike,
955
+ );
956
+ }
957
+ }
958
+
959
+ /**
960
+ * Whether a key is an array index in the JS sense (enumerated before other keys, ascending).
961
+ * @param text - the key
962
+ * @returns true for canonical integer text below 2^32 - 1
963
+ */
964
+ function isArrayIndexKey(text: string): boolean {
965
+ return /^(0|[1-9][0-9]*)$/.test(text) && Number(text) < 4294967295;
966
+ }
967
+
968
+ // ============================================================ writing
969
+
970
+ /**
971
+ * The text parts of the document.
972
+ * @param snapshot - the snapshot
973
+ * @param p - the plan
974
+ * @yields one part per header, node, edge and footer
975
+ * @returns nothing
976
+ */
977
+ function* write(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
978
+ const { dialect } = p.resolved;
979
+ if (p.notes.some((n) => n.code === LOSS.MIXED_DIRECTION_ERROR)) {
980
+ throw new GraphFormatError(
981
+ "E_DIRECTED",
982
+ `${dialect} has no mixed direction; pass onMixedDirection "directed" or "undirected"`,
983
+ { reason: "mixed direction", dialect },
984
+ );
985
+ }
986
+ if (p.notes.some((n) => n.code === JSON_LOSS.ID_TEXT_COLLISION)) {
987
+ throw new GraphFormatError("E_INVALID_ID", "two node ids have the same text; JGF keys nodes by text", {
988
+ reason: "collision",
989
+ });
990
+ }
991
+ switch (dialect) {
992
+ case "node-link":
993
+ case "d3":
994
+ yield* writeNodeLink(snapshot, p);
995
+ return;
996
+ case "vis":
997
+ yield* writeVis(snapshot, p);
998
+ return;
999
+ case "graphology":
1000
+ yield* writeGraphology(snapshot, p);
1001
+ return;
1002
+ case "jgf":
1003
+ yield* writeJgf(snapshot, p);
1004
+ return;
1005
+ case "cytoscape":
1006
+ yield* writeCytoscape(snapshot, p);
1007
+ return;
1008
+ default: {
1009
+ const name: string = dialect;
1010
+ throw new GraphFormatError("E_UNSUPPORTED", `unknown dialect ${name}`, { option: "dialect", found: name });
1011
+ }
1012
+ }
1013
+ }
1014
+
1015
+ /** The shared per-row state of the writers. */
1016
+ interface Cursor {
1017
+ readonly w: JsonWriter;
1018
+ readonly ids: GraphSnapshot["ids"];
1019
+ readonly nonfinite: Counter;
1020
+ }
1021
+
1022
+ /**
1023
+ * Write the members of one row's planned columns into the current object, attributes only.
1024
+ * @param c - the cursor
1025
+ * @param plans - the table plan
1026
+ * @param row - the row
1027
+ * @param slots - which slots to write
1028
+ */
1029
+ function writeSlots(c: Cursor, plans: readonly ColumnPlan[], row: number, slots: ReadonlySet<Slot>): void {
1030
+ for (const item of plans) {
1031
+ if (!slots.has(item.slot) || !item.column.isSet(row)) {
1032
+ continue;
1033
+ }
1034
+ let text: string;
1035
+ switch (item.slot) {
1036
+ case "position": {
1037
+ const value = item.column.value(row);
1038
+ const dims = dimsOf(item.column);
1039
+ const values = Array.from(value as ArrayLike<number>).slice(0, dims);
1040
+ text = valueText(values, item.column.dtype === "f32", c.nonfinite);
1041
+ break;
1042
+ }
1043
+ case "classes":
1044
+ text = JSON.stringify((item.column as Extract<Column, { dtype: "list" }>).sliceOf(row).join(" "));
1045
+ break;
1046
+ default:
1047
+ text = cellText(item.column, row, c.ids, c.nonfinite);
1048
+ break;
1049
+ }
1050
+ c.w.member(item.key, text);
1051
+ }
1052
+ }
1053
+
1054
+ const ATTRIBUTE_SLOTS: ReadonlySet<Slot> = new Set(["attribute", "position"]);
1055
+ const ELEMENT_SLOTS: ReadonlySet<Slot> = new Set(["element", "classes"]);
1056
+
1057
+ /**
1058
+ * The number of components a position column writes: `extra.sourceDims` when recorded, else all.
1059
+ * @param column - the position column
1060
+ * @returns 2 or 3 (or the component count)
1061
+ */
1062
+ function dimsOf(column: Column): number {
1063
+ const dims: unknown = column.meta.extra.sourceDims;
1064
+ if (typeof dims === "number" && dims >= 1 && dims <= column.meta.components) {
1065
+ return dims;
1066
+ }
1067
+ return column.meta.components;
1068
+ }
1069
+
1070
+ /**
1071
+ * Whether any planned column of a slot set has a value in a row (to omit an empty nested dict).
1072
+ * @param plans - the table plan
1073
+ * @param row - the row
1074
+ * @param slots - the slots
1075
+ * @returns true when something would be written
1076
+ */
1077
+ function anySet(plans: readonly ColumnPlan[], row: number, slots: ReadonlySet<Slot>): boolean {
1078
+ return plans.some((item) => slots.has(item.slot) && item.column.isSet(row));
1079
+ }
1080
+
1081
+ /**
1082
+ * The JSON text of a node id.
1083
+ * @param id - the id
1084
+ * @returns the text
1085
+ */
1086
+ function idText(id: NodeId): string {
1087
+ return typeof id === "number" && Object.is(id, -0) ? "0" : JSON.stringify(id);
1088
+ }
1089
+
1090
+ /**
1091
+ * The edge id text of an edge, or null when none is set.
1092
+ * @param p - the plan
1093
+ * @param e - the edge
1094
+ * @param c - the cursor
1095
+ * @returns the text, or null
1096
+ */
1097
+ function edgeIdText(p: Plan, e: number, c: Cursor): string | null {
1098
+ const column = p.edgeIds;
1099
+ if (column === null || !column.isSet(e)) {
1100
+ return null;
1101
+ }
1102
+ return cellText(column, e, c.ids, c.nonfinite);
1103
+ }
1104
+
1105
+ /**
1106
+ * Whether an edge is written, and as which kind, under the plan's direction policy.
1107
+ * @param p - the plan
1108
+ * @param e - the edge
1109
+ * @returns "skip" for a folded mirror, "undirected" for a pair primary, "directed" otherwise
1110
+ */
1111
+ function edgeDisposition(p: Plan, e: number): "skip" | "undirected" | "directed" {
1112
+ const { folding } = p;
1113
+ if (folding.folded(e)) {
1114
+ return "skip";
1115
+ }
1116
+ if (!p.directed) {
1117
+ return "undirected";
1118
+ }
1119
+ if (folding.sourceDirected(e)) {
1120
+ return "directed";
1121
+ }
1122
+ // a source-undirected edge (pair primary or expanded self-loop): a dialect with a per-edge flag
1123
+ // keeps it; one without writes it per the policy (the pair folds to one directed edge)
1124
+ if (dialectCapabilities(p.resolved.dialect).mixedDirection) {
1125
+ return "undirected";
1126
+ }
1127
+ return p.resolved.common.onMixedDirection === "directed" ? "directed" : "undirected";
1128
+ }
1129
+
1130
+ /**
1131
+ * The graph-level attribute object.
1132
+ * @param c - the cursor
1133
+ * @param p - the plan
1134
+ */
1135
+ function writeGraphObject(c: Cursor, p: Plan): void {
1136
+ c.w.open("{");
1137
+ for (const item of p.graph) {
1138
+ if (item.column.isSet(0)) {
1139
+ c.w.member(item.key, cellText(item.column, 0, c.ids, c.nonfinite));
1140
+ }
1141
+ }
1142
+ c.w.close("}");
1143
+ }
1144
+
1145
+ /**
1146
+ * The endpoint texts of an edge: ids, or array positions under index links.
1147
+ * @param snapshot - the snapshot
1148
+ * @param p - the plan
1149
+ * @param e - the edge
1150
+ * @returns the source and target texts
1151
+ */
1152
+ function endpoints(snapshot: GraphSnapshot, p: Plan, e: number): [string, string] {
1153
+ const list = snapshot.edgeList();
1154
+ const u = list.src[e];
1155
+ const v = list.dst[e];
1156
+ if (p.resolved.indexLinks) {
1157
+ return [String(u), String(v)];
1158
+ }
1159
+ return [idText(snapshot.ids.idOf(u)), idText(snapshot.ids.idOf(v))];
1160
+ }
1161
+
1162
+ /**
1163
+ * Write a node-link / d3 document.
1164
+ * @param snapshot - the snapshot
1165
+ * @param p - the plan
1166
+ * @yields the parts
1167
+ * @returns nothing
1168
+ */
1169
+ function* writeNodeLink(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
1170
+ const { resolved } = p;
1171
+ const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
1172
+ const { w } = c;
1173
+ w.open("{");
1174
+ if (resolved.dialect !== "d3") {
1175
+ // d3 is the bare shape: no direction, multigraph or graph keys (the importer sniffs it by their absence)
1176
+ w.member("directed", p.directed ? "true" : "false");
1177
+ w.member("multigraph", p.multigraph ? "true" : "false");
1178
+ w.key("graph");
1179
+ writeGraphObject(c, p);
1180
+ }
1181
+ w.key("nodes");
1182
+ w.open("[");
1183
+ yield w.take();
1184
+ for (let i = 0; i < snapshot.nodeCount; i++) {
1185
+ w.item();
1186
+ w.open("{");
1187
+ if (resolved.nodeIdKey !== null) {
1188
+ w.member(resolved.nodeIdKey, idText(snapshot.ids.idOf(i)));
1189
+ }
1190
+ writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
1191
+ w.close("}");
1192
+ yield w.take();
1193
+ }
1194
+ w.close("]");
1195
+ w.key(resolved.edgesKey);
1196
+ w.open("[");
1197
+ for (let e = 0; e < snapshot.edgeCount; e++) {
1198
+ const disposition = edgeDisposition(p, e);
1199
+ if (disposition === "skip") {
1200
+ continue;
1201
+ }
1202
+ const [s, t] = endpoints(snapshot, p, e);
1203
+ w.item();
1204
+ w.open("{");
1205
+ w.member(resolved.sourceKey, s);
1206
+ w.member(resolved.targetKey, t);
1207
+ const weight = p.weights(e);
1208
+ if (weight !== null) {
1209
+ w.member(resolved.weightKey, weight);
1210
+ }
1211
+ writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
1212
+ w.close("}");
1213
+ yield w.take();
1214
+ }
1215
+ w.close("]");
1216
+ w.close("}");
1217
+ yield w.take();
1218
+ }
1219
+
1220
+ /**
1221
+ * Write a vis.js document.
1222
+ * @param snapshot - the snapshot
1223
+ * @param p - the plan
1224
+ * @yields the parts
1225
+ * @returns nothing
1226
+ */
1227
+ function* writeVis(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
1228
+ const { resolved } = p;
1229
+ const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
1230
+ const { w } = c;
1231
+ const nodeIdKey = resolved.nodeIdKey ?? "id";
1232
+ w.open("{");
1233
+ w.key("nodes");
1234
+ w.open("[");
1235
+ yield w.take();
1236
+ for (let i = 0; i < snapshot.nodeCount; i++) {
1237
+ w.item();
1238
+ w.open("{");
1239
+ w.member(nodeIdKey, idText(snapshot.ids.idOf(i)));
1240
+ writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
1241
+ w.close("}");
1242
+ yield w.take();
1243
+ }
1244
+ w.close("]");
1245
+ w.key("edges");
1246
+ w.open("[");
1247
+ for (let e = 0; e < snapshot.edgeCount; e++) {
1248
+ if (edgeDisposition(p, e) === "skip") {
1249
+ continue;
1250
+ }
1251
+ const list = snapshot.edgeList();
1252
+ w.item();
1253
+ w.open("{");
1254
+ const id = edgeIdText(p, e, c);
1255
+ if (id !== null) {
1256
+ w.member("id", id);
1257
+ }
1258
+ w.member(resolved.sourceKey, idText(snapshot.ids.idOf(list.src[e])));
1259
+ w.member(resolved.targetKey, idText(snapshot.ids.idOf(list.dst[e])));
1260
+ const weight = p.weights(e);
1261
+ if (weight !== null) {
1262
+ w.member(resolved.weightKey, weight);
1263
+ }
1264
+ writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
1265
+ w.close("}");
1266
+ yield w.take();
1267
+ }
1268
+ w.close("]");
1269
+ w.close("}");
1270
+ yield w.take();
1271
+ }
1272
+
1273
+ /**
1274
+ * Write a graphology serialisation.
1275
+ * @param snapshot - the snapshot
1276
+ * @param p - the plan
1277
+ * @yields the parts
1278
+ * @returns nothing
1279
+ */
1280
+ function* writeGraphology(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
1281
+ const { resolved } = p;
1282
+ const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
1283
+ const { w } = c;
1284
+ let type: string;
1285
+ if (!p.directed) {
1286
+ type = "undirected";
1287
+ } else {
1288
+ type = countMixedEdges(snapshot) > 0 ? "mixed" : "directed";
1289
+ }
1290
+ w.open("{");
1291
+ w.key("attributes");
1292
+ writeGraphObject(c, p);
1293
+ w.key("options");
1294
+ w.open("{");
1295
+ w.member("type", JSON.stringify(type));
1296
+ // the shape the importer read is written back as read: `multi` and `allowSelfLoops` only when
1297
+ // the source declared them (or the graph needs them)
1298
+ if (snapshot.meta.declaredMultigraph !== null || p.multigraph) {
1299
+ w.member("multi", p.multigraph ? "true" : "false");
1300
+ }
1301
+ if (resolved.shape.allowSelfLoops !== undefined) {
1302
+ w.member("allowSelfLoops", !resolved.shape.allowSelfLoops && snapshot.selfLoopCount === 0 ? "false" : "true");
1303
+ }
1304
+ w.close("}");
1305
+ w.key("nodes");
1306
+ w.open("[");
1307
+ yield w.take();
1308
+ for (let i = 0; i < snapshot.nodeCount; i++) {
1309
+ w.item();
1310
+ w.open("{");
1311
+ w.member("key", idText(snapshot.ids.idOf(i)));
1312
+ if (anySet(p.nodes, i, ATTRIBUTE_SLOTS)) {
1313
+ w.key("attributes");
1314
+ w.open("{");
1315
+ writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
1316
+ w.close("}");
1317
+ }
1318
+ writeSlots(c, p.nodes, i, ELEMENT_SLOTS);
1319
+ w.close("}");
1320
+ yield w.take();
1321
+ }
1322
+ w.close("]");
1323
+ w.key("edges");
1324
+ w.open("[");
1325
+ for (let e = 0; e < snapshot.edgeCount; e++) {
1326
+ const disposition = edgeDisposition(p, e);
1327
+ if (disposition === "skip") {
1328
+ continue;
1329
+ }
1330
+ const list = snapshot.edgeList();
1331
+ w.item();
1332
+ w.open("{");
1333
+ const id = edgeIdText(p, e, c);
1334
+ if (id !== null) {
1335
+ w.member("key", id);
1336
+ }
1337
+ w.member("source", idText(snapshot.ids.idOf(list.src[e])));
1338
+ w.member("target", idText(snapshot.ids.idOf(list.dst[e])));
1339
+ if (type === "mixed" && disposition === "undirected") {
1340
+ w.member("undirected", "true");
1341
+ }
1342
+ const weight = p.weights(e);
1343
+ if (weight !== null || anySet(p.edges, e, ATTRIBUTE_SLOTS)) {
1344
+ w.key("attributes");
1345
+ w.open("{");
1346
+ if (weight !== null) {
1347
+ w.member(resolved.weightKey, weight);
1348
+ }
1349
+ writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
1350
+ w.close("}");
1351
+ }
1352
+ writeSlots(c, p.edges, e, ELEMENT_SLOTS);
1353
+ w.close("}");
1354
+ yield w.take();
1355
+ }
1356
+ w.close("]");
1357
+ w.close("}");
1358
+ yield w.take();
1359
+ }
1360
+
1361
+ /**
1362
+ * Write a JSON Graph Format v2 document.
1363
+ * @param snapshot - the snapshot
1364
+ * @param p - the plan
1365
+ * @yields the parts
1366
+ * @returns nothing
1367
+ */
1368
+ function* writeJgf(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
1369
+ const { resolved } = p;
1370
+ const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
1371
+ const { w } = c;
1372
+ const labelSlots: ReadonlySet<Slot> = new Set(["label"]);
1373
+ const relationSlots: ReadonlySet<Slot> = new Set(["relation"]);
1374
+ w.open("{");
1375
+ w.key("graph");
1376
+ w.open("{");
1377
+ if (resolved.shape.id !== undefined) {
1378
+ w.member("id", JSON.stringify(resolved.shape.id));
1379
+ }
1380
+ if (snapshot.meta.name !== null) {
1381
+ w.member("label", JSON.stringify(snapshot.meta.name));
1382
+ }
1383
+ if (resolved.shape.type !== undefined) {
1384
+ w.member("type", JSON.stringify(resolved.shape.type));
1385
+ }
1386
+ w.member("directed", p.directed ? "true" : "false");
1387
+ if (p.graph.some((item) => item.column.isSet(0))) {
1388
+ w.key("metadata");
1389
+ writeGraphObject(c, p);
1390
+ }
1391
+ w.key("nodes");
1392
+ w.open("{");
1393
+ yield w.take();
1394
+ for (let i = 0; i < snapshot.nodeCount; i++) {
1395
+ w.key(String(snapshot.ids.idOf(i)));
1396
+ w.open("{");
1397
+ writeSlots(c, p.nodes, i, labelSlots);
1398
+ if (anySet(p.nodes, i, ATTRIBUTE_SLOTS)) {
1399
+ w.key("metadata");
1400
+ w.open("{");
1401
+ writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
1402
+ w.close("}");
1403
+ }
1404
+ writeSlots(c, p.nodes, i, ELEMENT_SLOTS);
1405
+ w.close("}");
1406
+ yield w.take();
1407
+ }
1408
+ w.close("}");
1409
+ w.key("edges");
1410
+ w.open("[");
1411
+ for (let e = 0; e < snapshot.edgeCount; e++) {
1412
+ const disposition = edgeDisposition(p, e);
1413
+ if (disposition === "skip") {
1414
+ continue;
1415
+ }
1416
+ const list = snapshot.edgeList();
1417
+ w.item();
1418
+ w.open("{");
1419
+ const id = edgeIdText(p, e, c);
1420
+ if (id !== null) {
1421
+ w.member("id", id);
1422
+ }
1423
+ w.member("source", JSON.stringify(String(snapshot.ids.idOf(list.src[e]))));
1424
+ w.member("target", JSON.stringify(String(snapshot.ids.idOf(list.dst[e]))));
1425
+ writeSlots(c, p.edges, e, relationSlots);
1426
+ if (p.directed && disposition === "undirected") {
1427
+ w.member("directed", "false");
1428
+ }
1429
+ writeSlots(c, p.edges, e, labelSlots);
1430
+ const weight = p.weights(e);
1431
+ if (weight !== null || anySet(p.edges, e, ATTRIBUTE_SLOTS)) {
1432
+ w.key("metadata");
1433
+ w.open("{");
1434
+ if (weight !== null) {
1435
+ w.member(resolved.weightKey, weight);
1436
+ }
1437
+ writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
1438
+ w.close("}");
1439
+ }
1440
+ writeSlots(c, p.edges, e, ELEMENT_SLOTS);
1441
+ w.close("}");
1442
+ yield w.take();
1443
+ }
1444
+ w.close("]");
1445
+ w.close("}");
1446
+ w.close("}");
1447
+ yield w.take();
1448
+ }
1449
+
1450
+ /**
1451
+ * Write Cytoscape.js elements.
1452
+ * @param snapshot - the snapshot
1453
+ * @param p - the plan
1454
+ * @yields the parts
1455
+ * @returns nothing
1456
+ */
1457
+ function* writeCytoscape(snapshot: GraphSnapshot, p: Plan): Generator<string, void, undefined> {
1458
+ const { resolved } = p;
1459
+ const c: Cursor = { w: new JsonWriter(resolved.indent), ids: snapshot.ids, nonfinite: { count: 0 } };
1460
+ const { w } = c;
1461
+ const parentSlots: ReadonlySet<Slot> = new Set(["parent"]);
1462
+ const positionSlots: ReadonlySet<Slot> = new Set(["cytoscapePosition"]);
1463
+ w.open("{");
1464
+ w.key("elements");
1465
+ w.open("{");
1466
+ w.key("nodes");
1467
+ w.open("[");
1468
+ yield w.take();
1469
+ for (let i = 0; i < snapshot.nodeCount; i++) {
1470
+ w.item();
1471
+ w.open("{");
1472
+ w.key("data");
1473
+ w.open("{");
1474
+ w.member("id", idText(snapshot.ids.idOf(i)));
1475
+ writeSlots(c, p.nodes, i, parentSlots);
1476
+ writeSlots(c, p.nodes, i, ATTRIBUTE_SLOTS);
1477
+ w.close("}");
1478
+ for (const item of p.nodes) {
1479
+ if (positionSlots.has(item.slot) && item.column.isSet(i)) {
1480
+ const value = item.column.value(i) as ArrayLike<number>;
1481
+ const f32 = item.column.dtype === "f32";
1482
+ w.key("position");
1483
+ w.open("{");
1484
+ w.member("x", numberText(value[0], f32, c.nonfinite));
1485
+ w.member("y", numberText(value[1], f32, c.nonfinite));
1486
+ w.close("}");
1487
+ }
1488
+ }
1489
+ writeSlots(c, p.nodes, i, ELEMENT_SLOTS);
1490
+ w.close("}");
1491
+ yield w.take();
1492
+ }
1493
+ w.close("]");
1494
+ w.key("edges");
1495
+ w.open("[");
1496
+ const generated = edgeIdGenerator(snapshot, p);
1497
+ for (let e = 0; e < snapshot.edgeCount; e++) {
1498
+ if (edgeDisposition(p, e) === "skip") {
1499
+ continue;
1500
+ }
1501
+ const list = snapshot.edgeList();
1502
+ w.item();
1503
+ w.open("{");
1504
+ w.key("data");
1505
+ w.open("{");
1506
+ w.member("id", edgeIdText(p, e, c) ?? generated(e));
1507
+ w.member("source", idText(snapshot.ids.idOf(list.src[e])));
1508
+ w.member("target", idText(snapshot.ids.idOf(list.dst[e])));
1509
+ const weight = p.weights(e);
1510
+ if (weight !== null) {
1511
+ w.member(resolved.weightKey, weight);
1512
+ }
1513
+ writeSlots(c, p.edges, e, ATTRIBUTE_SLOTS);
1514
+ w.close("}");
1515
+ writeSlots(c, p.edges, e, ELEMENT_SLOTS);
1516
+ w.close("}");
1517
+ yield w.take();
1518
+ }
1519
+ w.close("]");
1520
+ w.close("}");
1521
+ if (p.graph.some((item) => item.column.isSet(0))) {
1522
+ w.key("data");
1523
+ writeGraphObject(c, p);
1524
+ }
1525
+ const extra = resolved.shape.cytoscape;
1526
+ if (extra !== undefined) {
1527
+ for (const key of Object.keys(extra)) {
1528
+ if (key !== "elements" && key !== "data") {
1529
+ w.member(key, valueText(extra[key], false, c.nonfinite));
1530
+ }
1531
+ }
1532
+ }
1533
+ w.close("}");
1534
+ yield w.take();
1535
+ }
1536
+
1537
+ /**
1538
+ * A generator of canonical `e<index>` edge ids for the edges without one (design section 4.6),
1539
+ * skipping any text a node id or an explicit edge id already uses (Cytoscape ids share one
1540
+ * namespace).
1541
+ * @param snapshot - the snapshot
1542
+ * @param p - the plan
1543
+ * @returns the id text of an edge without an explicit id
1544
+ */
1545
+ function edgeIdGenerator(snapshot: GraphSnapshot, p: Plan): (e: number) => string {
1546
+ const used = new Set<string>();
1547
+ for (let i = 0; i < snapshot.nodeCount; i++) {
1548
+ used.add(String(snapshot.ids.idOf(i)));
1549
+ }
1550
+ const column = p.edgeIds;
1551
+ if (column !== null) {
1552
+ for (let e = 0; e < snapshot.edgeCount; e++) {
1553
+ if (column.isSet(e)) {
1554
+ used.add(String(column.value(e)));
1555
+ }
1556
+ }
1557
+ }
1558
+ return (e: number): string => {
1559
+ let candidate = `e${e}`;
1560
+ for (let k = 2; used.has(candidate); k++) {
1561
+ candidate = `e${e}_${k}`;
1562
+ }
1563
+ used.add(candidate);
1564
+ return JSON.stringify(candidate);
1565
+ };
1566
+ }
1567
+
1568
+ // ============================================================ the plugin
1569
+
1570
+ /**
1571
+ * The JSON exporter plugin (design section 8.5). `capabilities` is the node-link table (the
1572
+ * default dialect); check() applies the table of the dialect actually selected.
1573
+ */
1574
+ export const jsonExporter: GraphExporter<JsonExportOptions> = Object.freeze({
1575
+ format: "json",
1576
+ capabilities: dialectCapabilities("node-link"),
1577
+
1578
+ /**
1579
+ * Pre-flight: every loss of the selected dialect, without writing anything.
1580
+ * @param snapshot - the snapshot
1581
+ * @param options - format-specific and common options
1582
+ * @returns the notes, empty when the export is exact
1583
+ */
1584
+ check(snapshot: GraphSnapshot, options?: JsonExportOptions & CommonExportOptions): readonly LossNote[] {
1585
+ return Object.freeze([...plan(snapshot, resolve(snapshot, options)).notes]);
1586
+ },
1587
+
1588
+ /**
1589
+ * Write the document as UTF-8 chunks.
1590
+ * @param snapshot - the snapshot
1591
+ * @param options - format-specific and common options
1592
+ * @returns the chunks
1593
+ */
1594
+ export(snapshot: GraphSnapshot, options?: JsonExportOptions & CommonExportOptions): AsyncIterable<Uint8Array> {
1595
+ return encodeChunks(write(snapshot, plan(snapshot, resolve(snapshot, options))));
1596
+ },
1597
+
1598
+ /**
1599
+ * Write the document as one string.
1600
+ * @param snapshot - the snapshot
1601
+ * @param options - format-specific and common options
1602
+ * @returns the document
1603
+ */
1604
+ exportToString(snapshot: GraphSnapshot, options?: JsonExportOptions & CommonExportOptions): Promise<string> {
1605
+ return joinText(write(snapshot, plan(snapshot, resolve(snapshot, options))));
1606
+ },
1607
+ });
1608
+
1609
+ /**
1610
+ * The capability table of one dialect, for callers that pick a dialect before check().
1611
+ * @param dialect - the dialect
1612
+ * @returns the frozen table
1613
+ */
1614
+ export function jsonCapabilities(dialect: JsonDialect): ExportCapabilities {
1615
+ return dialectCapabilities(dialect);
1616
+ }