@react-x11/components 0.8.0 → 0.10.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 (721) hide show
  1. package/README.md +105 -38
  2. package/dist/charts/node.d.ts +12 -2
  3. package/dist/charts/node.d.ts.map +1 -1
  4. package/dist/charts/node.js +50 -16
  5. package/dist/charts/node.js.map +1 -1
  6. package/dist/charts/render.d.ts +12 -3
  7. package/dist/charts/render.d.ts.map +1 -1
  8. package/dist/charts/render.js +94 -25
  9. package/dist/charts/render.js.map +1 -1
  10. package/dist/code/index.d.ts.map +1 -1
  11. package/dist/code/index.js +3 -1
  12. package/dist/code/index.js.map +1 -1
  13. package/dist/code-editor/completion.d.ts.map +1 -1
  14. package/dist/code-editor/completion.js +3 -2
  15. package/dist/code-editor/completion.js.map +1 -1
  16. package/dist/code-editor/index.d.ts.map +1 -1
  17. package/dist/code-editor/index.js +19 -0
  18. package/dist/code-editor/index.js.map +1 -1
  19. package/dist/code-editor/node.d.ts +177 -7
  20. package/dist/code-editor/node.d.ts.map +1 -1
  21. package/dist/code-editor/node.js +1329 -164
  22. package/dist/code-editor/node.js.map +1 -1
  23. package/dist/code-language/languages/glsl.d.ts.map +1 -1
  24. package/dist/code-language/languages/glsl.js +10 -2
  25. package/dist/code-language/languages/glsl.js.map +1 -1
  26. package/dist/code-language/languages/javascript.d.ts.map +1 -1
  27. package/dist/code-language/languages/javascript.js +18 -3
  28. package/dist/code-language/languages/javascript.js.map +1 -1
  29. package/dist/code-language/languages/json.d.ts.map +1 -1
  30. package/dist/code-language/languages/json.js +10 -2
  31. package/dist/code-language/languages/json.js.map +1 -1
  32. package/dist/code-language/languages/shell.d.ts.map +1 -1
  33. package/dist/code-language/languages/shell.js +10 -2
  34. package/dist/code-language/languages/shell.js.map +1 -1
  35. package/dist/code-language/languages/sql.d.ts.map +1 -1
  36. package/dist/code-language/languages/sql.js +20 -2
  37. package/dist/code-language/languages/sql.js.map +1 -1
  38. package/dist/code-language/stream.d.ts +22 -0
  39. package/dist/code-language/stream.d.ts.map +1 -1
  40. package/dist/code-language/stream.js +259 -9
  41. package/dist/code-language/stream.js.map +1 -1
  42. package/dist/code-language/types.d.ts +5 -4
  43. package/dist/code-language/types.d.ts.map +1 -1
  44. package/dist/codeblock/index.d.ts +13 -2
  45. package/dist/codeblock/index.d.ts.map +1 -1
  46. package/dist/codeblock/index.js +20 -3
  47. package/dist/codeblock/index.js.map +1 -1
  48. package/dist/embed/client.d.ts +48 -3
  49. package/dist/embed/client.d.ts.map +1 -1
  50. package/dist/embed/client.js +79 -5
  51. package/dist/embed/client.js.map +1 -1
  52. package/dist/embed/index.d.ts +1 -1
  53. package/dist/embed/index.d.ts.map +1 -1
  54. package/dist/embed/index.js +1 -1
  55. package/dist/embed/index.js.map +1 -1
  56. package/dist/flow/draw.d.ts +50 -0
  57. package/dist/flow/draw.d.ts.map +1 -1
  58. package/dist/flow/draw.js +132 -33
  59. package/dist/flow/draw.js.map +1 -1
  60. package/dist/flow/gl/color.d.ts +13 -0
  61. package/dist/flow/gl/color.d.ts.map +1 -0
  62. package/dist/flow/gl/color.js +82 -0
  63. package/dist/flow/gl/color.js.map +1 -0
  64. package/dist/flow/gl/index.d.ts +60 -0
  65. package/dist/flow/gl/index.d.ts.map +1 -0
  66. package/dist/flow/gl/index.js +283 -0
  67. package/dist/flow/gl/index.js.map +1 -0
  68. package/dist/flow/gl/pack.d.ts +133 -0
  69. package/dist/flow/gl/pack.d.ts.map +1 -0
  70. package/dist/flow/gl/pack.js +408 -0
  71. package/dist/flow/gl/pack.js.map +1 -0
  72. package/dist/flow/gl/renderer.d.ts +95 -0
  73. package/dist/flow/gl/renderer.d.ts.map +1 -0
  74. package/dist/flow/gl/renderer.js +459 -0
  75. package/dist/flow/gl/renderer.js.map +1 -0
  76. package/dist/flow/gl/shaders.d.ts +79 -0
  77. package/dist/flow/gl/shaders.d.ts.map +1 -0
  78. package/dist/flow/gl/shaders.js +346 -0
  79. package/dist/flow/gl/shaders.js.map +1 -0
  80. package/dist/flow/gl/text.d.ts +318 -0
  81. package/dist/flow/gl/text.d.ts.map +1 -0
  82. package/dist/flow/gl/text.js +1103 -0
  83. package/dist/flow/gl/text.js.map +1 -0
  84. package/dist/flow/index.d.ts +1 -1
  85. package/dist/flow/index.d.ts.map +1 -1
  86. package/dist/flow/index.js +619 -64
  87. package/dist/flow/index.js.map +1 -1
  88. package/dist/flow/node.d.ts +621 -72
  89. package/dist/flow/node.d.ts.map +1 -1
  90. package/dist/flow/node.js +2253 -1018
  91. package/dist/flow/node.js.map +1 -1
  92. package/dist/flow/paint.d.ts +35 -0
  93. package/dist/flow/paint.d.ts.map +1 -0
  94. package/dist/flow/paint.js +393 -0
  95. package/dist/flow/paint.js.map +1 -0
  96. package/dist/flow/paths.d.ts +14 -0
  97. package/dist/flow/paths.d.ts.map +1 -1
  98. package/dist/flow/paths.js +45 -19
  99. package/dist/flow/paths.js.map +1 -1
  100. package/dist/flow/scene.d.ts +584 -0
  101. package/dist/flow/scene.d.ts.map +1 -0
  102. package/dist/flow/scene.js +1453 -0
  103. package/dist/flow/scene.js.map +1 -0
  104. package/dist/flow/types.d.ts +111 -1
  105. package/dist/flow/types.d.ts.map +1 -1
  106. package/dist/html/controls.d.ts +28 -0
  107. package/dist/html/controls.d.ts.map +1 -1
  108. package/dist/html/controls.js +52 -2
  109. package/dist/html/controls.js.map +1 -1
  110. package/dist/html/css/calc.d.ts +14 -0
  111. package/dist/html/css/calc.d.ts.map +1 -0
  112. package/dist/html/css/calc.js +344 -0
  113. package/dist/html/css/calc.js.map +1 -0
  114. package/dist/html/css/cascade.d.ts +240 -6
  115. package/dist/html/css/cascade.d.ts.map +1 -1
  116. package/dist/html/css/cascade.js +1327 -66
  117. package/dist/html/css/cascade.js.map +1 -1
  118. package/dist/html/css/color.d.ts +23 -0
  119. package/dist/html/css/color.d.ts.map +1 -0
  120. package/dist/html/css/color.js +740 -0
  121. package/dist/html/css/color.js.map +1 -0
  122. package/dist/html/css/content.d.ts +92 -0
  123. package/dist/html/css/content.d.ts.map +1 -0
  124. package/dist/html/css/content.js +388 -0
  125. package/dist/html/css/content.js.map +1 -0
  126. package/dist/html/css/counter-styles.d.ts +76 -0
  127. package/dist/html/css/counter-styles.d.ts.map +1 -0
  128. package/dist/html/css/counter-styles.js +893 -0
  129. package/dist/html/css/counter-styles.js.map +1 -0
  130. package/dist/html/css/decode.d.ts +12 -0
  131. package/dist/html/css/decode.d.ts.map +1 -0
  132. package/dist/html/css/decode.js +116 -0
  133. package/dist/html/css/decode.js.map +1 -0
  134. package/dist/html/css/parse.d.ts +110 -10
  135. package/dist/html/css/parse.d.ts.map +1 -1
  136. package/dist/html/css/parse.js +1473 -180
  137. package/dist/html/css/parse.js.map +1 -1
  138. package/dist/html/css/style.d.ts +498 -15
  139. package/dist/html/css/style.d.ts.map +1 -1
  140. package/dist/html/css/style.js +3698 -206
  141. package/dist/html/css/style.js.map +1 -1
  142. package/dist/html/css/ua.d.ts.map +1 -1
  143. package/dist/html/css/ua.js +57 -11
  144. package/dist/html/css/ua.js.map +1 -1
  145. package/dist/html/css/values.d.ts +37 -3
  146. package/dist/html/css/values.d.ts.map +1 -1
  147. package/dist/html/css/values.js +122 -48
  148. package/dist/html/css/values.js.map +1 -1
  149. package/dist/html/css/vars.d.ts +52 -0
  150. package/dist/html/css/vars.d.ts.map +1 -0
  151. package/dist/html/css/vars.js +358 -0
  152. package/dist/html/css/vars.js.map +1 -0
  153. package/dist/html/dom.d.ts +30 -4
  154. package/dist/html/dom.d.ts.map +1 -1
  155. package/dist/html/dom.js +278 -17
  156. package/dist/html/dom.js.map +1 -1
  157. package/dist/html/fonts.d.ts +106 -0
  158. package/dist/html/fonts.d.ts.map +1 -0
  159. package/dist/html/fonts.js +565 -0
  160. package/dist/html/fonts.js.map +1 -0
  161. package/dist/html/gif.d.ts +15 -0
  162. package/dist/html/gif.d.ts.map +1 -0
  163. package/dist/html/gif.js +229 -0
  164. package/dist/html/gif.js.map +1 -0
  165. package/dist/html/index.d.ts +50 -11
  166. package/dist/html/index.d.ts.map +1 -1
  167. package/dist/html/index.js +115 -9
  168. package/dist/html/index.js.map +1 -1
  169. package/dist/html/layout/block.d.ts +226 -3
  170. package/dist/html/layout/block.d.ts.map +1 -1
  171. package/dist/html/layout/block.js +2937 -197
  172. package/dist/html/layout/block.js.map +1 -1
  173. package/dist/html/layout/boxes.d.ts +340 -17
  174. package/dist/html/layout/boxes.d.ts.map +1 -1
  175. package/dist/html/layout/boxes.js +1714 -182
  176. package/dist/html/layout/boxes.js.map +1 -1
  177. package/dist/html/layout/cache.d.ts +58 -0
  178. package/dist/html/layout/cache.d.ts.map +1 -0
  179. package/dist/html/layout/cache.js +224 -0
  180. package/dist/html/layout/cache.js.map +1 -0
  181. package/dist/html/layout/collapse.d.ts +45 -0
  182. package/dist/html/layout/collapse.d.ts.map +1 -0
  183. package/dist/html/layout/collapse.js +246 -0
  184. package/dist/html/layout/collapse.js.map +1 -0
  185. package/dist/html/layout/css-grid.d.ts +7 -0
  186. package/dist/html/layout/css-grid.d.ts.map +1 -0
  187. package/dist/html/layout/css-grid.js +900 -0
  188. package/dist/html/layout/css-grid.js.map +1 -0
  189. package/dist/html/layout/flex.d.ts.map +1 -1
  190. package/dist/html/layout/flex.js +1025 -89
  191. package/dist/html/layout/flex.js.map +1 -1
  192. package/dist/html/layout/floats.d.ts +45 -6
  193. package/dist/html/layout/floats.d.ts.map +1 -1
  194. package/dist/html/layout/floats.js +113 -14
  195. package/dist/html/layout/floats.js.map +1 -1
  196. package/dist/html/layout/grid-lines.d.ts +39 -0
  197. package/dist/html/layout/grid-lines.d.ts.map +1 -0
  198. package/dist/html/layout/grid-lines.js +119 -0
  199. package/dist/html/layout/grid-lines.js.map +1 -0
  200. package/dist/html/layout/grid.d.ts +53 -0
  201. package/dist/html/layout/grid.d.ts.map +1 -0
  202. package/dist/html/layout/grid.js +153 -0
  203. package/dist/html/layout/grid.js.map +1 -0
  204. package/dist/html/layout/inline.d.ts +151 -1
  205. package/dist/html/layout/inline.d.ts.map +1 -1
  206. package/dist/html/layout/inline.js +3375 -174
  207. package/dist/html/layout/inline.js.map +1 -1
  208. package/dist/html/layout/shaping.d.ts +6 -0
  209. package/dist/html/layout/shaping.d.ts.map +1 -0
  210. package/dist/html/layout/shaping.js +100 -0
  211. package/dist/html/layout/shaping.js.map +1 -0
  212. package/dist/html/layout/table.d.ts +3 -0
  213. package/dist/html/layout/table.d.ts.map +1 -1
  214. package/dist/html/layout/table.js +681 -175
  215. package/dist/html/layout/table.js.map +1 -1
  216. package/dist/html/layout/tracks.d.ts +44 -0
  217. package/dist/html/layout/tracks.d.ts.map +1 -0
  218. package/dist/html/layout/tracks.js +349 -0
  219. package/dist/html/layout/tracks.js.map +1 -0
  220. package/dist/html/node.d.ts +172 -5
  221. package/dist/html/node.d.ts.map +1 -1
  222. package/dist/html/node.js +1336 -103
  223. package/dist/html/node.js.map +1 -1
  224. package/dist/html/paint.d.ts +92 -3
  225. package/dist/html/paint.d.ts.map +1 -1
  226. package/dist/html/paint.js +3503 -141
  227. package/dist/html/paint.js.map +1 -1
  228. package/dist/html/resources.d.ts +60 -14
  229. package/dist/html/resources.d.ts.map +1 -1
  230. package/dist/html/resources.js +122 -36
  231. package/dist/html/resources.js.map +1 -1
  232. package/dist/html/surfaces.d.ts +23 -0
  233. package/dist/html/surfaces.d.ts.map +1 -0
  234. package/dist/html/surfaces.js +68 -0
  235. package/dist/html/surfaces.js.map +1 -0
  236. package/dist/html/svg.d.ts +80 -0
  237. package/dist/html/svg.d.ts.map +1 -0
  238. package/dist/html/svg.js +435 -0
  239. package/dist/html/svg.js.map +1 -0
  240. package/dist/html/url.d.ts +23 -0
  241. package/dist/html/url.d.ts.map +1 -0
  242. package/dist/html/url.js +80 -0
  243. package/dist/html/url.js.map +1 -0
  244. package/dist/index.d.ts +2 -2
  245. package/dist/index.d.ts.map +1 -1
  246. package/dist/index.js +1 -1
  247. package/dist/index.js.map +1 -1
  248. package/dist/internal/collate.d.ts +3 -0
  249. package/dist/internal/collate.d.ts.map +1 -0
  250. package/dist/internal/collate.js +11 -0
  251. package/dist/internal/collate.js.map +1 -0
  252. package/dist/{markdown → internal/markdown}/ast.d.ts +23 -0
  253. package/dist/internal/markdown/ast.d.ts.map +1 -0
  254. package/dist/internal/markdown/ast.js +22 -0
  255. package/dist/internal/markdown/ast.js.map +1 -0
  256. package/dist/internal/markdown/parse.d.ts.map +1 -0
  257. package/dist/{markdown → internal/markdown}/parse.js +221 -30
  258. package/dist/internal/markdown/parse.js.map +1 -0
  259. package/dist/internal/markdown/stringify.d.ts +4 -0
  260. package/dist/internal/markdown/stringify.d.ts.map +1 -0
  261. package/dist/internal/markdown/stringify.js +684 -0
  262. package/dist/internal/markdown/stringify.js.map +1 -0
  263. package/dist/internal/markdown/tags.d.ts.map +1 -0
  264. package/dist/internal/markdown/tags.js.map +1 -0
  265. package/dist/internal/prewarm.d.ts +18 -0
  266. package/dist/internal/prewarm.d.ts.map +1 -0
  267. package/dist/internal/prewarm.js +42 -0
  268. package/dist/internal/prewarm.js.map +1 -0
  269. package/dist/internal/scroll.d.ts +3 -2
  270. package/dist/internal/scroll.d.ts.map +1 -1
  271. package/dist/internal/scroll.js +1 -0
  272. package/dist/internal/scroll.js.map +1 -1
  273. package/dist/internal/sdf.d.ts +20 -0
  274. package/dist/internal/sdf.d.ts.map +1 -0
  275. package/dist/internal/sdf.js +216 -0
  276. package/dist/internal/sdf.js.map +1 -0
  277. package/dist/internal/splice.d.ts +12 -0
  278. package/dist/internal/splice.d.ts.map +1 -0
  279. package/dist/internal/splice.js +23 -0
  280. package/dist/internal/splice.js.map +1 -0
  281. package/dist/internal/window.d.ts +12 -0
  282. package/dist/internal/window.d.ts.map +1 -1
  283. package/dist/internal/window.js +23 -6
  284. package/dist/internal/window.js.map +1 -1
  285. package/dist/maps/anchors.d.ts +103 -0
  286. package/dist/maps/anchors.d.ts.map +1 -0
  287. package/dist/maps/anchors.js +790 -0
  288. package/dist/maps/anchors.js.map +1 -0
  289. package/dist/maps/controller.d.ts +340 -0
  290. package/dist/maps/controller.d.ts.map +1 -0
  291. package/dist/maps/controller.js +822 -0
  292. package/dist/maps/controller.js.map +1 -0
  293. package/dist/maps/gl/buckets.d.ts +99 -0
  294. package/dist/maps/gl/buckets.d.ts.map +1 -0
  295. package/dist/maps/gl/buckets.js +365 -0
  296. package/dist/maps/gl/buckets.js.map +1 -0
  297. package/dist/maps/gl/build-worker.d.ts +15 -0
  298. package/dist/maps/gl/build-worker.d.ts.map +1 -0
  299. package/dist/maps/gl/build-worker.js +44 -0
  300. package/dist/maps/gl/build-worker.js.map +1 -0
  301. package/dist/maps/gl/color.d.ts +8 -0
  302. package/dist/maps/gl/color.d.ts.map +1 -0
  303. package/dist/maps/gl/color.js +57 -0
  304. package/dist/maps/gl/color.js.map +1 -0
  305. package/dist/maps/gl/cover.d.ts +70 -0
  306. package/dist/maps/gl/cover.d.ts.map +1 -0
  307. package/dist/maps/gl/cover.js +137 -0
  308. package/dist/maps/gl/cover.js.map +1 -0
  309. package/dist/maps/gl/index.d.ts +3 -0
  310. package/dist/maps/gl/index.d.ts.map +1 -0
  311. package/dist/maps/gl/index.js +20 -0
  312. package/dist/maps/gl/index.js.map +1 -0
  313. package/dist/maps/gl/markers.d.ts +28 -0
  314. package/dist/maps/gl/markers.d.ts.map +1 -0
  315. package/dist/maps/gl/markers.js +60 -0
  316. package/dist/maps/gl/markers.js.map +1 -0
  317. package/dist/maps/gl/overlays.d.ts +68 -0
  318. package/dist/maps/gl/overlays.d.ts.map +1 -0
  319. package/dist/maps/gl/overlays.js +300 -0
  320. package/dist/maps/gl/overlays.js.map +1 -0
  321. package/dist/maps/gl/pane.d.ts +12 -0
  322. package/dist/maps/gl/pane.d.ts.map +1 -0
  323. package/dist/maps/gl/pane.js +37 -0
  324. package/dist/maps/gl/pane.js.map +1 -0
  325. package/dist/maps/gl/placement.d.ts +124 -0
  326. package/dist/maps/gl/placement.d.ts.map +1 -0
  327. package/dist/maps/gl/placement.js +642 -0
  328. package/dist/maps/gl/placement.js.map +1 -0
  329. package/dist/maps/gl/renderer.d.ts +384 -0
  330. package/dist/maps/gl/renderer.d.ts.map +1 -0
  331. package/dist/maps/gl/renderer.js +1184 -0
  332. package/dist/maps/gl/renderer.js.map +1 -0
  333. package/dist/maps/gl/shaders.d.ts +121 -0
  334. package/dist/maps/gl/shaders.d.ts.map +1 -0
  335. package/dist/maps/gl/shaders.js +453 -0
  336. package/dist/maps/gl/shaders.js.map +1 -0
  337. package/dist/maps/gl/store.d.ts +125 -0
  338. package/dist/maps/gl/store.d.ts.map +1 -0
  339. package/dist/maps/gl/store.js +512 -0
  340. package/dist/maps/gl/store.js.map +1 -0
  341. package/dist/maps/gl/text.d.ts +283 -0
  342. package/dist/maps/gl/text.d.ts.map +1 -0
  343. package/dist/maps/gl/text.js +698 -0
  344. package/dist/maps/gl/text.js.map +1 -0
  345. package/dist/maps/gl/view.d.ts +345 -0
  346. package/dist/maps/gl/view.d.ts.map +1 -0
  347. package/dist/maps/gl/view.js +1209 -0
  348. package/dist/maps/gl/view.js.map +1 -0
  349. package/dist/maps/icons.d.ts +32 -0
  350. package/dist/maps/icons.d.ts.map +1 -0
  351. package/dist/maps/icons.js +151 -0
  352. package/dist/maps/icons.js.map +1 -0
  353. package/dist/maps/index.d.ts +3 -1
  354. package/dist/maps/index.d.ts.map +1 -1
  355. package/dist/maps/index.js +218 -52
  356. package/dist/maps/index.js.map +1 -1
  357. package/dist/maps/labels.d.ts +91 -18
  358. package/dist/maps/labels.d.ts.map +1 -1
  359. package/dist/maps/labels.js +376 -140
  360. package/dist/maps/labels.js.map +1 -1
  361. package/dist/maps/node.d.ts +179 -56
  362. package/dist/maps/node.d.ts.map +1 -1
  363. package/dist/maps/node.js +663 -537
  364. package/dist/maps/node.js.map +1 -1
  365. package/dist/maps/overlay.d.ts +72 -9
  366. package/dist/maps/overlay.d.ts.map +1 -1
  367. package/dist/maps/overlay.js +108 -28
  368. package/dist/maps/overlay.js.map +1 -1
  369. package/dist/maps/paint.d.ts +3 -0
  370. package/dist/maps/paint.d.ts.map +1 -1
  371. package/dist/maps/paint.js.map +1 -1
  372. package/dist/maps/proj.d.ts +62 -0
  373. package/dist/maps/proj.d.ts.map +1 -1
  374. package/dist/maps/proj.js +101 -0
  375. package/dist/maps/proj.js.map +1 -1
  376. package/dist/maps/renderer.d.ts +69 -0
  377. package/dist/maps/renderer.d.ts.map +1 -0
  378. package/dist/maps/renderer.js +66 -0
  379. package/dist/maps/renderer.js.map +1 -0
  380. package/dist/maps/sources.d.ts +30 -6
  381. package/dist/maps/sources.d.ts.map +1 -1
  382. package/dist/maps/sources.js +22 -3
  383. package/dist/maps/sources.js.map +1 -1
  384. package/dist/maps/style.d.ts +35 -0
  385. package/dist/maps/style.d.ts.map +1 -1
  386. package/dist/maps/style.js.map +1 -1
  387. package/dist/maps/styles.d.ts +12 -0
  388. package/dist/maps/styles.d.ts.map +1 -1
  389. package/dist/maps/styles.js +120 -6
  390. package/dist/maps/styles.js.map +1 -1
  391. package/dist/maps/theme.d.ts +19 -0
  392. package/dist/maps/theme.d.ts.map +1 -0
  393. package/dist/maps/theme.js +44 -0
  394. package/dist/maps/theme.js.map +1 -0
  395. package/dist/maps/tiles.d.ts +147 -27
  396. package/dist/maps/tiles.d.ts.map +1 -1
  397. package/dist/maps/tiles.js +221 -54
  398. package/dist/maps/tiles.js.map +1 -1
  399. package/dist/maps/types.d.ts +213 -21
  400. package/dist/maps/types.d.ts.map +1 -1
  401. package/dist/markdown/expressions.d.ts +1 -1
  402. package/dist/markdown/expressions.d.ts.map +1 -1
  403. package/dist/markdown/expressions.js.map +1 -1
  404. package/dist/markdown/index.d.ts +7 -6
  405. package/dist/markdown/index.d.ts.map +1 -1
  406. package/dist/markdown/index.js +121 -50
  407. package/dist/markdown/index.js.map +1 -1
  408. package/dist/markdown/spans.d.ts +1 -1
  409. package/dist/markdown/spans.d.ts.map +1 -1
  410. package/dist/markdown/spans.js +6 -0
  411. package/dist/markdown/spans.js.map +1 -1
  412. package/dist/media-player/index.d.ts +11 -3
  413. package/dist/media-player/index.d.ts.map +1 -1
  414. package/dist/media-player/index.js +13 -2
  415. package/dist/media-player/index.js.map +1 -1
  416. package/dist/qml/containers.d.ts.map +1 -1
  417. package/dist/qml/containers.js +6 -1
  418. package/dist/qml/containers.js.map +1 -1
  419. package/dist/qml/interaction.d.ts.map +1 -1
  420. package/dist/qml/interaction.js +38 -5
  421. package/dist/qml/interaction.js.map +1 -1
  422. package/dist/qml/layouts.d.ts.map +1 -1
  423. package/dist/qml/layouts.js +21 -6
  424. package/dist/qml/layouts.js.map +1 -1
  425. package/dist/qml/qtquick.d.ts.map +1 -1
  426. package/dist/qml/qtquick.js +9 -2
  427. package/dist/qml/qtquick.js.map +1 -1
  428. package/dist/qml/react.d.ts.map +1 -1
  429. package/dist/qml/react.js +9 -7
  430. package/dist/qml/react.js.map +1 -1
  431. package/dist/reorder/clock.d.ts +7 -0
  432. package/dist/reorder/clock.d.ts.map +1 -0
  433. package/dist/reorder/clock.js +26 -0
  434. package/dist/reorder/clock.js.map +1 -0
  435. package/dist/reorder/index.d.ts +25 -3
  436. package/dist/reorder/index.d.ts.map +1 -1
  437. package/dist/reorder/index.js +64 -19
  438. package/dist/reorder/index.js.map +1 -1
  439. package/dist/rich-text-editor/clipboard.d.ts +23 -0
  440. package/dist/rich-text-editor/clipboard.d.ts.map +1 -0
  441. package/dist/rich-text-editor/clipboard.js +307 -0
  442. package/dist/rich-text-editor/clipboard.js.map +1 -0
  443. package/dist/rich-text-editor/collab.d.ts +32 -0
  444. package/dist/rich-text-editor/collab.d.ts.map +1 -0
  445. package/dist/rich-text-editor/collab.js +55 -0
  446. package/dist/rich-text-editor/collab.js.map +1 -0
  447. package/dist/rich-text-editor/commands.d.ts +50 -0
  448. package/dist/rich-text-editor/commands.d.ts.map +1 -0
  449. package/dist/rich-text-editor/commands.js +376 -0
  450. package/dist/rich-text-editor/commands.js.map +1 -0
  451. package/dist/rich-text-editor/drag.d.ts +68 -0
  452. package/dist/rich-text-editor/drag.d.ts.map +1 -0
  453. package/dist/rich-text-editor/drag.js +148 -0
  454. package/dist/rich-text-editor/drag.js.map +1 -0
  455. package/dist/rich-text-editor/html.d.ts +145 -0
  456. package/dist/rich-text-editor/html.d.ts.map +1 -0
  457. package/dist/rich-text-editor/html.js +516 -0
  458. package/dist/rich-text-editor/html.js.map +1 -0
  459. package/dist/rich-text-editor/index.d.ts +252 -0
  460. package/dist/rich-text-editor/index.d.ts.map +1 -0
  461. package/dist/rich-text-editor/index.js +856 -0
  462. package/dist/rich-text-editor/index.js.map +1 -0
  463. package/dist/rich-text-editor/inline.d.ts +94 -0
  464. package/dist/rich-text-editor/inline.d.ts.map +1 -0
  465. package/dist/rich-text-editor/inline.js +252 -0
  466. package/dist/rich-text-editor/inline.js.map +1 -0
  467. package/dist/rich-text-editor/keymap.d.ts +71 -0
  468. package/dist/rich-text-editor/keymap.d.ts.map +1 -0
  469. package/dist/rich-text-editor/keymap.js +394 -0
  470. package/dist/rich-text-editor/keymap.js.map +1 -0
  471. package/dist/rich-text-editor/keys.d.ts +40 -0
  472. package/dist/rich-text-editor/keys.d.ts.map +1 -0
  473. package/dist/rich-text-editor/keys.js +202 -0
  474. package/dist/rich-text-editor/keys.js.map +1 -0
  475. package/dist/rich-text-editor/look.d.ts +71 -0
  476. package/dist/rich-text-editor/look.d.ts.map +1 -0
  477. package/dist/rich-text-editor/look.js +342 -0
  478. package/dist/rich-text-editor/look.js.map +1 -0
  479. package/dist/rich-text-editor/markdown.d.ts +18 -0
  480. package/dist/rich-text-editor/markdown.d.ts.map +1 -0
  481. package/dist/rich-text-editor/markdown.js +496 -0
  482. package/dist/rich-text-editor/markdown.js.map +1 -0
  483. package/dist/rich-text-editor/marks.d.ts +9 -0
  484. package/dist/rich-text-editor/marks.d.ts.map +1 -0
  485. package/dist/rich-text-editor/marks.js +83 -0
  486. package/dist/rich-text-editor/marks.js.map +1 -0
  487. package/dist/rich-text-editor/nodes.d.ts +135 -0
  488. package/dist/rich-text-editor/nodes.d.ts.map +1 -0
  489. package/dist/rich-text-editor/nodes.js +397 -0
  490. package/dist/rich-text-editor/nodes.js.map +1 -0
  491. package/dist/rich-text-editor/render.d.ts +68 -0
  492. package/dist/rich-text-editor/render.d.ts.map +1 -0
  493. package/dist/rich-text-editor/render.js +518 -0
  494. package/dist/rich-text-editor/render.js.map +1 -0
  495. package/dist/rich-text-editor/schema.d.ts +16 -0
  496. package/dist/rich-text-editor/schema.d.ts.map +1 -0
  497. package/dist/rich-text-editor/schema.js +386 -0
  498. package/dist/rich-text-editor/schema.js.map +1 -0
  499. package/dist/rich-text-editor/store.d.ts +11 -0
  500. package/dist/rich-text-editor/store.d.ts.map +1 -0
  501. package/dist/rich-text-editor/store.js +43 -0
  502. package/dist/rich-text-editor/store.js.map +1 -0
  503. package/dist/rich-text-editor/suggest.d.ts +127 -0
  504. package/dist/rich-text-editor/suggest.d.ts.map +1 -0
  505. package/dist/rich-text-editor/suggest.js +0 -0
  506. package/dist/rich-text-editor/suggest.js.map +1 -0
  507. package/dist/rich-text-editor/tables.d.ts +41 -0
  508. package/dist/rich-text-editor/tables.d.ts.map +1 -0
  509. package/dist/rich-text-editor/tables.js +204 -0
  510. package/dist/rich-text-editor/tables.js.map +1 -0
  511. package/dist/rich-text-editor/toolbar.d.ts +58 -0
  512. package/dist/rich-text-editor/toolbar.d.ts.map +1 -0
  513. package/dist/rich-text-editor/toolbar.js +548 -0
  514. package/dist/rich-text-editor/toolbar.js.map +1 -0
  515. package/dist/rich-text-editor/view.d.ts +368 -0
  516. package/dist/rich-text-editor/view.d.ts.map +1 -0
  517. package/dist/rich-text-editor/view.js +1968 -0
  518. package/dist/rich-text-editor/view.js.map +1 -0
  519. package/dist/rich-text-editor/virtual.d.ts +54 -0
  520. package/dist/rich-text-editor/virtual.d.ts.map +1 -0
  521. package/dist/rich-text-editor/virtual.js +279 -0
  522. package/dist/rich-text-editor/virtual.js.map +1 -0
  523. package/dist/richtext/node.d.ts +71 -4
  524. package/dist/richtext/node.d.ts.map +1 -1
  525. package/dist/richtext/node.js +233 -28
  526. package/dist/richtext/node.js.map +1 -1
  527. package/dist/richtext/runs.d.ts +13 -8
  528. package/dist/richtext/runs.d.ts.map +1 -1
  529. package/dist/richtext/runs.js +18 -8
  530. package/dist/richtext/runs.js.map +1 -1
  531. package/dist/table/index.d.ts.map +1 -1
  532. package/dist/table/index.js +23 -0
  533. package/dist/table/index.js.map +1 -1
  534. package/dist/table/rows.d.ts +3 -2
  535. package/dist/table/rows.d.ts.map +1 -1
  536. package/dist/table/rows.js +20 -6
  537. package/dist/table/rows.js.map +1 -1
  538. package/dist/terminal/backends.d.ts +4 -1
  539. package/dist/terminal/backends.d.ts.map +1 -1
  540. package/dist/terminal/backends.js.map +1 -1
  541. package/dist/terminal/index.d.ts +18 -7
  542. package/dist/terminal/index.d.ts.map +1 -1
  543. package/dist/terminal/index.js +32 -11
  544. package/dist/terminal/index.js.map +1 -1
  545. package/dist/terminal/vt/pty.d.ts +13 -2
  546. package/dist/terminal/vt/pty.d.ts.map +1 -1
  547. package/dist/terminal/vt/pty.js +18 -3
  548. package/dist/terminal/vt/pty.js.map +1 -1
  549. package/dist/terminal-output/index.d.ts.map +1 -1
  550. package/dist/terminal-output/index.js +6 -1
  551. package/dist/terminal-output/index.js.map +1 -1
  552. package/dist/three/canvas.d.ts.map +1 -1
  553. package/dist/three/canvas.js +85 -7
  554. package/dist/three/canvas.js.map +1 -1
  555. package/dist/three/events.d.ts +22 -4
  556. package/dist/three/events.d.ts.map +1 -1
  557. package/dist/three/events.js +34 -4
  558. package/dist/three/events.js.map +1 -1
  559. package/dist/tree/index.d.ts.map +1 -1
  560. package/dist/tree/index.js +17 -0
  561. package/dist/tree/index.js.map +1 -1
  562. package/package.json +59 -31
  563. package/src/charts/node.ts +68 -28
  564. package/src/charts/render.ts +120 -30
  565. package/src/code/index.ts +3 -1
  566. package/src/code-editor/completion.ts +3 -2
  567. package/src/code-editor/index.ts +19 -0
  568. package/src/code-editor/node.ts +1574 -184
  569. package/src/code-language/languages/glsl.ts +12 -2
  570. package/src/code-language/languages/javascript.ts +21 -4
  571. package/src/code-language/languages/json.ts +12 -2
  572. package/src/code-language/languages/shell.ts +12 -2
  573. package/src/code-language/languages/sql.ts +22 -2
  574. package/src/code-language/stream.ts +257 -17
  575. package/src/code-language/types.ts +5 -4
  576. package/src/codeblock/index.ts +20 -3
  577. package/src/embed/client.ts +102 -8
  578. package/src/embed/index.ts +2 -0
  579. package/src/flow/draw.ts +167 -41
  580. package/src/flow/gl/color.ts +85 -0
  581. package/src/flow/gl/index.ts +342 -0
  582. package/src/flow/gl/pack.ts +585 -0
  583. package/src/flow/gl/renderer.ts +679 -0
  584. package/src/flow/gl/shaders.ts +371 -0
  585. package/src/flow/gl/text.ts +1295 -0
  586. package/src/flow/index.ts +794 -67
  587. package/src/flow/node.ts +2496 -1256
  588. package/src/flow/paint.ts +511 -0
  589. package/src/flow/paths.ts +80 -15
  590. package/src/flow/scene.ts +2199 -0
  591. package/src/flow/types.ts +113 -1
  592. package/src/html/controls.ts +73 -2
  593. package/src/html/css/calc.ts +385 -0
  594. package/src/html/css/cascade.ts +1403 -62
  595. package/src/html/css/color.ts +755 -0
  596. package/src/html/css/content.ts +417 -0
  597. package/src/html/css/counter-styles.ts +973 -0
  598. package/src/html/css/decode.ts +130 -0
  599. package/src/html/css/parse.ts +1514 -160
  600. package/src/html/css/style.ts +4102 -222
  601. package/src/html/css/ua.ts +58 -12
  602. package/src/html/css/values.ts +150 -50
  603. package/src/html/css/vars.ts +348 -0
  604. package/src/html/dom.ts +284 -17
  605. package/src/html/fonts.ts +641 -0
  606. package/src/html/gif.ts +233 -0
  607. package/src/html/index.ts +180 -20
  608. package/src/html/layout/block.ts +3456 -230
  609. package/src/html/layout/boxes.ts +2100 -190
  610. package/src/html/layout/cache.ts +289 -0
  611. package/src/html/layout/collapse.ts +317 -0
  612. package/src/html/layout/css-grid.ts +1063 -0
  613. package/src/html/layout/flex.ts +1115 -106
  614. package/src/html/layout/floats.ts +128 -14
  615. package/src/html/layout/grid-lines.ts +141 -0
  616. package/src/html/layout/grid.ts +181 -0
  617. package/src/html/layout/inline.ts +3943 -192
  618. package/src/html/layout/shaping.ts +140 -0
  619. package/src/html/layout/table.ts +696 -186
  620. package/src/html/layout/tracks.ts +437 -0
  621. package/src/html/node.ts +1486 -121
  622. package/src/html/paint.ts +4293 -169
  623. package/src/html/resources.ts +161 -50
  624. package/src/html/surfaces.ts +81 -0
  625. package/src/html/svg.ts +493 -0
  626. package/src/html/url.ts +86 -0
  627. package/src/index.ts +4 -0
  628. package/src/internal/collate.ts +12 -0
  629. package/src/{markdown → internal/markdown}/ast.ts +35 -7
  630. package/src/{markdown → internal/markdown}/parse.ts +288 -35
  631. package/src/internal/markdown/stringify.ts +772 -0
  632. package/src/internal/prewarm.ts +45 -0
  633. package/src/internal/scroll.ts +5 -3
  634. package/src/internal/sdf.ts +227 -0
  635. package/src/internal/splice.ts +25 -0
  636. package/src/internal/window.ts +36 -5
  637. package/src/maps/anchors.ts +913 -0
  638. package/src/maps/controller.ts +1084 -0
  639. package/src/maps/gl/buckets.ts +461 -0
  640. package/src/maps/gl/build-worker.ts +66 -0
  641. package/src/maps/gl/color.ts +62 -0
  642. package/src/maps/gl/cover.ts +205 -0
  643. package/src/maps/gl/index.ts +23 -0
  644. package/src/maps/gl/markers.ts +83 -0
  645. package/src/maps/gl/overlays.ts +379 -0
  646. package/src/maps/gl/pane.ts +42 -0
  647. package/src/maps/gl/placement.ts +968 -0
  648. package/src/maps/gl/renderer.ts +1660 -0
  649. package/src/maps/gl/shaders.ts +489 -0
  650. package/src/maps/gl/store.ts +615 -0
  651. package/src/maps/gl/text.ts +868 -0
  652. package/src/maps/gl/view.ts +1489 -0
  653. package/src/maps/icons.ts +183 -0
  654. package/src/maps/index.ts +274 -81
  655. package/src/maps/labels.ts +457 -144
  656. package/src/maps/node.ts +832 -665
  657. package/src/maps/overlay.ts +148 -43
  658. package/src/maps/paint.ts +3 -0
  659. package/src/maps/proj.ts +118 -0
  660. package/src/maps/renderer.ts +155 -0
  661. package/src/maps/sources.ts +43 -6
  662. package/src/maps/style.ts +32 -0
  663. package/src/maps/styles.ts +215 -4
  664. package/src/maps/theme.ts +51 -0
  665. package/src/maps/tiles.ts +253 -62
  666. package/src/maps/types.ts +213 -21
  667. package/src/markdown/expressions.ts +5 -1
  668. package/src/markdown/index.ts +163 -78
  669. package/src/markdown/spans.ts +6 -1
  670. package/src/media-player/index.ts +18 -4
  671. package/src/qml/containers.tsx +6 -1
  672. package/src/qml/interaction.tsx +42 -5
  673. package/src/qml/layouts.tsx +21 -6
  674. package/src/qml/qtquick.tsx +9 -2
  675. package/src/qml/react.tsx +7 -5
  676. package/src/reorder/clock.ts +27 -0
  677. package/src/reorder/index.ts +100 -27
  678. package/src/rich-text-editor/clipboard.ts +439 -0
  679. package/src/rich-text-editor/collab.ts +91 -0
  680. package/src/rich-text-editor/commands.ts +448 -0
  681. package/src/rich-text-editor/dom-globals.d.ts +23 -0
  682. package/src/rich-text-editor/drag.ts +211 -0
  683. package/src/rich-text-editor/html.ts +619 -0
  684. package/src/rich-text-editor/index.ts +1364 -0
  685. package/src/rich-text-editor/inline.ts +345 -0
  686. package/src/rich-text-editor/keymap.ts +510 -0
  687. package/src/rich-text-editor/keys.ts +228 -0
  688. package/src/rich-text-editor/look.ts +427 -0
  689. package/src/rich-text-editor/markdown.ts +653 -0
  690. package/src/rich-text-editor/marks.ts +101 -0
  691. package/src/rich-text-editor/nodes.ts +497 -0
  692. package/src/rich-text-editor/render.ts +837 -0
  693. package/src/rich-text-editor/schema.ts +431 -0
  694. package/src/rich-text-editor/store.ts +39 -0
  695. package/src/rich-text-editor/suggest.ts +597 -0
  696. package/src/rich-text-editor/tables.ts +272 -0
  697. package/src/rich-text-editor/toolbar.ts +724 -0
  698. package/src/rich-text-editor/view.ts +2287 -0
  699. package/src/rich-text-editor/virtual.ts +372 -0
  700. package/src/richtext/node.ts +294 -44
  701. package/src/richtext/runs.ts +23 -12
  702. package/src/table/index.ts +29 -0
  703. package/src/table/rows.ts +18 -8
  704. package/src/terminal/backends.ts +4 -1
  705. package/src/terminal/index.ts +47 -15
  706. package/src/terminal/vt/pty.ts +16 -2
  707. package/src/terminal-output/index.ts +6 -1
  708. package/src/three/canvas.ts +102 -8
  709. package/src/three/events.ts +49 -9
  710. package/src/tree/index.ts +19 -0
  711. package/dist/markdown/ast.d.ts.map +0 -1
  712. package/dist/markdown/ast.js +0 -17
  713. package/dist/markdown/ast.js.map +0 -1
  714. package/dist/markdown/parse.d.ts.map +0 -1
  715. package/dist/markdown/parse.js.map +0 -1
  716. package/dist/markdown/tags.d.ts.map +0 -1
  717. package/dist/markdown/tags.js.map +0 -1
  718. /package/dist/{markdown → internal/markdown}/parse.d.ts +0 -0
  719. /package/dist/{markdown → internal/markdown}/tags.d.ts +0 -0
  720. /package/dist/{markdown → internal/markdown}/tags.js +0 -0
  721. /package/src/{markdown → internal/markdown}/tags.ts +0 -0
package/src/flow/node.ts CHANGED
@@ -23,7 +23,6 @@ import * as ntk from 'react-x11/ntk';
23
23
  import { Node } from 'react-x11/node';
24
24
  import type { A11ySceneAction, A11ySceneItem } from 'react-x11/node';
25
25
  import type { Context2D } from 'react-x11/node';
26
- import { tint } from 'react-x11/style';
27
26
  import type { KeyboardEvent, MouseEvent, WheelEvent } from 'react-x11';
28
27
  import {
29
28
  ctrlChordLetter,
@@ -38,19 +37,16 @@ import {
38
37
  XK_UP,
39
38
  } from 'react-x11/keysyms';
40
39
 
41
- import { createPainter, measureText, toDevice } from './draw.js';
40
+ import { createPainter, measureText, snapToGrid, toDevice } from './draw.js';
42
41
  import type { CachedText, FontsLike, PainterOptions } from './draw.js';
43
42
  import {
44
43
  boundsOf,
45
44
  canConnect,
46
45
  clamp,
47
- DEFAULT_MARKER_SIZE,
48
46
  DEFAULT_MAX_ZOOM,
49
47
  DEFAULT_MIN_ZOOM,
50
48
  EDGE_SLOP,
51
- EDGE_STEP_OFFSET,
52
49
  fitViewport,
53
- handleAnchor,
54
50
  HANDLE_RADIUS,
55
51
  HANDLE_SLOP,
56
52
  gripPoint,
@@ -60,13 +56,8 @@ import {
60
56
  MIN_NODE_HEIGHT,
61
57
  MIN_NODE_WIDTH,
62
58
  NODE_BODY_INSET,
63
- NODE_DESC_SIZE,
64
59
  NODE_HEADER,
65
- NODE_LABEL_SIZE,
66
- NODE_PAD_X,
67
- NODE_PAD_Y,
68
60
  normalizeBackground,
69
- normalizeMarker,
70
61
  orientConnection,
71
62
  rectContains,
72
63
  rectsOverlap,
@@ -81,15 +72,42 @@ import {
81
72
  unionRects,
82
73
  ZOOM_STEP,
83
74
  } from './model.js';
75
+ import { distanceToPath, pathBounds } from './paths.js';
84
76
  import {
85
- distanceToPath,
86
- edgePath,
87
- endAngle,
88
- pathBounds,
89
- pointAtFraction,
90
- startAngle,
91
- trimEnd,
92
- } from './paths.js';
77
+ paintFloat,
78
+ paintGraph,
79
+ paintGraphEdges,
80
+ paintGraphNodes,
81
+ paintGround,
82
+ paintNodeItem,
83
+ paintPanels,
84
+ paintScene,
85
+ } from './paint.js';
86
+ import {
87
+ anchorsOf,
88
+ buildNodeItems,
89
+ buildScene,
90
+ connectionPath,
91
+ SceneCache,
92
+ CULL_MARGIN,
93
+ edgeCoarseBox,
94
+ edgeInkBox,
95
+ withLabelPlate,
96
+ edgeRoute,
97
+ batchesEdges,
98
+ endpoint,
99
+ HANDLE_ZOOM,
100
+ MINIMAP_BLEED,
101
+ miniMapPlace,
102
+ screenRect,
103
+ screenViewport,
104
+ } from './scene.js';
105
+ import type {
106
+ FlowScene,
107
+ SceneGrid,
108
+ SceneInput,
109
+ SceneNodeSource,
110
+ } from './scene.js';
93
111
  import type {
94
112
  BackgroundOptions,
95
113
  BackgroundVariant,
@@ -97,6 +115,8 @@ import type {
97
115
  EdgeChange,
98
116
  FitViewOptions,
99
117
  FlowEdge,
118
+ FlowBodyBudget,
119
+ FlowFrameStats,
100
120
  FlowNode,
101
121
  FlowNodeData,
102
122
  FlowNodeType,
@@ -109,8 +129,6 @@ import type {
109
129
  MiniMapOptions,
110
130
  NodeBodyRect,
111
131
  NodeChange,
112
- ShapeOptions,
113
- StrokeOptions,
114
132
  TextOptions,
115
133
  Viewport,
116
134
  XYPosition,
@@ -141,44 +159,120 @@ interface PatternLike {
141
159
  _picture?: { destroy?(): void };
142
160
  }
143
161
 
162
+ /** The slice of ntk's Surface a 2D zoom gesture paints the graph onto. */
163
+ interface ShotSurface {
164
+ getContext(name: '2d'): unknown;
165
+ destroy?(): void;
166
+ }
167
+ type ShotCtor = new (
168
+ app: unknown,
169
+ options: { width: number; height: number; format: string },
170
+ ) => ShotSurface;
171
+
172
+ /** The graph as a 2D zoom gesture's first step drew it, and the viewport
173
+ * it drew at. */
174
+ interface ZoomShot {
175
+ surface: ShotSurface;
176
+ viewport: Viewport;
177
+ width: number;
178
+ height: number;
179
+ }
180
+
181
+ /**
182
+ * What a 2D drag copies from instead of repainting: the graph without the
183
+ * nodes it moves, in two pictures of the pane — the ground and the edges,
184
+ * and the cards on a clear ground over them — so the moved nodes' edges go
185
+ * between the two and the nodes over both, as the painter orders them.
186
+ * Good for the gesture it was made in, at the viewport and the version of
187
+ * the graph it was painted at.
188
+ */
189
+ interface LiftShot {
190
+ under: ShotSurface;
191
+ over: ShotSurface;
192
+ /** Where the cards' picture has anything, in the pane's logical pixels:
193
+ * each card and the reach of its handles. Blended in there and nowhere
194
+ * else — a step's box between the cards has none to blend. */
195
+ cards: FlowRect[];
196
+ gesture: object;
197
+ version: number;
198
+ viewport: Viewport;
199
+ width: number;
200
+ height: number;
201
+ }
202
+
203
+ /** A canvas `<Flow>` paints a panel on: a core node, asked to repaint its
204
+ * own box. */
205
+ interface PanelCanvas {
206
+ invalidate(layout: boolean, damage: unknown, reason: string): void;
207
+ /** Where a claim of the canvas lands: its box and core's damage slop,
208
+ * device pixels. */
209
+ paintBounds?(): FlowRect;
210
+ /** Its box, device pixels. */
211
+ readonly abs?: FlowRect;
212
+ }
213
+
144
214
  /** Timers, through `globalThis`: `src/` compiles with `types: []` so a Node
145
215
  * global that wandered in would become an implicit `@types/node` dependency
146
216
  * a consumer has to satisfy. */
147
217
  const timers = globalThis as {
148
218
  setInterval?(fn: () => void, ms: number): unknown;
149
219
  clearInterval?(id: unknown): void;
220
+ setTimeout?(fn: () => void, ms: number): unknown;
221
+ clearTimeout?(id: unknown): void;
150
222
  };
151
223
 
224
+ /** How long the zoom has to hold still before mounted bodies come back at
225
+ * the new scale. Wheel notches in a flick land well inside it. */
226
+ const BODY_ZOOM_REST_MS = 150;
227
+ /** How long held bodies wait for the frame that draws a zoom's last step
228
+ * before they come back without it (`_restFrom`). */
229
+ const BODY_REST_CAP_MS = 1000;
230
+ /** What re-scaling the mounted bodies may add to one step of a zoom
231
+ * gesture before they sit the gesture out (`_holdBodies`). */
232
+ const BODY_BUDGET_MS = 8;
233
+ /** What one body costs a zoom step until the pane has measured it: 1.1–1.2
234
+ * ms on an M-series Mac, the 400-widget scene on Cocoa (42 bodies added
235
+ * 49 ms to a 13 ms step, 9 added 10). Learned from then on. */
236
+ const BODY_STEP_PRIOR_MS = 1.2;
237
+
238
+ /** Under GL, how long a zoom holds still before the world is rebuilt at it
239
+ * — until then each step draws the world already on the GPU, scaled
240
+ * (`glFrame`) — and how far that scaling may go before a step rebuilds
241
+ * anyway, so a long gesture never magnifies one build by more than this.
242
+ * The 2D renderer's zoom picture keeps to both. */
243
+ const GL_ZOOM_REST_MS = 120;
244
+ const GL_ZOOM_SPAN = 2;
245
+ /** How much of the pane a 2D zoom step may paint live, round the picture it
246
+ * composites — the ring a zoom out uncovers — before it paints a new one. */
247
+ const ZOOM_SHOT_LIVE = 0.5;
248
+
249
+ /**
250
+ * What painting the graph whole has to cost, ms, before a zoom paints a
251
+ * picture of it and composites that (`_zoomShotFor`) — and what it has to
252
+ * fall under before a zoom paints it live again. A picture pays for a graph
253
+ * too big to paint at every step and costs one that is not: on macOS at 2x
254
+ * its composite is ~20 ms a step whatever the graph, on X11 each new
255
+ * picture was a 50–150 ms step, and either way it holds a surface the size
256
+ * of the pane. A thirty-node graph painted live in 7 ms a step on Cocoa and
257
+ * zoomed at 90 frames a second where its picture held it to 45. A frame at
258
+ * 60 Hz to start, and a margin under it to stop, so a graph near the line
259
+ * does not go back and forth; judged on the median of the last
260
+ * `GRAPH_PAINTS_KEPT` whole paints, so a slow paint or two — a collection,
261
+ * the server catching up — decide nothing.
262
+ */
263
+ const ZOOM_SHOT_WORTH_MS = 16;
264
+ const ZOOM_SHOT_DROP_MS = 12;
265
+ const GRAPH_PAINTS_KEPT = 5;
266
+ /** How far round a box selection's outline its step claims: the pen, the
267
+ * corner's radius and a pixel of antialiasing, with room to spare. */
268
+ const SELECT_BAND = 4;
152
269
  /** How often the dash on an animated edge moves. Slow enough that a graph
153
270
  * full of them is not a repaint storm, fast enough to read as motion. */
154
271
  const ANIMATION_MS = 60;
155
272
  const ANIMATION_SPEED = 1.4; // px of dash travel per tick, at zoom 1
156
- const DEFAULT_DASH = [7, 5];
157
-
158
- /** Below these zooms the pane stops drawing detail nobody could read — the
159
- * cheapest optimisation there is, and the one that keeps a zoomed-out
160
- * overview interactive. */
161
- const LABEL_ZOOM = 0.45;
162
- const HANDLE_ZOOM = 0.5;
163
- const DESC_ZOOM = 0.6;
164
273
 
165
274
  /** Screen pixels the pointer may travel before a press becomes a drag. */
166
275
  const DRAG_THRESHOLD = 3;
167
- /** The grid never draws denser than this on screen, whatever the zoom. */
168
- const MIN_GRID_PX = 16;
169
-
170
- /** See `StrokeBuckets.BATCH_MIN`. */
171
- const MARKER_BATCH_MIN = 24;
172
-
173
- /**
174
- * How far outside its own box a node's ink can land: handles (capped at
175
- * 6 × 1.4 plus their outline), resize grips, the selection border, a pixel
176
- * of antialiasing. Damage rects grow by this before they are invalidated,
177
- * and cull tests grow their target by the same amount — the two must agree,
178
- * or a moved node leaves crumbs of its old handles behind.
179
- */
180
- const CULL_MARGIN = 16;
181
-
182
276
  /**
183
277
  * The props whose change means different pixels. `applyProps` repaints when
184
278
  * one of these moves and stays quiet otherwise — the event handlers are
@@ -200,8 +294,24 @@ const VISUAL_PROPS = [
200
294
  'nodesConnectable',
201
295
  'nodesResizable',
202
296
  'disabled',
297
+ 'renderer',
203
298
  ] as const;
204
299
 
300
+ /** The clock, through `globalThis`: `src/` compiles with `types: []`. */
301
+ const clock = globalThis as { performance?: { now(): number } };
302
+
303
+ /**
304
+ * The pane's time: what a pan is stamped with, and what the dashes' march
305
+ * reads to decide it has held still. A test holds it (test/flow.test.ts):
306
+ * a runner that spends longer than the dashes' wait over one step of a pan
307
+ * lets them march in the middle of it — as they should — and a test of the
308
+ * pan cannot tell that from a march it should not have made.
309
+ */
310
+ export const flowClock = {
311
+ now: (): number => clock.performance?.now() ?? Date.now(),
312
+ };
313
+ const now = (): number => flowClock.now();
314
+
205
315
  const CONTROL_SIZE = 26;
206
316
  const PANEL_MARGIN = 10;
207
317
  const MINIMAP_W = 190;
@@ -327,6 +437,7 @@ export const SELF_DAMAGED_PROPS: readonly string[] = [
327
437
  'zoomOnDoubleClick',
328
438
  'selectionOnDrag',
329
439
  'deleteOnKey',
440
+ 'adaptive',
330
441
  'snapToGrid',
331
442
  'snapGrid',
332
443
  ];
@@ -383,95 +494,27 @@ function unionMaybe(a: FlowRect | null, b: FlowRect | null): FlowRect | null {
383
494
  return unionRects(a, b);
384
495
  }
385
496
 
386
- /** An arrowhead pile: the filled heads and the open ones share a colour, so
387
- * they share a bucket and differ only in which list they land in. */
388
- interface MarkerBucket {
389
- color: string;
390
- lineWidth: number;
391
- filled: XYPosition[][];
392
- open: XYPosition[][];
393
- }
394
-
395
- /**
396
- * Edge strokes, grouped by the pen that will draw them.
397
- *
398
- * Everything a graph strokes is the same two or three pens — the default
399
- * edge, the selected one, the animated dash — so grouping collapses a
400
- * per-edge request into a per-pen one. The key has to carry the dash *and*
401
- * its offset: two edges marching out of phase cannot share a path, because
402
- * the offset is set on the context, not on the subpath.
403
- */
404
- class StrokeBuckets {
405
- private readonly byPen = new Map<
406
- string,
407
- { options: StrokeOptions; runs: XYPosition[][] }
408
- >();
409
-
410
- bucket(
411
- stroke: string,
412
- lineWidth: number,
413
- dash: readonly number[] | undefined,
414
- phase: number,
415
- zoom: number,
416
- ): XYPosition[][] {
417
- const scaled = dash?.map((d) => d * zoom);
418
- const key = `${stroke}|${lineWidth}|${scaled?.join(',') ?? ''}|${phase}`;
419
- let entry = this.byPen.get(key);
420
- if (!entry) {
421
- entry = {
422
- options: {
423
- stroke,
424
- lineWidth,
425
- dash: scaled,
426
- dashOffset: phase ? -phase * zoom : 0,
427
- },
428
- runs: [],
429
- };
430
- this.byPen.set(key, entry);
431
- }
432
- return entry.runs;
433
- }
497
+ /** The minimap's fit: where its panel is, and the part of the graph it
498
+ * shows at what scale — all that puts a node where it is in it. */
499
+ type MiniMapFit = { panel: FlowRect; bounds: FlowRect; scale: number };
434
500
 
435
- /**
436
- * Below this many edges in one pen, they are stroked one at a time.
437
- *
438
- * A path's mask is its bounding box, so batching scattered geometry trades
439
- * many small masks for one the size of the pane — about three quarters of
440
- * a megabyte at a normal window size. That is a large win at seven hundred
441
- * edges (measured: 3.9 MB a frame down to 1.3) and a loss at twenty, where
442
- * the individual masks never add up to a paneful. The threshold is where
443
- * they start to.
444
- */
445
- private static readonly BATCH_MIN = 24;
501
+ /** A node a change moved or resized, and its box in the graph before. */
502
+ interface MiniMapMove {
503
+ entry: NodeEntry;
504
+ before: FlowRect;
505
+ }
446
506
 
447
- paint(painter: FlowPainter): void {
448
- for (const { options, runs } of this.byPen.values()) {
449
- if (runs.length >= StrokeBuckets.BATCH_MIN) {
450
- painter.strokeRuns(runs, options);
451
- } else {
452
- for (const run of runs) painter.polyline(run, options);
453
- }
454
- }
455
- }
507
+ function sameRect(p: FlowRect, q: FlowRect): boolean {
508
+ return (
509
+ p.x === q.x && p.y === q.y && p.width === q.width && p.height === q.height
510
+ );
456
511
  }
457
512
 
458
- /**
459
- * Are these the same handle? Anchors are rebuilt from the node on every read
460
- * — a drag moves them between commits — so identity says nothing, and the
461
- * `id` alone says too little: the node every graph starts with has *two*
462
- * handles with no id at all. Node, id, type and side together are what
463
- * distinguish them.
464
- */
465
- function sameHandle(
466
- a: HandleAnchor | null | undefined,
467
- b: HandleAnchor,
468
- ): boolean {
513
+ function sameFit(a: MiniMapFit, b: MiniMapFit): boolean {
469
514
  return (
470
- a != null &&
471
- a.nodeId === b.nodeId &&
472
- (a.id ?? null) === (b.id ?? null) &&
473
- a.type === b.type &&
474
- a.position === b.position
515
+ sameRect(a.panel, b.panel) &&
516
+ sameRect(a.bounds, b.bounds) &&
517
+ a.scale === b.scale
475
518
  );
476
519
  }
477
520
 
@@ -504,21 +547,146 @@ export class FlowGraphNode extends Node implements FlowInstance {
504
547
  * when the app applies the changes it is being sent. Cleared on release,
505
548
  * which is also how a refused drag snaps back. */
506
549
  private _dragTo: Map<string, XYPosition> | null = null;
550
+ /** Whether the pane's edges are stroked a pen at a time (`EDGE_BATCH`):
551
+ * chosen by a pass over the whole pane, and followed by every pass over
552
+ * part of it until the next. */
553
+ private _batch = false;
507
554
  /** The box a resize is currently making, for the same reason. */
508
555
  private _resizeTo: { id: string; rect: FlowRect } | null = null;
509
- /** What was last handed to `onNodeBodies`, so the React half is told only
510
- * when something actually moved. */
511
- private _bodiesKey = '';
556
+ /** The bodies last sent, re-sent as the same array when only the origin
557
+ * moved, and that origin — and each one by id, reused as the same object
558
+ * when nothing about it changed, so the React half re-renders the card
559
+ * that moved and not the ones beside it. */
560
+ private _bodies: readonly NodeBodyRect[] = [];
561
+ private _bodiesById = new Map<string, NodeBodyRect>();
562
+ private _bodiesOrigin: XYPosition = { x: 0, y: 0 };
563
+ /** Bodies held back while the zoom moves (`_holdBodies`): whether they
564
+ * are, the zoom last seen, the timer that brings them back and when the
565
+ * last held step was. */
566
+ private _bodiesHeld = false;
567
+ private _seenZoom = NaN;
568
+ private _bodiesRest: unknown = null;
569
+ private _bodiesAt = 0;
570
+ /** Mounted cards `<Flow>` shows, painted in the bodies' layer — which
571
+ * the 2D pane then leaves out (`setShownBodies`, `_cardInLayer`). */
572
+ private _shownBodies: ReadonlySet<string> = new Set();
573
+ /** The canvases `<Flow>` paints the minimap and controls on, over the
574
+ * bodies — while there are any, the graph leaves the panels out. */
575
+ private _panelCanvases: readonly PanelCanvas[] = [];
576
+ /** The fit the minimap on screen was painted with: every pass over it
577
+ * since its corner was last claimed whole resolved the same one. Set by
578
+ * the pass that paints it (`_sceneInput`), read by what a move claims
579
+ * there (`_miniMapMoves`). */
580
+ private _miniMapShown: MiniMapFit | null = null;
581
+ private _panelsKey = '';
582
+ /** The budget's model (`_holdBodies`, `_frameTick`): what a body adds to a
583
+ * zoom step, what a step costs with none re-scaled, the last frame's
584
+ * time, and what the step awaiting its frame did. */
585
+ private _bodyStepMs = BODY_STEP_PRIOR_MS;
586
+ private _baseStepMs = NaN;
587
+ private _lastFrameAt = -Infinity;
588
+ private _zoomStep: { live: boolean; bodies: number } | null = null;
512
589
  private _dashPhase = 0;
590
+ /** The box the bodies are laid out in, which a 2D pan carries. */
591
+ private _bodiesLayer: Node | null = null;
592
+ /** When a pan last asked to blit — the dash timer waits for it. */
593
+ private _blittedPanAt = -Infinity;
594
+ /** When the view last moved, and when the dash timer last came round,
595
+ * ticking or not: a 2D pan's dashes wait for a view that held still. */
596
+ private _viewMovedAt = -Infinity;
597
+ private _tickAt = -Infinity;
598
+ /** When the dash timer last marched, and what a 2D frame has cost lately
599
+ * — the paint, and the server's answer to the frame before it; a running
600
+ * mean, ms: what a tick waits on. */
601
+ private _marchedAt = -Infinity;
602
+ private _tickCost = 0;
513
603
  private _animTimer: unknown = null;
514
604
  /** Inside `paint`, where an invalidation would only schedule a redraw of
515
605
  * the frame being drawn. */
516
606
  private _painting = false;
607
+ /** Routes that survived the last frame — see {@link SceneCache}. */
608
+ private readonly _sceneCache = new SceneCache();
609
+ /** Asks the GL surface for a frame, while one is drawing this pane. */
610
+ private _glRequest: (() => void) | null = null;
611
+ /**
612
+ * Bumped by every repaint this element asks for that is *not* a pure pan
613
+ * — which is how the GL surface knows the graph it holds on the GPU is
614
+ * still the graph, and a frame is only a new offset. Errs one way on
615
+ * purpose: anything it cannot see is covered by the rest of the key
616
+ * (`glFrame`), and a rebuild too many costs a frame's packing, where one
617
+ * too few draws the wrong graph.
618
+ */
619
+ private _worldVersion = 0;
620
+ /**
621
+ * The same for the nodes a drag is moving under GL, which are lifted out
622
+ * of the world for as long as it moves them (`_lifted`): a step changes
623
+ * them and nothing else, so it moves this and the world stays on the GPU.
624
+ * Rebuilding the world was the whole of a drag step's cost — 5-7 ms of
625
+ * scene and packing a step at 2,000 nodes, for one card.
626
+ */
627
+ private _liftVersion = 0;
628
+ /** The lifted ids, for the drag gesture they were made for, and a count
629
+ * of every change to them — the world's key, since the world is built
630
+ * without them. */
631
+ private _liftSet: Set<string> | null = null;
632
+ private _liftFor: object | null = null;
633
+ private _liftGen = 0;
634
+ /** Whether a marching edge is in the world, or in the lifted layer. */
635
+ private _worldAnimated = false;
636
+ private _liftedAnimated = false;
637
+ /** The last nodes commit changed lifted nodes and nothing else. */
638
+ private _liftOnly = false;
639
+ /** The overscan the GL world was culled to, in its own pinned
640
+ * coordinates, and the key it was built under. */
641
+ private _worldCull: FlowRect | null = null;
642
+ private _worldCullId = 0;
643
+ private _worldZoom = NaN;
644
+ /** When the zoom last moved, whether that move continued a stream of
645
+ * them — a gesture, where one alone is a jump — and the timer that asks
646
+ * for the frame that rebuilds the GL world once the stream stops. */
647
+ private _zoomAt = -Infinity;
648
+ private _zoomStream = false;
649
+ /** When the frame that drew the latest zoom step began, and when it last
650
+ * finished a pass: an animation's next step follows the frame that drew
651
+ * its last, however long that frame took (`_applyViewport`). */
652
+ private _zoomFrameAt = -Infinity;
653
+ private _zoomDrawnAt = -Infinity;
654
+ private _zoomRest: unknown = null;
655
+ /** A 2D paint drew labels scaled mid-zoom, and owes them at their own
656
+ * sizes once it rests. */
657
+ private _textApproximated = false;
658
+ /**
659
+ * Mid-zoom on the 2D renderer: the graph and its ground as the gesture
660
+ * drew them once, on a surface of their own, composited scaled for every
661
+ * step after — the GL renderer's world drawn scaled, in a bitmap. A step
662
+ * used to stroke every edge, fill every card and lay the grid again, 20 ms
663
+ * of Direct2D a step over the stress lattice, for a picture the next step
664
+ * moved off. Dropped when the zoom rests, which paints it all again
665
+ * exactly, and whenever the graph itself changes.
666
+ */
667
+ private _zoomShot: ZoomShot | null = null;
668
+ /** What the last `GRAPH_PAINTS_KEPT` whole paints of the graph cost, ms,
669
+ * newest last: the steps of a zoom painted live and the pictures painted
670
+ * for zooms, the two passes that say what a step would cost live — and
671
+ * not a zoom's first step, which sets every label at its size once and
672
+ * says what the steps after it will not. Whether a zoom is worth a
673
+ * picture (`ZOOM_SHOT_WORTH_MS`); empty until a zoom has painted, so the
674
+ * first one starts live. */
675
+ private _graphPaints: number[] = [];
676
+ /** A 2D drag's pictures (`_liftShotFor`), and the gesture that is not to
677
+ * have any: one whose view moved under it, where a picture a step would
678
+ * cost two full paints a step. */
679
+ private _liftShot: LiftShot | null = null;
680
+ private _liftShotRefused: object | null = null;
517
681
  /** Inside a live input dispatch — what makes a body emission `sync`.
518
682
  * Motion and the wheel run at continuous priority, whose React updates
519
683
  * can trail the pane's own painting by frames; an emission made under
520
684
  * this flag asks the receiver to commit before the dispatch returns. */
521
685
  private _gestureSync = false;
686
+ /** Inside a call through `FlowInstance` (`fromHandle`) — an application's
687
+ * handler, timer or animation loop moving the viewport. Its emissions ask
688
+ * to commit inline too. */
689
+ private _handleSync = false;
522
690
  /** The scene has been offered to assistive tech at least once — items
523
691
  * only exist once layout has placed them, which no commit marks. */
524
692
  private _sceneAnnounced = false;
@@ -616,13 +784,40 @@ export class FlowGraphNode extends Node implements FlowInstance {
616
784
  private _measure = (text: string, options?: TextOptions): number =>
617
785
  measureText(this._textOptions(), text, options).width;
618
786
 
787
+ /** The same shaping, both dimensions: what a scene's label chips are
788
+ * sized from. `_measure` answers a width because `measureNode` only ever
789
+ * wanted one. */
790
+ private _measureBox = (
791
+ text: string,
792
+ options?: TextOptions,
793
+ ): { width: number; height: number } =>
794
+ measureText(this._textOptions(), text, options);
795
+
796
+ /**
797
+ * The palette, resolved once for the theme and the `palette` prop it was
798
+ * resolved from. Every text measured asks for it (`_textOptions`), and
799
+ * resolving it tints the accent and reads the background's lightness out
800
+ * of a colour string: a 2D pan's passes measured their labels with a
801
+ * fresh one each. The theme object is replaced when it changes.
802
+ */
619
803
  private _palette(): FlowPalette {
620
- return resolvePalette(
621
- this.theme as Record<string, unknown> | null,
622
- this._prop<Partial<FlowPalette>>('palette'),
623
- );
804
+ const theme = this.theme as Record<string, unknown> | null;
805
+ const overrides = this._prop<Partial<FlowPalette>>('palette');
806
+ const held = this._paletteHeld;
807
+ if (held && held.theme === theme && held.overrides === overrides) {
808
+ return held.palette;
809
+ }
810
+ const palette = resolvePalette(theme, overrides);
811
+ this._paletteHeld = { theme, overrides, palette };
812
+ return palette;
624
813
  }
625
814
 
815
+ private _paletteHeld: {
816
+ theme: Record<string, unknown> | null;
817
+ overrides: Partial<FlowPalette> | undefined;
818
+ palette: FlowPalette;
819
+ } | null = null;
820
+
626
821
  // --- the derived graph ---------------------------------------------------
627
822
 
628
823
  /**
@@ -686,12 +881,12 @@ export class FlowGraphNode extends Node implements FlowInstance {
686
881
  // a fresh literal saying the same thing: keep the built entry
687
882
  continue;
688
883
  }
689
- const wasBox = this._edgeCoarseBox(this._edges[i]);
884
+ const wasBox = this._edgeInkBox(this._edges[i]);
690
885
  this._edges[i] = defaults
691
886
  ? ({ ...defaults, ...next } as AnyEdge)
692
887
  : (next as AnyEdge);
693
888
  this._edgesRaw[i] = next;
694
- const isBox = this._edgeCoarseBox(this._edges[i]);
889
+ const isBox = this._edgeInkBox(this._edges[i]);
695
890
  if (wasBox) damage = damage ? unionRects(damage, wasBox) : wasBox;
696
891
  if (isBox) damage = damage ? unionRects(damage, isBox) : isBox;
697
892
  if (next.source !== old.source || next.target !== old.target) {
@@ -722,9 +917,23 @@ export class FlowGraphNode extends Node implements FlowInstance {
722
917
  * Returns `'full'` after a rebuild, a damage rect after an in-place fold,
723
918
  * and `null` when nothing visual changed at all.
724
919
  */
725
- private _applyNodes(nodes: readonly AnyNode[]): 'full' | FlowRect | null {
920
+ private _applyNodes(nodes: readonly AnyNode[]): 'full' | FlowRect[] {
921
+ const sameCardText = (a: AnyNode, b: AnyNode): boolean => {
922
+ const da = a.data as FlowNodeData | undefined;
923
+ const db = b.data as FlowNodeData | undefined;
924
+ return (
925
+ (da?.label ?? a.id) === (db?.label ?? b.id) &&
926
+ da?.description === db?.description
927
+ );
928
+ };
726
929
  const prev = this._entries;
727
930
  this._nodesSeen = this.props.nodes;
931
+ // Whether every node this commit changed is one a drag has lifted out
932
+ // of the GL world — the commit of the drag's own step, when the app
933
+ // stores it — so the world on the GPU is still the world.
934
+ const lift = this._lifted();
935
+ let onlyLifted = lift != null;
936
+ this._liftOnly = false;
728
937
  let structural = nodes.length !== prev.length;
729
938
  if (!structural) {
730
939
  for (let i = 0; i < nodes.length; i++) {
@@ -734,7 +943,6 @@ export class FlowGraphNode extends Node implements FlowInstance {
734
943
  if (
735
944
  n.id !== o.id ||
736
945
  n.type !== o.type ||
737
- n.selected !== o.selected ||
738
946
  n.hidden !== o.hidden ||
739
947
  n.zIndex !== o.zIndex ||
740
948
  n.handles !== o.handles ||
@@ -752,15 +960,39 @@ export class FlowGraphNode extends Node implements FlowInstance {
752
960
  this._rebuildNodes();
753
961
  return 'full';
754
962
  }
755
- let damage: FlowRect | null = null;
963
+ const damage: FlowRect[] = [];
964
+ // A node that is selected, or no longer is, is restyled and restacked —
965
+ // selection lifts it over its neighbours — and both happen inside its
966
+ // own box: edges are drawn under every node. Treated as structural, a
967
+ // selection change re-measured every node and repainted the pane, which
968
+ // a box selection does on every step that takes a node in.
969
+ let reordered = false;
970
+ // The minimap draws every node where it is, at its size, marked when it
971
+ // is selected — and, to a `nodeColor` function, as anything at all.
972
+ const colour = this._miniMapOptions()?.nodeColor;
973
+ let mapped = false;
974
+ // …and where the pane draws it, a node that only moved or changed size
975
+ // changed its own places in it, while the fit holds (`_miniMapMoves`)
976
+ const drawsMap = this._drawsMiniMap();
977
+ const moves: MiniMapMove[] = [];
978
+ // round the box: its handles, and the resize grips a selected node
979
+ // grows at its corners, which at a deep zoom reach past the cull margin
980
+ const margin = Math.max(
981
+ CULL_MARGIN,
982
+ RESIZE_GRIP * this._viewport().zoom + 2,
983
+ );
756
984
  for (let i = 0; i < nodes.length; i++) {
757
985
  const entry = prev[i];
758
986
  const next = nodes[i];
759
987
  const old = entry.node;
760
988
  if (next === old) continue;
989
+ if (!lift?.has(next.id)) onlyLifted = false;
990
+ if (next.selected !== old.selected) reordered = true;
991
+ if (typeof colour === 'function') mapped = true;
761
992
  const moved =
762
993
  next.position.x !== old.position.x ||
763
994
  next.position.y !== old.position.y;
995
+ if (moved) mapped = true;
764
996
  // `data`, an explicit size or a paint style are the node's own to
765
997
  // change: they re-measure and repaint *this* node, never the graph. A
766
998
  // keystroke into a mounted node's textarea patches `data` on every
@@ -771,7 +1003,8 @@ export class FlowGraphNode extends Node implements FlowInstance {
771
1003
  next.data !== old.data ||
772
1004
  next.width !== old.width ||
773
1005
  next.height !== old.height;
774
- const restyled = next.style !== old.style;
1006
+ const restyled =
1007
+ next.style !== old.style || next.selected !== old.selected;
775
1008
  if (!moved && !reshaped && !restyled) {
776
1009
  // a behavioural flag (draggable, deletable…) — nothing drawn reads it
777
1010
  entry.node = next;
@@ -779,6 +1012,8 @@ export class FlowGraphNode extends Node implements FlowInstance {
779
1012
  }
780
1013
  const widthBefore = entry.width;
781
1014
  const heightBefore = entry.height;
1015
+ const placeBefore =
1016
+ drawsMap && (moved || reshaped) ? this.rectOf(entry) : null;
782
1017
  let box = moved ? this._nodeDamage(entry) : this._screenRect(entry);
783
1018
  entry.node = next;
784
1019
  if (reshaped) {
@@ -788,6 +1023,26 @@ export class FlowGraphNode extends Node implements FlowInstance {
788
1023
  entry.specs = resolveHandles(next, entry.type);
789
1024
  }
790
1025
  const grew = entry.width !== widthBefore || entry.height !== heightBefore;
1026
+ if (grew) mapped = true;
1027
+ // A drag's own step, stored by the app, moves nothing on screen:
1028
+ // the node was already where the drag had it
1029
+ if (placeBefore && !sameRect(placeBefore, this.rectOf(entry))) {
1030
+ moves.push({ entry, before: placeBefore });
1031
+ }
1032
+ // Nothing the pane draws changed, while the node holds its place and
1033
+ // its size, when its card is the bodies' layer's on the 2D renderer —
1034
+ // the layer repaints it, if anything it shows changed — or when what
1035
+ // changed is data its card does not show: a card shows the label, the
1036
+ // description and the style, and a type that paints its own may show
1037
+ // anything. A board of live widgets, a tenth of them patching their
1038
+ // data ten times a second, rebuilt the GL world on every patch, and on
1039
+ // the 2D renderer repainted cards that showed none of it.
1040
+ if (!moved && !grew) {
1041
+ if (this._cardInLayer(next.id)) continue;
1042
+ if (!restyled && !entry.type?.paint && sameCardText(old, next)) {
1043
+ continue;
1044
+ }
1045
+ }
791
1046
  // The edges ride along only when an endpoint actually moved — a
792
1047
  // label edit that kept the box is the box's own business, and a
793
1048
  // keystroke that unioned its node's edges swept half the layer's
@@ -796,9 +1051,22 @@ export class FlowGraphNode extends Node implements FlowInstance {
796
1051
  box,
797
1052
  moved || grew ? this._nodeDamage(entry) : this._screenRect(entry),
798
1053
  );
799
- damage = damage ? unionRects(damage, box) : box;
1054
+ damage.push(inflateRect(box, margin));
800
1055
  }
801
- return damage ? inflateRect(damage, CULL_MARGIN) : null;
1056
+ if (reordered) this._sortOrder();
1057
+ if (reordered || mapped) {
1058
+ // A node that moved, grew or was picked out is a change in the
1059
+ // minimap's corner too. Only a selection used to claim it, so a node
1060
+ // the app moved repainted its own box and edges and the minimap went
1061
+ // on showing it where it had been. A selection, or a colour worked
1062
+ // out per node, is the whole corner's: it changes how the nodes there
1063
+ // are filled together (`SceneMiniMapNode`).
1064
+ const whole = !drawsMap || reordered || typeof colour === 'function';
1065
+ const map = whole ? this._miniMapCorner() : this._miniMapMoves(moves);
1066
+ if (map) damage.push(map);
1067
+ }
1068
+ this._liftOnly = onlyLifted;
1069
+ return damage;
802
1070
  }
803
1071
 
804
1072
  /**
@@ -817,12 +1085,16 @@ export class FlowGraphNode extends Node implements FlowInstance {
817
1085
  let box = this._screenRect(entry);
818
1086
  const edges = this._edgesByNode.get(entry.node.id) ?? [];
819
1087
  const tight = edges.length <= 16;
1088
+ const sv = this._screenViewport();
820
1089
  for (const edge of edges) {
821
1090
  const geometry = tight ? this._edgeGeometry(edge) : null;
822
- const bounds = geometry
823
- ? pathBounds(geometry.points)
824
- : this._edgeCoarseBox(edge);
825
- if (bounds) box = unionRects(box, bounds);
1091
+ // the route's own ink, heads and pen and all — the pen only past the
1092
+ // margin every caller grows this by — or the coarse box, whose slack
1093
+ // holds both
1094
+ const ink = geometry
1095
+ ? edgeInkBox(sv, edge, geometry.points, CULL_MARGIN)
1096
+ : this._edgeInkBox(edge);
1097
+ if (ink) box = unionRects(box, ink);
826
1098
  }
827
1099
  return box;
828
1100
  }
@@ -847,7 +1119,13 @@ export class FlowGraphNode extends Node implements FlowInstance {
847
1119
  }
848
1120
  this._entries = entries;
849
1121
  this._byId = byId;
850
- this._order = entries
1122
+ this._sortOrder();
1123
+ }
1124
+
1125
+ /** The paint order: by `zIndex`, a selected node over the rest of its
1126
+ * layer, and declaration order breaking ties. */
1127
+ private _sortOrder(): void {
1128
+ this._order = this._entries
851
1129
  .map((entry, index) => ({ entry, index }))
852
1130
  .sort((a, b) => {
853
1131
  const az = a.entry.node.zIndex ?? 0;
@@ -892,6 +1170,12 @@ export class FlowGraphNode extends Node implements FlowInstance {
892
1170
  return this._prop<Viewport>('viewport') ?? this._vp;
893
1171
  }
894
1172
 
1173
+ /** The viewport with the pane's window origin folded in — what every
1174
+ * graph-to-window conversion shared with `./scene.ts` takes. */
1175
+ private _screenViewport(): Viewport {
1176
+ return screenViewport(this._viewport(), this._pane());
1177
+ }
1178
+
895
1179
  /**
896
1180
  * Device pixels per logical pixel — the display scale this pane's window
897
1181
  * resolved to (react-x11's docs/scale.md): `1` on an ordinary display,
@@ -946,6 +1230,48 @@ export class FlowGraphNode extends Node implements FlowInstance {
946
1230
  this.invalidate(false, this._device(rect), reason);
947
1231
  }
948
1232
 
1233
+ /** Whether the pane draws the minimap itself. On a canvas of its own —
1234
+ * under mounted bodies — it is `invalidate`'s to repaint. */
1235
+ private _drawsMiniMap(): boolean {
1236
+ return this._panelCanvases.length === 0 && this._miniMapOptions() != null;
1237
+ }
1238
+
1239
+ /**
1240
+ * What moving or resizing `moves` changed in the minimap's corner, where
1241
+ * the pane draws it: each node's place there before and after, while the
1242
+ * fit is the one on screen — the graph's bounds with the viewport's,
1243
+ * which put every node where it is there — and the whole corner when it
1244
+ * is not, since every node moves in it then. A drag of one node among
1245
+ * 2,000 claimed the corner on every step, and every such pass built and
1246
+ * filled all 2,000 again, where it builds what it reaches now
1247
+ * (`buildMiniMap`). Asked against the fit on screen rather than the one
1248
+ * before the change, a step works the fit out once, and not at all when
1249
+ * nothing moved on screen.
1250
+ */
1251
+ private _miniMapMoves(moves: readonly MiniMapMove[]): FlowRect | null {
1252
+ const corner = this._miniMapCorner();
1253
+ if (!corner || moves.length === 0) return null;
1254
+ const fit = this._miniMap();
1255
+ const shown = this._miniMapShown;
1256
+ if (!fit && !shown) return null;
1257
+ if (!fit || !shown || !sameFit(fit, shown)) return corner;
1258
+ let out: FlowRect | null = null;
1259
+ for (const { entry, before } of moves) {
1260
+ out = unionMaybe(out, miniMapPlace(shown, before));
1261
+ out = unionMaybe(out, miniMapPlace(fit, this.rectOf(entry)));
1262
+ }
1263
+ return out && inflateRect(out, MINIMAP_BLEED);
1264
+ }
1265
+
1266
+ /** A gesture's step, claimed in the minimap: `_miniMapMoves`. */
1267
+ private _claimMiniMapMoves(
1268
+ moves: readonly MiniMapMove[],
1269
+ reason: string,
1270
+ ): void {
1271
+ const map = this._miniMapMoves(moves);
1272
+ if (map) this._claim(map, reason);
1273
+ }
1274
+
949
1275
  /**
950
1276
  * The pane proper: `abs` inset by border and padding, off core's own
951
1277
  * resolved layout (react-x11#254) — so a `<Flow style={{ borderWidth: 1,
@@ -1027,9 +1353,96 @@ export class FlowGraphNode extends Node implements FlowInstance {
1027
1353
  });
1028
1354
  }
1029
1355
 
1356
+ /**
1357
+ * One entry as `./scene.ts` takes it: the live gesture state resolved
1358
+ * away, so everything past this point is pure. A drag's position and a
1359
+ * resize's box are already in `rect`, and `grips` is already filtered by
1360
+ * whatever the handles took.
1361
+ *
1362
+ * Built per call rather than cached: `rectOf` is what keeps a drag in step
1363
+ * with the commit that has not landed yet, and a cache here is a cache of
1364
+ * last frame's positions.
1365
+ */
1366
+ /** Whether a React body is mounted over this node — which is what makes
1367
+ * its card draw a title bar rather than a centred label, and what
1368
+ * `_emitBodies` reports a box for. */
1369
+ private _mounted(entry: NodeEntry): boolean {
1370
+ return entry.type?.render != null && this._viewport().zoom >= RENDER_ZOOM;
1371
+ }
1372
+
1373
+ /**
1374
+ * The frame being painted: each node's scene source, and the two lists a
1375
+ * 2D pass builds its scene from. A frame paints its damage as several
1376
+ * passes — a pan's strips, its pinned furniture, the corners of a rounded
1377
+ * pane — and the scene cache culls the graph to the screen once for all
1378
+ * of them (`SceneCache.edgesOnScreen`), keyed on these very lists, so
1379
+ * every pass has to be handed the same ones. The passes run one after
1380
+ * another in a single task, so a microtask queued by the first forgets
1381
+ * the frame after the last — the same reasoning as `_reportFrame`.
1382
+ */
1383
+ private _frame: {
1384
+ sources: Map<NodeEntry, SceneNodeSource>;
1385
+ lists: { all: SceneNodeSource[]; nodes: SceneNodeSource[] } | null;
1386
+ } | null = null;
1387
+
1388
+ private _paintedFrame(): NonNullable<FlowGraphNode['_frame']> {
1389
+ let frame = this._frame;
1390
+ if (!frame) {
1391
+ frame = this._frame = { sources: new Map(), lists: null };
1392
+ void Promise.resolve().then(() => {
1393
+ this._frame = null;
1394
+ });
1395
+ }
1396
+ return frame;
1397
+ }
1398
+
1399
+ private _sourceOf(entry: NodeEntry): SceneNodeSource {
1400
+ if (!this._painting) return this._source(entry);
1401
+ const { sources } = this._paintedFrame();
1402
+ let source = sources.get(entry);
1403
+ if (!source) {
1404
+ source = this._source(entry);
1405
+ sources.set(entry, source);
1406
+ }
1407
+ return source;
1408
+ }
1409
+
1410
+ /** A 2D pass's node lists, the same arrays for every pass of a frame. */
1411
+ private _frameLists(order: readonly NodeEntry[]): {
1412
+ all: SceneNodeSource[];
1413
+ nodes: SceneNodeSource[];
1414
+ } {
1415
+ const frame = this._paintedFrame();
1416
+ frame.lists ??= {
1417
+ all: this._entries.map((entry) => this._sourceOf(entry)),
1418
+ nodes: order
1419
+ .filter((entry) => !this._cardInLayer(entry.node.id))
1420
+ .map((entry) => this._sourceOf(entry)),
1421
+ };
1422
+ return frame.lists;
1423
+ }
1424
+
1425
+ private _source(entry: NodeEntry): SceneNodeSource {
1426
+ return {
1427
+ node: entry.node,
1428
+ rect: this.rectOf(entry),
1429
+ specs: entry.specs,
1430
+ type: entry.type,
1431
+ header: this._headerHeight(entry),
1432
+ mounted: this._mounted(entry),
1433
+ connectable: this._connectable(entry),
1434
+ grips: this._grips(entry),
1435
+ };
1436
+ }
1437
+
1438
+ /** Not through `_source`: a source carries its grips, and `_grips` asks
1439
+ * where the handles are — the pair would recur. */
1030
1440
  private _handlesOf(entry: NodeEntry): HandleAnchor[] {
1031
- const rect = this.rectOf(entry);
1032
- return entry.specs.map((spec) => handleAnchor(entry.node.id, rect, spec));
1441
+ return anchorsOf({
1442
+ node: entry.node,
1443
+ rect: this.rectOf(entry),
1444
+ specs: entry.specs,
1445
+ });
1033
1446
  }
1034
1447
 
1035
1448
  /**
@@ -1047,22 +1460,14 @@ export class FlowGraphNode extends Node implements FlowInstance {
1047
1460
  * testing reads the same rect, so what is drawn and what is clicked still
1048
1461
  * agree to the pixel.
1049
1462
  */
1050
- private _screenRect(entry: NodeEntry): FlowRect {
1051
- const v = this._viewport();
1052
- const rect = this.rectOf(entry);
1053
- const p = this._toScreen(rect);
1054
- const s = this._scale;
1055
- const grid = (value: number): number => Math.round(value * s) / s;
1056
- const x = grid(p.x);
1057
- const y = grid(p.y);
1058
- return {
1059
- x,
1060
- y,
1061
- // rounded as edges rather than as a size, so two nodes that share a
1062
- // column still share it after rounding
1063
- width: grid(p.x + rect.width * v.zoom) - x,
1064
- height: grid(p.y + rect.height * v.zoom) - y,
1065
- };
1463
+ private _screenRect(
1464
+ entry: NodeEntry,
1465
+ // A loop over the nodes passes it in: the viewport reads the pane's
1466
+ // content box off layout, and asked once per node it was most of what a
1467
+ // hit test cost on a large graph — every pointer move, 400 times.
1468
+ sv: Viewport = this._screenViewport(),
1469
+ ): FlowRect {
1470
+ return screenRect(sv, this.rectOf(entry), this._scale);
1066
1471
  }
1067
1472
 
1068
1473
  private _visible(): boolean {
@@ -1082,12 +1487,46 @@ export class FlowGraphNode extends Node implements FlowInstance {
1082
1487
  const v = { x: next.x, y: next.y, zoom };
1083
1488
  const controlled = this.props.viewport !== undefined;
1084
1489
  if (!controlled) this._vp = v;
1490
+ if (previous.x !== v.x || previous.y !== v.y || previous.zoom !== v.zoom) {
1491
+ this._viewMovedAt = now();
1492
+ }
1493
+ if (previous.zoom !== v.zoom) {
1494
+ // A stream is steps close together — or a step that follows the frame
1495
+ // that drew the one before it: an animation zooms a step a frame, and
1496
+ // a frame whose bodies re-scale can take longer than the rest. Judged
1497
+ // on the steps alone, such an animation was a run of single jumps, so
1498
+ // its bodies were never held, every step re-scaled them all, and it
1499
+ // stayed at two frames a second where held it ran at twenty — on
1500
+ // whichever side of the line its first frames happened to fall.
1501
+ const t = now();
1502
+ this._zoomStream =
1503
+ t - this._zoomAt < GL_ZOOM_REST_MS ||
1504
+ t - this._zoomDrawnAt < GL_ZOOM_REST_MS;
1505
+ this._zoomAt = t;
1506
+ this._zoomFrameAt = -Infinity;
1507
+ this._zoomDrawnAt = -Infinity;
1508
+ }
1509
+ // Each claims its own box: a claim with no region is the whole window,
1510
+ // which made every pan step and dash tick a full frame. The minimap's
1511
+ // alone: its view box moves, and the controls show nothing of the
1512
+ // viewport.
1513
+ for (const canvas of this._miniMapCanvases())
1514
+ canvas.invalidate(false, canvas, 'props');
1085
1515
  this._prop<(vp: Viewport) => void>('onViewportChange')?.(v);
1086
1516
  // The `fitView` that runs at the top of a paint has already changed what
1087
1517
  // this frame will draw, so asking for another one would only draw the
1088
1518
  // same picture twice.
1089
1519
  if (!controlled && !this._painting) {
1090
- if (!this._blitPan(previous, v)) this._repaint('scroll');
1520
+ if (this._gl) {
1521
+ // Under GL a pan is the surface's offset uniform and nothing else,
1522
+ // and a zoom is the frame's to decide — the world scaled while the
1523
+ // gesture moves, rebuilt once it rests (`glFrame`). Neither is a
1524
+ // change to the world, and the 2D pane under the surface shows none
1525
+ // of the graph: claiming its box every step repainted it for
1526
+ // nothing (~4 ms a frame) — and, with bodies mounted, reached
1527
+ // core's overlay too.
1528
+ this._glRequest?.();
1529
+ } else if (!this._blitPan(previous, v)) this._repaint('scroll');
1091
1530
  // same-frame compositing for mounted bodies — see `_dragStep`
1092
1531
  this._emitBodies();
1093
1532
  }
@@ -1101,26 +1540,33 @@ export class FlowGraphNode extends Node implements FlowInstance {
1101
1540
  * the shift exposed — which `paintDamage()` then hands to `paint`, so the
1102
1541
  * existing culling draws the sliver and nothing else.
1103
1542
  *
1104
- * The furniture — minimap, zoom controls — is pinned to the pane while
1105
- * its pixels would ride the blit, so its bands are carved out of the
1106
- * region that shifts and claimed as ordinary damage. The blit gate tests
1107
- * foreign claims against the *rect* (react-x11#309/#310), so a claim
1108
- * sitting edge to edge with it leaves the frame a blit: the middle of
1109
- * the pane is copied, the strips repaint with the new viewport, and the
1110
- * panels repaint in place.
1543
+ * The furniture — minimap, zoom controls — stays put while the graph
1544
+ * moves, so it is handed over as `pinned` (react-x11#682, 2.22): the
1545
+ * whole pane is copied, and core repaints each panel and the image of
1546
+ * it the copy dragged along, with the strips the shift exposed.
1547
+ *
1548
+ * Mounted node bodies ride it. They are laid out in one box beside the
1549
+ * pane, which a pan moves by exactly the pan, so their pixels move with
1550
+ * the pane's: the box goes to `scrollContents` as a rider
1551
+ * (react-x11#671), its commit claims nothing, and what it leaves or
1552
+ * reaches outside the rect is all it costs. `<Flow>` clips it to the pane for that. A body entering or
1553
+ * leaving the pane, or changing as it goes, claims inside the rect and
1554
+ * declines that frame's blit; the next one blits again.
1111
1555
  *
1112
1556
  * Still a full repaint when:
1113
1557
  * - the zoom moved (scaling is not a blit) or the shift is fractional on
1114
1558
  * the device grid — every real pan gesture is whole device pixels;
1115
- * - mounted node bodies exist: they are core-owned siblings whose
1116
- * gesture-time commits claim *inside* the rect every step, which
1117
- * declines the blit anyway — bailing early skips the churn;
1118
- * - the furniture bands would eat the pane (a tiny pane, or panels on
1119
- * both the top and the bottom of a short one).
1559
+ * - bodies are mounted and `<Flow>` has not handed their box over;
1560
+ * - the pane is too small to be worth it; and core falls back itself
1561
+ * when the repairs would repaint most of it.
1120
1562
  */
1121
1563
  private _blitPan(previous: Viewport, next: Viewport): boolean {
1564
+ // There is no backing store to scroll under GL: the surface redraws the
1565
+ // whole scene every frame, and a pan is its cheapest frame of all.
1566
+ if (this._gl) return false;
1122
1567
  if (next.zoom !== previous.zoom) return false;
1123
- if (this._bodiesKey !== '') return false;
1568
+ const riders = this._bodies.length > 0 ? this._bodiesLayer : null;
1569
+ if (this._bodies.length > 0 && !riders) return false;
1124
1570
  // Device pixels: the blit copies the backing store, and its grid is the
1125
1571
  // panel's. A pointer step lands on it whatever the scale — it came off
1126
1572
  // the wire as whole device pixels — so every real pan gesture blits.
@@ -1131,77 +1577,68 @@ export class FlowGraphNode extends Node implements FlowInstance {
1131
1577
  const dy = Math.round(shiftY);
1132
1578
  if (dx === 0 && dy === 0) return true; // sub-pixel: nothing to show yet
1133
1579
  if (shiftX !== dx || shiftY !== dy) return false;
1134
- const pane = this._pane();
1135
-
1136
- // The horizontal bands the furniture lives in. Full-width, because the
1137
- // graph beside a panel must repaint too — it does not ride the blit.
1138
- let topBand = 0;
1139
- let bottomBand = 0;
1140
- const claim = (panel: FlowRect, position: string | undefined): void => {
1141
- if ((position ?? 'bottom-right').startsWith('top')) {
1142
- topBand = Math.max(topBand, panel.y + panel.height - pane.y);
1143
- } else {
1144
- bottomBand = Math.max(bottomBand, pane.y + pane.height - panel.y);
1145
- }
1146
- };
1147
- const map = this._miniMapOptions();
1148
- if (map) {
1149
- claim(
1150
- this._corner(
1151
- map.position,
1152
- map.width ?? MINIMAP_W,
1153
- map.height ?? MINIMAP_H,
1154
- 'bottom-right',
1155
- ),
1156
- map.position,
1157
- );
1158
- }
1159
- const buttons = this._controlButtons();
1160
- if (buttons.length > 0) {
1161
- const first = buttons[0].rect;
1162
- const last = buttons[buttons.length - 1].rect;
1163
- claim(
1164
- unionRects(first, last),
1165
- this._controlsOptions()?.position ?? 'bottom-left',
1166
- );
1167
- }
1168
- // From here on, device pixels: `scrollContents` and the claims speak
1169
- // core's units. The bands round *up* and the blit is what is left, so
1170
- // the three sit edge to edge on whole pixels — a claim overlapping the
1171
- // blit rect by a pixel is a foreign claim, and declines the blit.
1580
+ // The furniture — minimap, zoom controls — stays put while the graph
1581
+ // moves under it: pinned inside the region, which core repaints after
1582
+ // the copy, with the stale image the copy dragged along (react-x11
1583
+ // #682, 2.22). It used to be carved out of the region, and a region is
1584
+ // one rectangle, so controls in one bottom corner and the minimap in the
1585
+ // other carved a band the pane's full width — every card, label and edge
1586
+ // in it repainted on every pan frame: 47 fps over the stress example's
1587
+ // widgets on XQuartz, where a pane with no furniture pans at 80.
1588
+ //
1589
+ // Device pixels, rounded out, and a pixel over: a panel's own claims —
1590
+ // the minimap's view box moves with every pan — must land inside its
1591
+ // pinned rect, or they decline the blit.
1172
1592
  const box = this.contentBox();
1173
- const top = Math.ceil(toDevice(topBand, s));
1174
- const bottom = Math.ceil(toDevice(bottomBand, s));
1175
- const blit: FlowRect = {
1176
- x: box.x,
1177
- y: box.y + top,
1178
- width: box.width,
1179
- height: box.height - top - bottom,
1180
- };
1181
- if (blit.width < 64 * s || blit.height < 64 * s) return false;
1182
- if (Math.abs(dx) >= blit.width || Math.abs(dy) >= blit.height) {
1183
- return false;
1184
- }
1185
- this.scrollContents(blit, dx, dy);
1186
- if (top > 0) {
1187
- this.invalidate(
1188
- false,
1189
- { x: box.x, y: box.y, width: box.width, height: top },
1190
- 'scroll',
1593
+ if (box.width < 64 * s || box.height < 64 * s) return false;
1594
+ if (Math.abs(dx) >= box.width || Math.abs(dy) >= box.height) return false;
1595
+ const pinned: FlowRect[] = [];
1596
+ const pin = (rect: FlowRect): void => {
1597
+ const x0 = Math.max(box.x, Math.floor(rect.x) - 1);
1598
+ const y0 = Math.max(box.y, Math.floor(rect.y) - 1);
1599
+ const x1 = Math.min(
1600
+ box.x + box.width,
1601
+ Math.ceil(rect.x + rect.width) + 1,
1191
1602
  );
1192
- }
1193
- if (bottom > 0) {
1194
- this.invalidate(
1195
- false,
1196
- {
1197
- x: box.x,
1198
- y: blit.y + blit.height,
1199
- width: box.width,
1200
- height: bottom,
1201
- },
1202
- 'scroll',
1603
+ const y1 = Math.min(
1604
+ box.y + box.height,
1605
+ Math.ceil(rect.y + rect.height) + 1,
1203
1606
  );
1607
+ if (x1 > x0 && y1 > y0) {
1608
+ pinned.push({ x: x0, y: y0, width: x1 - x0, height: y1 - y0 });
1609
+ }
1610
+ };
1611
+ if (this._panelCanvases.length > 0) {
1612
+ // Painted on canvases of their own, over mounted bodies: a node laid
1613
+ // over the region is pinned by its paint bounds, which carry core's
1614
+ // slop round its box.
1615
+ for (const canvas of this._panelCanvases) {
1616
+ const reach = canvas.paintBounds?.();
1617
+ if (reach) pin(reach);
1618
+ }
1619
+ } else {
1620
+ const map = this._miniMapOptions();
1621
+ if (map) {
1622
+ pin(
1623
+ this._device(
1624
+ this._corner(
1625
+ map.position,
1626
+ map.width ?? MINIMAP_W,
1627
+ map.height ?? MINIMAP_H,
1628
+ 'bottom-right',
1629
+ ),
1630
+ ),
1631
+ );
1632
+ }
1633
+ const buttons = this._controlButtons();
1634
+ if (buttons.length > 0) {
1635
+ const first = buttons[0].rect;
1636
+ const last = buttons[buttons.length - 1].rect;
1637
+ pin(this._device(unionRects(first, last)));
1638
+ }
1204
1639
  }
1640
+ this.scrollContents(box, dx, dy, riders ? [riders] : null, pinned);
1641
+ this._blittedPanAt = now();
1205
1642
  return true;
1206
1643
  }
1207
1644
 
@@ -1245,6 +1682,27 @@ export class FlowGraphNode extends Node implements FlowInstance {
1245
1682
 
1246
1683
  // --- the imperative surface (`FlowInstance`) -----------------------------
1247
1684
 
1685
+ /**
1686
+ * `fn`, as a call through the handle. A programmatic pan — an animation
1687
+ * loop stepping `setViewport` — moves the pane at once and the bodies'
1688
+ * box through React, and on React's schedule the box caught up with the
1689
+ * pane in jumps: several steps' worth in one commit, landing in a frame
1690
+ * whose blit shifted by one. A rider that moves by more than the blit is
1691
+ * a layout move, so that frame repainted the pane whole — a fifth of the
1692
+ * frames of a pan over mounted bodies (`_blitPan`). Marked like a
1693
+ * gesture's, the emission commits before the call returns, and the box
1694
+ * moves by exactly the pan in the frame that blits it.
1695
+ */
1696
+ fromHandle<T>(fn: () => T): T {
1697
+ const was = this._handleSync;
1698
+ this._handleSync = true;
1699
+ try {
1700
+ return fn();
1701
+ } finally {
1702
+ this._handleSync = was;
1703
+ }
1704
+ }
1705
+
1248
1706
  getViewport(): Viewport {
1249
1707
  return { ...this._viewport() };
1250
1708
  }
@@ -1386,10 +1844,11 @@ export class FlowGraphNode extends Node implements FlowInstance {
1386
1844
  /** The topmost node under a window point, or null. Walks paint order
1387
1845
  * backwards, which is what "topmost" means. */
1388
1846
  private _nodeAt(x: number, y: number): NodeEntry | null {
1847
+ const sv = this._screenViewport();
1389
1848
  for (let i = this._order.length - 1; i >= 0; i--) {
1390
1849
  const entry = this._order[i];
1391
1850
  if (entry.node.hidden) continue;
1392
- if (rectContains(this._screenRect(entry), { x, y })) return entry;
1851
+ if (rectContains(this._screenRect(entry, sv), { x, y })) return entry;
1393
1852
  }
1394
1853
  return null;
1395
1854
  }
@@ -1401,13 +1860,14 @@ export class FlowGraphNode extends Node implements FlowInstance {
1401
1860
  ): HandleAnchor | null {
1402
1861
  const zoom = this._viewport().zoom;
1403
1862
  const reach = Math.max(7, (HANDLE_RADIUS + HANDLE_SLOP) * zoom);
1863
+ const sv = this._screenViewport();
1404
1864
  for (let i = this._order.length - 1; i >= 0; i--) {
1405
1865
  const entry = this._order[i];
1406
1866
  if (entry.node.hidden) continue;
1407
1867
  if (!this._connectable(entry)) continue;
1408
1868
  // cheap reject: the handles are on the node's border, so nothing more
1409
1869
  // than `reach` outside its box can be one
1410
- const rect = this._screenRect(entry);
1870
+ const rect = this._screenRect(entry, sv);
1411
1871
  if (
1412
1872
  x < rect.x - reach ||
1413
1873
  x > rect.x + rect.width + reach ||
@@ -1437,12 +1897,13 @@ export class FlowGraphNode extends Node implements FlowInstance {
1437
1897
  ): { entry: NodeEntry; dir: XYPosition } | null {
1438
1898
  const zoom = this._viewport().zoom;
1439
1899
  const reach = Math.max(6, (RESIZE_GRIP + RESIZE_SLOP) * zoom);
1900
+ const sv = this._screenViewport();
1440
1901
  for (let i = this._order.length - 1; i >= 0; i--) {
1441
1902
  const entry = this._order[i];
1442
1903
  if (entry.node.hidden) continue;
1443
1904
  const grips = this._grips(entry);
1444
1905
  if (grips.length === 0) continue;
1445
- const rect = this._screenRect(entry);
1906
+ const rect = this._screenRect(entry, sv);
1446
1907
  if (
1447
1908
  x < rect.x - reach ||
1448
1909
  x > rect.x + rect.width + reach ||
@@ -1487,38 +1948,30 @@ export class FlowGraphNode extends Node implements FlowInstance {
1487
1948
  * the square root of the gap (see `paths.ts`) — `8` where the shoulder
1488
1949
  * uses `6.25`, so the bound is generous rather than tight.
1489
1950
  */
1951
+ /** The coarse box with the label's plate in it: what an edge that
1952
+ * changes has to claim, where its route alone would leave a plate's
1953
+ * ends. */
1954
+ private _edgeInkBox(edge: AnyEdge): FlowRect | null {
1955
+ const box = this._edgeCoarseBox(edge);
1956
+ return box && withLabelPlate(box, edge, this._viewport().zoom);
1957
+ }
1958
+
1490
1959
  private _edgeCoarseBox(edge: AnyEdge): FlowRect | null {
1491
1960
  const source = this._byId.get(edge.source);
1492
1961
  const target = this._byId.get(edge.target);
1493
1962
  if (!source || !target) return null;
1494
1963
  if (source.node.hidden || target.node.hidden) return null;
1495
- const zoom = this._viewport().zoom;
1496
- const a = this._screenRect(source);
1497
- const b = this._screenRect(target);
1498
- const span = Math.hypot(
1499
- a.x + a.width / 2 - (b.x + b.width / 2),
1500
- a.y + a.height / 2 - (b.y + b.height / 2),
1964
+ return edgeCoarseBox(
1965
+ this._screenViewport(),
1966
+ this._source(source),
1967
+ this._source(target),
1968
+ this._scale,
1501
1969
  );
1502
- const slack =
1503
- Math.max(5, EDGE_SLOP * zoom) +
1504
- Math.max(EDGE_STEP_OFFSET * 3 * zoom, 8 * Math.sqrt(zoom * span));
1505
- const x = Math.min(a.x, b.x) - slack;
1506
- const y = Math.min(a.y, b.y) - slack;
1507
- return {
1508
- x,
1509
- y,
1510
- width: Math.max(a.x + a.width, b.x + b.width) + slack - x,
1511
- height: Math.max(a.y + a.height, b.y + b.height) + slack - y,
1512
- };
1513
1970
  }
1514
1971
 
1515
1972
  /** The drawn handle, in screen pixels. Capped as well as floored: a dot
1516
1973
  * that scaled all the way up would be a saucer at 2.5×, and the thing it
1517
1974
  * marks — a point on the border — does not get bigger. */
1518
- private _handleRadius(): number {
1519
- return clamp(HANDLE_RADIUS * this._viewport().zoom, 2.5, 6);
1520
- }
1521
-
1522
1975
  private _connectable(entry: NodeEntry): boolean {
1523
1976
  return (
1524
1977
  (entry.node.connectable ?? this._bool('nodesConnectable', true)) !== false
@@ -1541,25 +1994,12 @@ export class FlowGraphNode extends Node implements FlowInstance {
1541
1994
  type: 'source' | 'target',
1542
1995
  towards: XYPosition | null,
1543
1996
  ): HandleAnchor | null {
1544
- const anchors = this._handlesOf(entry);
1545
- if (handleId != null) {
1546
- const named = anchors.find((a) => a.id === handleId);
1547
- if (named) return named;
1548
- }
1549
- const typed = anchors.filter((a) => a.type === type);
1550
- const candidates = typed.length > 0 ? typed : anchors;
1551
- if (candidates.length === 0) return null;
1552
- if (candidates.length === 1 || !towards) return candidates[0];
1553
- let best = candidates[0];
1554
- let bestDistance = Infinity;
1555
- for (const anchor of candidates) {
1556
- const d = Math.hypot(anchor.x - towards.x, anchor.y - towards.y);
1557
- if (d < bestDistance) {
1558
- bestDistance = d;
1559
- best = anchor;
1560
- }
1561
- }
1562
- return best;
1997
+ return endpoint(
1998
+ { node: entry.node, rect: this.rectOf(entry), specs: entry.specs },
1999
+ handleId,
2000
+ type,
2001
+ towards,
2002
+ );
1563
2003
  }
1564
2004
 
1565
2005
  /** An edge's polyline, in **screen** pixels, plus the two ends it joins.
@@ -1568,46 +2008,16 @@ export class FlowGraphNode extends Node implements FlowInstance {
1568
2008
  private _edgeGeometry(
1569
2009
  edge: AnyEdge,
1570
2010
  ): { points: XYPosition[]; from: HandleAnchor; to: HandleAnchor } | null {
1571
- const sourceEntry = this._byId.get(edge.source);
1572
- const targetEntry = this._byId.get(edge.target);
1573
- if (!sourceEntry || !targetEntry) return null;
1574
- if (sourceEntry.node.hidden || targetEntry.node.hidden) return null;
1575
- const sourceCentre = this._centre(sourceEntry);
1576
- const targetCentre = this._centre(targetEntry);
1577
- const from = this._endpoint(
1578
- sourceEntry,
1579
- edge.sourceHandle,
1580
- 'source',
1581
- targetCentre,
1582
- );
1583
- const to = this._endpoint(
1584
- targetEntry,
1585
- edge.targetHandle,
1586
- 'target',
1587
- sourceCentre,
1588
- );
1589
- if (!from || !to) return null;
1590
-
1591
- const zoom = this._viewport().zoom;
1592
- const s = this._toScreen(from);
1593
- const t = this._toScreen(to);
1594
- const points = edgePath(
1595
- edge.type,
1596
- { x: s.x, y: s.y, position: from.position },
1597
- { x: t.x, y: t.y, position: to.position },
1598
- {
1599
- stepOffset: EDGE_STEP_OFFSET * zoom,
1600
- radius: 8 * zoom,
1601
- scale: zoom,
1602
- loop: edge.source === edge.target,
1603
- },
2011
+ const source = this._byId.get(edge.source);
2012
+ const target = this._byId.get(edge.target);
2013
+ if (!source || !target) return null;
2014
+ if (source.node.hidden || target.node.hidden) return null;
2015
+ return edgeRoute(
2016
+ this._screenViewport(),
2017
+ edge,
2018
+ this._source(source),
2019
+ this._source(target),
1604
2020
  );
1605
- return { points, from, to };
1606
- }
1607
-
1608
- private _centre(entry: NodeEntry): XYPosition {
1609
- const r = this.rectOf(entry);
1610
- return { x: r.x + r.width / 2, y: r.y + r.height / 2 };
1611
2021
  }
1612
2022
 
1613
2023
  // --- pane furniture geometry --------------------------------------------
@@ -1692,21 +2102,43 @@ export class FlowGraphNode extends Node implements FlowInstance {
1692
2102
  // A minimap that covers the pane it summarises is worse than none.
1693
2103
  return null;
1694
2104
  }
1695
- const rects: FlowRect[] = [];
1696
- for (const entry of this._entries) {
1697
- if (!entry.node.hidden) rects.push(this.rectOf(entry));
1698
- }
1699
2105
  // The viewport joins the bounds, so panning off the graph still shows
1700
- // where you are rather than pinning the box to an edge.
2106
+ // where you are rather than pinning the box to an edge. Worked out with
2107
+ // no box made per node: a drag asks this twice a step and again for
2108
+ // the pass (`_miniMapMoves`), over every node of the graph.
1701
2109
  const v = this._viewport();
1702
- rects.push({
1703
- x: -v.x / v.zoom,
1704
- y: -v.y / v.zoom,
1705
- width: pane.width / v.zoom,
1706
- height: pane.height / v.zoom,
1707
- });
1708
- const bounds = boundsOf(rects);
1709
- if (!bounds) return null;
2110
+ let minX = -v.x / v.zoom;
2111
+ let minY = -v.y / v.zoom;
2112
+ let maxX = minX + pane.width / v.zoom;
2113
+ let maxY = minY + pane.height / v.zoom;
2114
+ const resizing = this._resizeTo;
2115
+ for (const entry of this._entries) {
2116
+ const node = entry.node;
2117
+ if (node.hidden) continue;
2118
+ let x: number;
2119
+ let y: number;
2120
+ let w: number;
2121
+ let h: number;
2122
+ if (resizing && resizing.id === node.id) {
2123
+ ({ x, y, width: w, height: h } = resizing.rect);
2124
+ } else {
2125
+ const p = this._positionOf(node);
2126
+ x = p.x;
2127
+ y = p.y;
2128
+ w = entry.width;
2129
+ h = entry.height;
2130
+ }
2131
+ if (x < minX) minX = x;
2132
+ if (y < minY) minY = y;
2133
+ if (x + w > maxX) maxX = x + w;
2134
+ if (y + h > maxY) maxY = y + h;
2135
+ }
2136
+ const bounds = {
2137
+ x: minX,
2138
+ y: minY,
2139
+ width: maxX - minX,
2140
+ height: maxY - minY,
2141
+ };
1710
2142
  const pad = Math.max(bounds.width, bounds.height) * 0.05 + 10;
1711
2143
  const padded = {
1712
2144
  x: bounds.x - pad,
@@ -1737,20 +2169,6 @@ export class FlowGraphNode extends Node implements FlowInstance {
1737
2169
  };
1738
2170
  }
1739
2171
 
1740
- private _miniToScreen(
1741
- map: { panel: FlowRect; bounds: FlowRect; scale: number },
1742
- p: XYPosition,
1743
- ): XYPosition {
1744
- const ox =
1745
- map.panel.x + (map.panel.width - map.bounds.width * map.scale) / 2;
1746
- const oy =
1747
- map.panel.y + (map.panel.height - map.bounds.height * map.scale) / 2;
1748
- return {
1749
- x: ox + (p.x - map.bounds.x) * map.scale,
1750
- y: oy + (p.y - map.bounds.y) * map.scale,
1751
- };
1752
- }
1753
-
1754
2172
  // --- gestures ------------------------------------------------------------
1755
2173
 
1756
2174
  override defaultMouseDown(ev: MouseEvent): void {
@@ -1950,13 +2368,10 @@ export class FlowGraphNode extends Node implements FlowInstance {
1950
2368
  gesture.x = ev.x;
1951
2369
  gesture.y = ev.y;
1952
2370
  this._selectBox(gesture);
1953
- // The box outline is all that moves this step; a node crossing the
1954
- // boundary changes `selected`, and that repaints through the
1955
- // controlled round trip like any other selection change.
1956
- this._claim(
1957
- inflateRect(unionRects(oldBox, selectBoxRect(gesture)), CULL_MARGIN),
1958
- 'style-state',
1959
- );
2371
+ // The box is all that moves this step; a node crossing the boundary
2372
+ // changes `selected`, and that repaints through the controlled round
2373
+ // trip like any other selection change.
2374
+ this._claimSelectStep(oldBox, selectBoxRect(gesture));
1960
2375
  return;
1961
2376
  }
1962
2377
  case 'minimap': {
@@ -2104,6 +2519,33 @@ export class FlowGraphNode extends Node implements FlowInstance {
2104
2519
  );
2105
2520
  }
2106
2521
 
2522
+ /**
2523
+ * The nodes a drag is moving, once it has moved them: lifted out of the
2524
+ * rest of the graph for the rest of the gesture. Under GL the world is
2525
+ * built without them and they are a layer of their own (`glFrame`), so
2526
+ * each step packs them and their edges and leaves the world on the GPU;
2527
+ * in 2D the rest is two pictures a step copies from (`_liftShotFor`).
2528
+ * Null otherwise — and under GL the change back is a change of the
2529
+ * world's key, which rebuilds it with them in.
2530
+ */
2531
+ private _lifted(): ReadonlySet<string> | null {
2532
+ const g = this._gesture;
2533
+ if (g?.kind !== 'drag' || !g.moved || g.ids.length === 0) {
2534
+ if (this._liftSet) {
2535
+ this._liftSet = null;
2536
+ this._liftFor = null;
2537
+ this._liftGen++;
2538
+ }
2539
+ return null;
2540
+ }
2541
+ if (this._liftFor !== g) {
2542
+ this._liftSet = new Set(g.ids);
2543
+ this._liftFor = g;
2544
+ this._liftGen++;
2545
+ }
2546
+ return this._liftSet;
2547
+ }
2548
+
2107
2549
  private _dragStep(
2108
2550
  gesture: Extract<Gesture, { kind: 'drag' }>,
2109
2551
  ev: MouseEvent,
@@ -2151,22 +2593,38 @@ export class FlowGraphNode extends Node implements FlowInstance {
2151
2593
  }
2152
2594
  // Old places first, new places second: the union is everything this
2153
2595
  // step uncovers plus everything it covers, and nothing else — with the
2154
- // edges that follow the moved nodes, coarse, on both sides.
2596
+ // edges that follow the moved nodes, coarse, on both sides. Under GL
2597
+ // there is nothing to size: the step is a frame of the surface's, and
2598
+ // routing each moved node's edges for a rect nobody reads was a third
2599
+ // of a millisecond a step.
2600
+ const sized = !this._gl;
2155
2601
  let damage: FlowRect | null = null;
2156
- for (const id of gesture.ids) {
2157
- const entry = this._byId.get(id);
2158
- if (entry) damage = unionMaybe(damage, this._nodeDamage(entry));
2602
+ // …and in the minimap, the boxes the step moves them from
2603
+ const maps = sized && this._drawsMiniMap();
2604
+ const moves: MiniMapMove[] = [];
2605
+ if (sized) {
2606
+ for (const id of gesture.ids) {
2607
+ const entry = this._byId.get(id);
2608
+ if (!entry) continue;
2609
+ damage = unionMaybe(damage, this._nodeDamage(entry));
2610
+ if (maps) moves.push({ entry, before: this.rectOf(entry) });
2611
+ }
2159
2612
  }
2160
2613
  this._dragTo = to;
2161
- for (const id of gesture.ids) {
2162
- const entry = this._byId.get(id);
2163
- if (entry) damage = unionMaybe(damage, this._nodeDamage(entry));
2614
+ if (sized) {
2615
+ for (const id of gesture.ids) {
2616
+ const entry = this._byId.get(id);
2617
+ if (entry) damage = unionMaybe(damage, this._nodeDamage(entry));
2618
+ }
2164
2619
  }
2165
2620
  this._emitNodes(changes);
2621
+ const reason = this._lifted() ? 'lift' : 'content';
2166
2622
  if (damage) {
2167
- this._claim(inflateRect(damage, CULL_MARGIN), 'content');
2623
+ this._claim(inflateRect(damage, CULL_MARGIN), reason);
2624
+ if (maps) this._claimMiniMapMoves(moves, reason);
2168
2625
  } else {
2169
- this._repaint();
2626
+ // under GL: a frame, and — while the nodes are lifted — of them alone
2627
+ this._repaint(reason);
2170
2628
  }
2171
2629
  // Inside the gesture dispatch, deliberately: the body-rect setState this
2172
2630
  // triggers is a discrete-priority update, so React commits the moved
@@ -2203,6 +2661,8 @@ export class FlowGraphNode extends Node implements FlowInstance {
2203
2661
  },
2204
2662
  snap,
2205
2663
  );
2664
+ const maps = this._drawsMiniMap();
2665
+ const before = this.rectOf(entry);
2206
2666
  let damage = this._nodeDamage(entry);
2207
2667
  this._resizeTo = { id: gesture.id, rect };
2208
2668
  damage = unionRects(damage, this._nodeDamage(entry));
@@ -2224,6 +2684,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
2224
2684
  }
2225
2685
  this._emitNodes(changes);
2226
2686
  this._claim(inflateRect(damage, CULL_MARGIN), 'content');
2687
+ if (maps) this._claimMiniMapMoves([{ entry, before }], 'content');
2227
2688
  this._emitBodies();
2228
2689
  }
2229
2690
 
@@ -2295,29 +2756,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
2295
2756
  private _connectionPath(
2296
2757
  gesture: Extract<Gesture, { kind: 'connect' }>,
2297
2758
  ): XYPosition[] {
2298
- const v = this._viewport();
2299
- const from = this._toScreen(gesture.from);
2300
- const target = gesture.to ?? gesture.pointer;
2301
- const to = this._toScreen(target);
2302
- const toPosition =
2303
- gesture.to?.position ??
2304
- (gesture.from.position === 'left'
2305
- ? 'right'
2306
- : gesture.from.position === 'right'
2307
- ? 'left'
2308
- : gesture.from.position === 'top'
2309
- ? 'bottom'
2310
- : 'top');
2311
- return edgePath(
2312
- 'bezier',
2313
- { x: from.x, y: from.y, position: gesture.from.position },
2314
- { x: to.x, y: to.y, position: toPosition },
2315
- {
2316
- stepOffset: EDGE_STEP_OFFSET * v.zoom,
2317
- radius: 8 * v.zoom,
2318
- scale: v.zoom,
2319
- },
2320
- );
2759
+ return connectionPath(this._screenViewport(), gesture);
2321
2760
  }
2322
2761
 
2323
2762
  private _validConnection(connection: {
@@ -2330,6 +2769,76 @@ export class FlowGraphNode extends Node implements FlowInstance {
2330
2769
  return check ? check(connection) !== false : true;
2331
2770
  }
2332
2771
 
2772
+ /**
2773
+ * What one step of a box selection changes on screen: a band round each
2774
+ * side of the box that moved, across the whole of both boxes. That covers
2775
+ * the outline where it was and where it is, and the tint where one box
2776
+ * lies over the graph and the other does not; a side that held still —
2777
+ * the two the gesture is anchored by — changed only where a moving side
2778
+ * shortened or lengthened it, which that side's band covers. Claiming the
2779
+ * box round both repainted everything under the selection, every node and
2780
+ * edge in it, on every step: 21 ms of a step on the stress example's
2781
+ * fan-out, which is what made a sweep lag the pointer.
2782
+ *
2783
+ * The bands are cut on the device grid so that none of them overlaps
2784
+ * another: core merges overlapping damage into the box round it, which is
2785
+ * the whole selection again — and a pass over a translucent tint must not
2786
+ * paint it twice. Claimed past this element's own `invalidate`, whose one
2787
+ * job here would be to repaint the minimap and the controls on their
2788
+ * canvases, and a box selection moves nothing on either.
2789
+ *
2790
+ * Under GL the box is the overlay's, drawn every frame: a step is a frame
2791
+ * and not a change to the world, which a claim would have rebuilt.
2792
+ */
2793
+ private _claimSelectStep(from: FlowRect, to: FlowRect): void {
2794
+ if (this._gl) {
2795
+ this._glRequest!();
2796
+ return;
2797
+ }
2798
+ const s = this._scale;
2799
+ const lo = (v: number): number => Math.floor(toDevice(v - SELECT_BAND, s));
2800
+ const hi = (v: number): number => Math.ceil(toDevice(v + SELECT_BAND, s));
2801
+ /** The bands round the sides that moved along one axis, merged where
2802
+ * they meet, in device pixels. */
2803
+ const bands = (
2804
+ a0: number,
2805
+ a1: number,
2806
+ b0: number,
2807
+ b1: number,
2808
+ ): [number, number][] => {
2809
+ const out: [number, number][] = [];
2810
+ if (a0 !== b0) out.push([lo(Math.min(a0, b0)), hi(Math.max(a0, b0))]);
2811
+ if (a1 !== b1) out.push([lo(Math.min(a1, b1)), hi(Math.max(a1, b1))]);
2812
+ out.sort((m, n) => m[0] - n[0]);
2813
+ if (out.length === 2 && out[1][0] <= out[0][1]) {
2814
+ return [[out[0][0], Math.max(out[0][1], out[1][1])]];
2815
+ }
2816
+ return out;
2817
+ };
2818
+ const outer = unionRects(from, to);
2819
+ const x0 = lo(outer.x);
2820
+ const x1 = hi(outer.x + outer.width);
2821
+ const y0 = lo(outer.y);
2822
+ const y1 = hi(outer.y + outer.height);
2823
+ const columns = bands(from.x, from.x + from.width, to.x, to.x + to.width);
2824
+ const rows = bands(from.y, from.y + from.height, to.y, to.y + to.height);
2825
+ const claim = (x: number, y: number, width: number, height: number) => {
2826
+ if (width > 0 && height > 0) {
2827
+ super.invalidate(false, { x, y, width, height }, 'style-state');
2828
+ }
2829
+ };
2830
+ for (const [c0, c1] of columns) claim(c0, y0, c1 - c0, y1 - y0);
2831
+ for (const [r0, r1] of rows) {
2832
+ // across both boxes, less the columns already claimed
2833
+ let x = x0;
2834
+ for (const [c0, c1] of columns) {
2835
+ claim(x, r0, c0 - x, r1 - r0);
2836
+ x = Math.max(x, c1);
2837
+ }
2838
+ claim(x, r0, x1 - x, r1 - r0);
2839
+ }
2840
+ }
2841
+
2333
2842
  private _selectBox(gesture: Extract<Gesture, { kind: 'select' }>): void {
2334
2843
  const a = this._toGraph(gesture.startX, gesture.startY);
2335
2844
  const b = this._toGraph(gesture.x, gesture.y);
@@ -2463,10 +2972,11 @@ export class FlowGraphNode extends Node implements FlowInstance {
2463
2972
  override a11yScene(): A11ySceneItem[] {
2464
2973
  this._sync();
2465
2974
  const pane = this._pane();
2975
+ const sv = screenViewport(this._viewport(), pane);
2466
2976
  const items: A11ySceneItem[] = [];
2467
2977
  for (const entry of this._order) {
2468
2978
  if (entry.node.hidden) continue;
2469
- const rect = this._screenRect(entry);
2979
+ const rect = this._screenRect(entry, sv);
2470
2980
  if (!rectsOverlap(rect, pane)) continue;
2471
2981
  const data = entry.node.data as FlowNodeData | undefined;
2472
2982
  const out = this._edgesByNode.get(entry.node.id) ?? [];
@@ -2646,7 +3156,12 @@ export class FlowGraphNode extends Node implements FlowInstance {
2646
3156
  // changed — a moved node repaints the box it moved through, an edit to
2647
3157
  // anything else on the visual list repaints in full, and handler churn
2648
3158
  // repaints nothing.
2649
- let damage: 'full' | FlowRect | null = null;
3159
+ // Boxes rather than the box around them: the nodes a box selection
3160
+ // takes in one step lie along both of its moving edges, and the box
3161
+ // round an L of nodes is the whole selection again.
3162
+ let damage: 'full' | FlowRect[] = [];
3163
+ let edgesRedrawn = false;
3164
+ this._liftOnly = false;
2650
3165
  if (
2651
3166
  !shallowEqual(nextProps.nodeTypes, before.nodeTypes) ||
2652
3167
  !shallowEqual(nextProps.defaultEdgeOptions, before.defaultEdgeOptions)
@@ -2655,16 +3170,17 @@ export class FlowGraphNode extends Node implements FlowInstance {
2655
3170
  damage = 'full';
2656
3171
  } else {
2657
3172
  if (nextProps.edges !== before.edges) {
2658
- damage = this._applyEdges(this._rawEdges());
3173
+ const edgeDamage = this._applyEdges(this._rawEdges());
3174
+ if (edgeDamage === 'full') damage = 'full';
3175
+ else if (edgeDamage) {
3176
+ damage.push(edgeDamage);
3177
+ edgesRedrawn = true;
3178
+ }
2659
3179
  }
2660
3180
  if (damage !== 'full' && nextProps.nodes !== before.nodes) {
2661
3181
  const nodeDamage = this._applyNodes(this._nodes());
2662
- damage =
2663
- nodeDamage === 'full' || damage === null
2664
- ? nodeDamage
2665
- : nodeDamage === null
2666
- ? damage
2667
- : unionRects(damage, nodeDamage);
3182
+ if (nodeDamage === 'full') damage = 'full';
3183
+ else damage.push(...nodeDamage);
2668
3184
  }
2669
3185
  }
2670
3186
  // `fitView` is a one-shot: turning it on later refits, and it stays off
@@ -2681,17 +3197,34 @@ export class FlowGraphNode extends Node implements FlowInstance {
2681
3197
  }
2682
3198
  }
2683
3199
  }
3200
+ // What moved, restyled or reshaped is the graph's own content: under GL
3201
+ // it is a frame of the surface's, where a claim billed as `props` went
3202
+ // on to a window pass over the 2D pane under it — every step of a drag
3203
+ // an app stores, 1.2 ms of each, for pixels the surface covers.
2684
3204
  if (damage === 'full') this._repaint('props');
2685
- else if (damage) this._claim(damage, 'props');
3205
+ else {
3206
+ // a commit of lifted nodes alone is a frame of the lifted layer
3207
+ const reason = this._liftOnly && !edgesRedrawn ? 'lift' : 'content';
3208
+ for (const box of damage) this._claim(box, reason);
3209
+ }
3210
+ this._liftOnly = false;
2686
3211
  // Core re-reads the scene for aria-prop commits; a `nodes`/`edges`
2687
3212
  // change is invisible to it, so the re-read is asked for by name.
2688
3213
  // Free when no assistive technology is listening.
2689
- if (damage) this.notifyA11ySceneChanged();
3214
+ if (damage === 'full' || damage.length > 0) this.notifyA11ySceneChanged();
2690
3215
  }
2691
3216
 
2692
3217
  override destroySubtree(): void {
3218
+ this._dropZoomShot();
3219
+ this._dropLiftShot();
3220
+ this._glRequest = null;
3221
+ if (this._bodiesRest != null) timers.clearTimeout?.(this._bodiesRest);
3222
+ this._bodiesRest = null;
3223
+ if (this._zoomRest != null) timers.clearTimeout?.(this._zoomRest);
3224
+ this._zoomRest = null;
2693
3225
  this._stopAnimation();
2694
3226
  this._dropGridTile();
3227
+ this._sceneCache.clear();
2695
3228
  super.destroySubtree();
2696
3229
  }
2697
3230
 
@@ -2699,14 +3232,59 @@ export class FlowGraphNode extends Node implements FlowInstance {
2699
3232
  if (this._animTimer != null) return;
2700
3233
  this._animTimer =
2701
3234
  timers.setInterval?.(() => {
2702
- this._dashPhase += ANIMATION_SPEED;
3235
+ // A pan that blits copies the pane's pixels, dashes and all, and a
3236
+ // tick claims the dashes inside the band it copies — which declines
3237
+ // the copy: every frame a tick landed in repainted the whole pane,
3238
+ // a sixth of a pan's frames and all of its stutter. So the dashes
3239
+ // sit the pan out, phase and all, and what the pan copies and what
3240
+ // it draws agree; they march again once it has held still.
3241
+ //
3242
+ // And the view moving at all is what they wait out, not a blit that
3243
+ // happened: a tick declines the blit it lands beside, so a pan
3244
+ // whose frames ran past the wait had its ticks back, each one
3245
+ // cancelling the next blit and repainting the pane — over the
3246
+ // stress example's widgets in a large window, 2 frames a second,
3247
+ // ticks and pan steps taking turns. A step since the last tick, or
3248
+ // one within two ticks, holds the dashes still whatever a frame
3249
+ // costs. Under GL a tick is a uniform and blits nothing, and they
3250
+ // march through a pan as before.
3251
+ //
3252
+ // A 2D tick is a repaint of the box the dashes are in, which over a
3253
+ // dense graph in a large window is most of the pane: 75 ms a tick on
3254
+ // XQuartz, against a timer of 60. So the wait scales with what
3255
+ // frames cost: the view held still for two frames' worth, and one
3256
+ // tick every two frames at most — the dashes slow down before the
3257
+ // thread is theirs, and a pan whose frames come slowly still sees
3258
+ // them hold still.
3259
+ const t = now();
3260
+ const lastTick = this._tickAt;
3261
+ this._tickAt = t;
3262
+ if (t - this._blittedPanAt < ANIMATION_MS * 2) return;
3263
+ let steps = 1;
3264
+ if (!this._gl) {
3265
+ const cost = this._tickCost * 2;
3266
+ const still = Math.max(ANIMATION_MS * 2, cost);
3267
+ if (this._viewMovedAt > lastTick || t - this._viewMovedAt < still) {
3268
+ return;
3269
+ }
3270
+ // a tick under ANIMATION_MS apart, less a millisecond of timer
3271
+ // slop; further while ticks are dear — and then the dashes cover
3272
+ // the ground the skipped ticks would have, so they slow down in
3273
+ // steps and not in speed
3274
+ const since = t - this._marchedAt;
3275
+ if (since < Math.max(ANIMATION_MS, cost) - 1) return;
3276
+ steps = Math.min(4, Math.max(1, Math.round(since / ANIMATION_MS)));
3277
+ }
3278
+ this._marchedAt = t;
3279
+ this._dashPhase += ANIMATION_SPEED * steps;
2703
3280
  // the box the last paint saw animated edges in, not the pane: a
2704
- // marching dash should not cost a full grid repaint per tick
2705
- this.invalidate(
2706
- false,
2707
- this._animBox ? this._device(this._animBox) : this.abs,
2708
- 'animation',
2709
- );
3281
+ // marching dash should not cost a full grid repaint per tick. And
3282
+ // only the part of it the pane shows — an edge on its way out of
3283
+ // the pane takes the box past it, and claimed whole, a tick
3284
+ // repainted everything the window has beside the graph.
3285
+ const box = this._animBox ? this._device(this._animBox) : this.abs;
3286
+ const shown = intersectRects(box, this.contentBox());
3287
+ if (shown) this.invalidate(false, shown, 'animation');
2710
3288
  }, ANIMATION_MS) ?? null;
2711
3289
  }
2712
3290
 
@@ -2719,6 +3297,9 @@ export class FlowGraphNode extends Node implements FlowInstance {
2719
3297
  // --- painting ------------------------------------------------------------
2720
3298
 
2721
3299
  override paint(ctx: Context2D): void {
3300
+ this._frameTick();
3301
+ const paintStart = now();
3302
+
2722
3303
  // What this pass is repainting. The renderer paints each damage rect as
2723
3304
  // its own clipped pass; content outside it survives on the window, so
2724
3305
  // everything we skip here is content the last frame already drew — the
@@ -2745,8 +3326,35 @@ export class FlowGraphNode extends Node implements FlowInstance {
2745
3326
  if (!insideInner) super.paint(ctx);
2746
3327
 
2747
3328
  if (!this._visible()) return;
2748
- const painter = createPainter(ctx, this._textOptions());
3329
+ if (this._gl) {
3330
+ // The surface over this box draws the graph. What the 2D pass still
3331
+ // owes is the part of a paint that is not drawing — the derived graph
3332
+ // brought up to date, the bodies placed — and then the frame itself,
3333
+ // asked of the surface. Every claim this element makes arrives here,
3334
+ // so every change the 2D renderer would repaint is one GL frame.
3335
+ this._sync();
3336
+ this._emitBodies();
3337
+ this._emitPanels();
3338
+ this._glRequest?.();
3339
+ return;
3340
+ }
3341
+ // Mid-gesture, labels come from the layouts they already have, scaled:
3342
+ // shaping every one again at each step of a zoom was half of what a 2D
3343
+ // step cost, at sizes the next step moves off. Only where the context
3344
+ // scales text with its transform — X11's draws glyphs at the size they
3345
+ // were shaped at — and set exactly once the zoom rests (`_restZoom`).
3346
+ const approximateText =
3347
+ this._zoomMoving() &&
3348
+ (ctx as { scalesText?: boolean }).scalesText === true;
3349
+ const painter = createPainter(ctx, {
3350
+ ...this._textOptions(),
3351
+ approximateText,
3352
+ });
2749
3353
  if (!painter) return; // a backend with no path API: geometry only
3354
+ if (approximateText) {
3355
+ this._textApproximated = true;
3356
+ this._restZoom();
3357
+ }
2750
3358
 
2751
3359
  this._sync();
2752
3360
  this._painting = true;
@@ -2808,34 +3416,120 @@ export class FlowGraphNode extends Node implements FlowInstance {
2808
3416
  ),
2809
3417
  ));
2810
3418
  painter.clipRect(x, y, width, height, nearCorner ? radius : 0);
2811
- // The fill and the grid draw only the region this pass repaints — on a
2812
- // full pass that is the pane, and on a drag it is the sliver that moved.
2813
- const region = this._frameClip
2814
- ? intersectRects({ x, y, width, height }, this._frameClip)
2815
- : { x, y, width, height };
2816
- if (region) {
2817
- // The style's own colour if it set one — `super.paint` already filled
2818
- // it, and repeating it costs one rectangle and keeps the two agreeing.
2819
- painter.rect(region.x, region.y, region.width, region.height, 0, {
2820
- fill:
2821
- (this.style.backgroundColor as string | undefined) ??
2822
- palette.background,
2823
- });
2824
- this._paintGrid(painter, palette, region);
3419
+ const started = now();
3420
+ // Not under a drag or a connection: the picture would hold the moving
3421
+ // node, or the line, where they were
3422
+ // …and only for a graph that costs more than a frame to paint live
3423
+ // (`ZOOM_SHOT_WORTH_MS`, `ZOOM_SHOT_DROP_MS`)
3424
+ const zooming = this._zoomMoving() && !this._gesture;
3425
+ const paintMs = zooming ? this._graphPaintMs() : 0;
3426
+ if (zooming && this._zoomShot !== null && paintMs < ZOOM_SHOT_DROP_MS) {
3427
+ this._dropZoomShot();
2825
3428
  }
2826
-
2827
- const animated = this._paintEdges(painter, palette);
2828
- this._paintNodes(painter, palette);
2829
- this._paintConnection(painter, palette);
2830
- this._paintSelectionBox(painter, palette);
2831
- this._paintMiniMap(painter, palette);
2832
- this._paintControls(painter, palette);
3429
+ const shot =
3430
+ zooming && (this._zoomShot !== null || paintMs > ZOOM_SHOT_WORTH_MS)
3431
+ ? this._zoomShotFor(palette, approximateText)
3432
+ : null;
3433
+ // A drag's nodes over pictures of the rest (`_liftShotFor`), in 2D and
3434
+ // not mid-zoom.
3435
+ const lift = shot || approximateText ? null : this._lifted();
3436
+ const liftShot = lift ? this._liftShotFor(palette, lift) : null;
3437
+ if (!lift && this._liftShot) this._dropLiftShot();
3438
+ let scene: FlowScene;
3439
+ if (liftShot && lift) {
3440
+ // the pictures, with the moved nodes' edges between them and the
3441
+ // nodes over both; the pane's furniture over it all
3442
+ const lifted = buildScene(
3443
+ this._sceneInput(palette, { lift: { ids: lift, only: true } }),
3444
+ );
3445
+ scene = buildScene(this._sceneInput(palette, 'overlay'));
3446
+ const built = now();
3447
+ this._compositeLift(ctx, liftShot, liftShot.under, null);
3448
+ paintGraphEdges(painter, lifted);
3449
+ this._compositeLift(ctx, liftShot, liftShot.over, liftShot.cards);
3450
+ paintGraphNodes(painter, lifted);
3451
+ paintFloat(painter, scene);
3452
+ this._reportFrame(built - started, now() - built);
3453
+ } else if (shot) {
3454
+ // the graph and the ground under it as the gesture drew them, scaled;
3455
+ // both live wherever the picture does not reach; and the pane's
3456
+ // furniture over them at the zoom of the moment
3457
+ scene = buildScene(this._sceneInput(palette, 'overlay'));
3458
+ const bands = this._uncovered(shot);
3459
+ const input = bands.length > 0 ? this._sceneInput(palette) : null;
3460
+ const live = input
3461
+ ? bands.map((band) => ({
3462
+ band,
3463
+ scene: buildScene({ ...input, clip: band }),
3464
+ }))
3465
+ : [];
3466
+ const built = now();
3467
+ this._compositeShot(ctx, shot);
3468
+ for (const { band, scene: part } of live) {
3469
+ painter.save();
3470
+ painter.clipRect(band.x, band.y, band.width, band.height, 0);
3471
+ paintGround(painter, part, this._grid);
3472
+ paintGraph(painter, part);
3473
+ painter.restore();
3474
+ }
3475
+ paintFloat(painter, scene);
3476
+ this._textApproximated = true;
3477
+ this._restZoom();
3478
+ this._reportFrame(built - started, now() - built);
3479
+ } else {
3480
+ // Only a pass over the whole pane may change how edges are stroked;
3481
+ // one over part of it follows what the rest was drawn with, and asks
3482
+ // for the whole when the edges on the pane have crossed over
3483
+ // (`EDGE_BATCH`) — so a pan's copy never meets a strip drawn the
3484
+ // other way.
3485
+ const input = this._sceneInput(palette);
3486
+ const whole = this._coversPane(clip);
3487
+ scene = buildScene(
3488
+ whole ? { ...input, batch: undefined, batchWas: this._batch } : input,
3489
+ );
3490
+ if (whole) this._batch = scene.batch;
3491
+ else if (batchesEdges(scene.edgesOnPane, this._batch) !== this._batch) {
3492
+ this._repaint('content');
3493
+ }
3494
+ const built = now();
3495
+ paintScene(painter, scene, this._grid);
3496
+ const done = now();
3497
+ this._reportFrame(built - started, done - built);
3498
+ // a zoom's step, whole and live: what the next one would cost
3499
+ if (this._zoomMoving() && this._wholePane(clip)) {
3500
+ this._noteGraphPaint(done - started);
3501
+ }
3502
+ }
3503
+ // The box the dash ticks invalidate comes off the *drawn* geometry; a
3504
+ // pass that culled every animated edge keeps the one before it, because
3505
+ // the endpoints did not move, or that move's own damage would have
3506
+ // redrawn them here.
3507
+ if (scene.animBox) this._animBox = scene.animBox;
2833
3508
  painter.restore();
2834
3509
  this._painting = false;
2835
3510
  this._frameClip = null;
3511
+ {
3512
+ // What a frame costs, for the dash timer (`_startAnimation`): this
3513
+ // paint, and how long the server took to answer the frame before it
3514
+ // — on XQuartz most of it.
3515
+ const latency = (
3516
+ this.root as { window?: { frameLatency?: number } } | null
3517
+ )?.window?.frameLatency;
3518
+ // A reply can take seconds on a server that is waiting for a
3519
+ // display — a Mac's asleep — and that is when ticks most need to
3520
+ // hold; clamped rather than dropped, so one outlier does not stand
3521
+ // for long in the mean.
3522
+ const cost =
3523
+ now() -
3524
+ paintStart +
3525
+ (typeof latency === 'number' && latency > 0
3526
+ ? Math.min(latency, 2000)
3527
+ : 0);
3528
+ this._tickCost = this._tickCost * 0.7 + cost * 0.3;
3529
+ }
2836
3530
 
2837
3531
  // The timer exists only while something on screen needs it.
2838
- if (animated) this._startAnimation();
3532
+ if (scene.animated) this._startAnimation();
2839
3533
  else this._stopAnimation();
2840
3534
 
2841
3535
  if (!this._sceneAnnounced) {
@@ -2849,163 +3543,1381 @@ export class FlowGraphNode extends Node implements FlowInstance {
2849
3543
  // to put the node bodies it mounts, and it is answered from the geometry
2850
3544
  // this frame just used — the same numbers, never a second derivation.
2851
3545
  this._emitBodies();
3546
+ this._emitPanels();
3547
+ // a pass of the frame that drew the latest zoom step ends here
3548
+ if (this._zoomFrameAt === this._lastFrameAt) this._zoomDrawnAt = now();
3549
+ }
3550
+
3551
+ /**
3552
+ * Everything `buildScene` reads, gathered from the element once.
3553
+ *
3554
+ * The two node lists differ on purpose: `nodes` is the paint order with a
3555
+ * dragged node lifted to the top, and `all` is every node, because the
3556
+ * minimap summarises the graph rather than the pass.
3557
+ */
3558
+ override invalidate(
3559
+ layout?: boolean,
3560
+ damage?: Parameters<Node['invalidate']>[1],
3561
+ reason?: string,
3562
+ ): void {
3563
+ // Whatever changed the graph may have changed the minimap, which
3564
+ // `<Flow>` may be painting on a canvas of its own; a change to this
3565
+ // element's props may have changed the controls too. The controls draw
3566
+ // nothing of the graph, and repainting them with every step of a drag
3567
+ // repainted the minimap with them — each canvas builds the panels its
3568
+ // box reaches, and theirs reached both. Each claims its own box: a
3569
+ // claim with no region is the whole window, which made every pan step
3570
+ // and dash tick a full frame. A dash tick changes neither — the minimap
3571
+ // draws no dashes — and repainting both canvases on every one kept an
3572
+ // idle pane painting them 17 times a second, and put a claim in the
3573
+ // middle of any pan frame it landed in.
3574
+ if (reason !== 'animation') {
3575
+ const canvases =
3576
+ reason === 'props' ? this._panelCanvases : this._miniMapCanvases();
3577
+ for (const canvas of canvases) canvas.invalidate(false, canvas, 'props');
3578
+ }
3579
+ // A dash tick moves the phase, a uniform on the GPU, so it is no change
3580
+ // to the world. A pan or a zoom never gets here under GL.
3581
+ if (reason === 'lift') this._liftVersion++;
3582
+ else if (reason !== 'animation') this._worldVersion++;
3583
+ // …and what a 2D zoom gesture composites is the graph as it was: a
3584
+ // change to it is a picture that no longer is
3585
+ if (reason !== 'animation' && reason !== 'scroll') this._dropZoomShot();
3586
+ // Under GL the surface over this box draws the graph and the 2D pane
3587
+ // under it shows nothing: a claim of its pixels was a window pass over
3588
+ // them, unseen, whose one job was to reach `paint` and ask the surface
3589
+ // for a frame — a BeginDraw, a walk and a commit on every step of a
3590
+ // drag, 2.6 ms of each. The graph's own claims ask for the frame
3591
+ // directly; the frame does what `paint` did (`glFrame`). A layout change
3592
+ // and a change of props — which may move the panels — still go through.
3593
+ if (
3594
+ !layout &&
3595
+ damage != null &&
3596
+ (reason === 'content' ||
3597
+ reason === 'style-state' ||
3598
+ reason === 'animation' ||
3599
+ reason === 'lift') &&
3600
+ this._gl
3601
+ ) {
3602
+ this._glRequest!();
3603
+ return;
3604
+ }
3605
+ // a lift is this element's own bookkeeping; core knows it as content
3606
+ super.invalidate(layout, damage, reason === 'lift' ? 'content' : reason);
3607
+ }
3608
+
3609
+ /** A 2D frame's cost so far, while its flush is still painting passes. */
3610
+ private _frameCost: { sceneMs: number; drawMs: number } | null = null;
3611
+
3612
+ /**
3613
+ * One `onFrame` per window flush, however many damage passes it painted.
3614
+ * The window paints its passes one after another in a single task, so a
3615
+ * microtask queued by the first runs after the last — and reports the
3616
+ * frame whole, which is what a frame rate counts.
3617
+ */
3618
+ private _reportFrame(sceneMs: number, drawMs: number): void {
3619
+ if (!this.props.onFrame) return;
3620
+ if (this._frameCost) {
3621
+ this._frameCost.sceneMs += sceneMs;
3622
+ this._frameCost.drawMs += drawMs;
3623
+ return;
3624
+ }
3625
+ this._frameCost = { sceneMs, drawMs };
3626
+ void Promise.resolve().then(() => {
3627
+ const cost = this._frameCost;
3628
+ this._frameCost = null;
3629
+ if (!cost || this.destroyed) return;
3630
+ this._prop<(stats: FlowFrameStats) => void>('onFrame')?.({
3631
+ renderer: 'retained',
3632
+ sceneMs: cost.sceneMs,
3633
+ packMs: 0,
3634
+ drawMs: cost.drawMs,
3635
+ drawCalls: 0,
3636
+ lines: 0,
3637
+ boxes: 0,
3638
+ triangles: 0,
3639
+ uploadBytes: 0,
3640
+ worldRebuilt: false,
3641
+ gaps: { text: 0, custom: 0 },
3642
+ bodies: this.bodyBudget(),
3643
+ });
3644
+ });
3645
+ }
3646
+
3647
+ private get _gl(): boolean {
3648
+ return this.props.renderer === 'gl' && this._glRequest != null;
3649
+ }
3650
+
3651
+ /** What the GL renderer's label atlas sets strings with: this pane's app,
3652
+ * for its staging surface, and the options its 2D painter measures with
3653
+ * — so a label is cut and placed the same on both renderers. Null where
3654
+ * there is no text engine to ask. */
3655
+ glText(): { app: unknown; options: PainterOptions } | null {
3656
+ const options = this._textOptions();
3657
+ return options.fonts ? { app: this.app, options } : null;
3658
+ }
3659
+
3660
+ /**
3661
+ * The GL surface's hook: how it asks for frames. Set while a surface draws
3662
+ * this pane, cleared when it goes — and until it is set the pane draws
3663
+ * itself, so a surface that is still loading shows the 2D graph rather
3664
+ * than an empty box.
3665
+ */
3666
+ setGlRequest(request: (() => void) | null): void {
3667
+ if (this._glRequest === request) return;
3668
+ this._glRequest = request;
3669
+ // whichever renderer now draws, it draws the whole pane
3670
+ this._repaint('props');
3671
+ }
3672
+
3673
+ /**
3674
+ * One GL frame's worth of scene, in the two layers the renderer keeps
3675
+ * apart (`./gl/renderer.ts`): the **world**, rebuilt only when it changed
3676
+ * since the key the surface last drew, and the **overlay**, every frame.
3677
+ *
3678
+ * The world is built at a pinned origin and culled to an *overscan* — the
3679
+ * view grown by a pane in every direction — so a pan inside it changes
3680
+ * nothing but `offset`. When the view leaves the overscan, or the zoom
3681
+ * moves, the overscan is re-centred and the world rebuilt; at every other
3682
+ * frame of a pan the world comes back `null` and the renderer draws the
3683
+ * buffers already on the GPU.
3684
+ *
3685
+ * A zoom *gesture* is drawn the same way: while the zoom keeps moving,
3686
+ * each step draws the world on the GPU magnified by `zoom` — the view's
3687
+ * zoom over the one it was built at — and the world is rebuilt at the
3688
+ * zoom the gesture stopped at, `GL_ZOOM_REST_MS` after its last step.
3689
+ * Rebuilding every step was the whole of a zoom's cost: a scene built,
3690
+ * packed and uploaded per step, 19 ms of it on a 300-node graph, where a
3691
+ * pan step is a uniform. What a magnified world gets wrong until then is
3692
+ * what a zoom does not scale linearly — a label's raster, the hairlines
3693
+ * with a floor of a pixel, the detail that appears past a zoom — which is
3694
+ * what every map does during a pinch. A single jump — a control's button,
3695
+ * `fitView`, `setViewport` — is not a stream and rebuilds at once, and so
3696
+ * does a step that would magnify one build by more than `GL_ZOOM_SPAN`
3697
+ * or show more of the graph than the overscan holds.
3698
+ *
3699
+ * The key is everything the world is a function of that this element can
3700
+ * name: its version (every repaint but a view change moves it), the
3701
+ * overscan, the zoom it was built at, and the palette and face — the two
3702
+ * a theme change moves, which core announces to the window rather than
3703
+ * to this element.
3704
+ *
3705
+ * Everything a 2D paint does around its drawing happens here too: the
3706
+ * pending fit, the dash timer, the first scene announcement, the bodies.
3707
+ */
3708
+ glFrame(
3709
+ lastKey: string | null,
3710
+ lastLifted: string | null = null,
3711
+ ): {
3712
+ world: FlowScene | null;
3713
+ key: string;
3714
+ lifted: FlowScene | null | undefined;
3715
+ liftedKey: string | null;
3716
+ offset: XYPosition;
3717
+ zoom: number;
3718
+ overlay: FlowScene;
3719
+ phase: number;
3720
+ moving: boolean;
3721
+ } | null {
3722
+ if (!this._visible()) return null;
3723
+ this._frameTick();
3724
+ this._sync();
3725
+ this._painting = true;
3726
+ this._frameClip = null;
3727
+ if (this._fitPending) {
3728
+ this._fitPending = false;
3729
+ this._fit(this._prop<FitViewOptions>('fitViewOptions'));
3730
+ }
3731
+ const v = this._viewport();
3732
+ const pane = this._pane();
3733
+ const palette = this._palette();
3734
+
3735
+ const lift = this._lifted();
3736
+ const keyOf = (): string =>
3737
+ `${this._worldVersion}|${this._worldCullId}|${this._worldZoom}|` +
3738
+ `${this._fontSeen}|${JSON.stringify(palette)}|${this._liftGen}`;
3739
+ let key = keyOf();
3740
+ // The view in the world's own coordinates — graph × the zoom it was
3741
+ // built at, the pan left out: `offset` puts the pan back, and `zoom`
3742
+ // the rest of the zoom.
3743
+ const zoom = v.zoom / this._worldZoom;
3744
+ const view = {
3745
+ x: -v.x / zoom,
3746
+ y: -v.y / zoom,
3747
+ width: pane.width / zoom,
3748
+ height: pane.height / zoom,
3749
+ };
3750
+ const cull = this._worldCull;
3751
+ const inside =
3752
+ cull != null &&
3753
+ view.x >= cull.x &&
3754
+ view.y >= cull.y &&
3755
+ view.x + view.width <= cull.x + cull.width &&
3756
+ view.y + view.height <= cull.y + cull.height;
3757
+ const scaled =
3758
+ inside &&
3759
+ zoom !== 1 &&
3760
+ key === lastKey &&
3761
+ this._zoomStream &&
3762
+ now() - this._zoomAt < GL_ZOOM_REST_MS &&
3763
+ zoom <= GL_ZOOM_SPAN &&
3764
+ zoom >= 1 / GL_ZOOM_SPAN;
3765
+ if (scaled) {
3766
+ this._restZoom();
3767
+ } else if (!inside || zoom !== 1) {
3768
+ this._worldCull = {
3769
+ x: -v.x - pane.width,
3770
+ y: -v.y - pane.height,
3771
+ width: pane.width * 3,
3772
+ height: pane.height * 3,
3773
+ };
3774
+ this._worldZoom = v.zoom;
3775
+ this._worldCullId++;
3776
+ key = keyOf();
3777
+ }
3778
+
3779
+ let world: FlowScene | null = null;
3780
+ if (key !== lastKey) {
3781
+ world = buildScene(
3782
+ this._sceneInput(palette, {
3783
+ world: this._worldCull!,
3784
+ lift: lift ? { ids: lift, only: false } : undefined,
3785
+ }),
3786
+ );
3787
+ this._worldAnimated = world.animated;
3788
+ }
3789
+ // The nodes a drag moves, and their edges, on their own: a step
3790
+ // rebuilds them and not the world (`_liftVersion`).
3791
+ let lifted: FlowScene | null | undefined;
3792
+ const liftedKey = lift ? `${this._liftVersion}|${key}` : null;
3793
+ if (lift && liftedKey !== lastLifted) {
3794
+ lifted = buildScene(
3795
+ this._sceneInput(palette, {
3796
+ world: this._worldCull!,
3797
+ lift: { ids: lift, only: true },
3798
+ }),
3799
+ );
3800
+ this._liftedAnimated = lifted.animated;
3801
+ } else if (!lift && lastLifted != null) {
3802
+ lifted = null;
3803
+ this._liftedAnimated = false;
3804
+ }
3805
+ if (world || lifted !== undefined) {
3806
+ // The dash timer lives as long as an animated edge is drawn; its
3807
+ // ticks move the phase, a uniform.
3808
+ if (this._worldAnimated || this._liftedAnimated) this._startAnimation();
3809
+ else this._stopAnimation();
3810
+ }
3811
+ const overlay = buildScene(this._sceneInput(palette, 'overlay'));
3812
+ this._painting = false;
3813
+
3814
+ if (!this._sceneAnnounced) {
3815
+ this._sceneAnnounced = true;
3816
+ this.notifyA11ySceneChanged();
3817
+ }
3818
+ this._emitBodies();
3819
+ return {
3820
+ world,
3821
+ key,
3822
+ lifted,
3823
+ liftedKey,
3824
+ offset: { x: pane.x + v.x, y: pane.y + v.y },
3825
+ zoom: scaled ? zoom : 1,
3826
+ overlay,
3827
+ // the scene bakes `-phase * zoom` into a marching edge; the GPU is
3828
+ // handed the same number as a uniform
3829
+ phase: -this._dashPhase * v.zoom,
3830
+ moving: this._zoomMoving(),
3831
+ };
3832
+ }
3833
+
3834
+ /** The frame that rebuilds the GL world once a zoom gesture stops — asked
3835
+ * for `GL_ZOOM_REST_MS` after its last step, however many steps the
3836
+ * timer outlived. */
3837
+ private _restZoom(): void {
3838
+ if (this._zoomRest != null) return;
3839
+ const wait = this._zoomAt + GL_ZOOM_REST_MS - now();
3840
+ this._zoomRest = timers.setTimeout?.(
3841
+ () => {
3842
+ this._zoomRest = null;
3843
+ if (now() - this._zoomAt < GL_ZOOM_REST_MS) this._restZoom();
3844
+ else if (this._gl) this._glRequest?.();
3845
+ else if (this._textApproximated) {
3846
+ this._dropZoomShot();
3847
+ // …and in 2D, the labels the gesture drew scaled, set at their
3848
+ // own sizes
3849
+ this._textApproximated = false;
3850
+ this._repaint('content');
3851
+ }
3852
+ },
3853
+ Math.max(0, wait) + 1,
3854
+ );
3855
+ }
3856
+
3857
+ /** A whole paint of the graph took `ms` (`_graphPaints`). */
3858
+ private _noteGraphPaint(ms: number): void {
3859
+ this._graphPaints.push(ms);
3860
+ if (this._graphPaints.length > GRAPH_PAINTS_KEPT) this._graphPaints.shift();
3861
+ }
3862
+
3863
+ /** The median of the graph's last whole paints — the lower of the two
3864
+ * middle ones for an even count — or zero before the first. */
3865
+ private _graphPaintMs(): number {
3866
+ const paints = this._graphPaints;
3867
+ if (paints.length === 0) return 0;
3868
+ const sorted = [...paints].sort((a, b) => a - b);
3869
+ return sorted[(sorted.length - 1) >> 1];
3870
+ }
3871
+
3872
+ /** Whether a pass over `clip` painted the whole pane — what a zoom step
3873
+ * paints, and the only pass whose cost says what one would. */
3874
+ /** Whether a pass (`paintDamage()`, device pixels) repaints every pixel
3875
+ * of the pane, unlike {@link _wholePane}'s nine tenths. */
3876
+ private _coversPane(clip: FlowRect | null): boolean {
3877
+ if (clip === null) return true;
3878
+ const pane = this._pane();
3879
+ const c = this._logical(clip);
3880
+ const slack = 0.5 / this._scale;
3881
+ return (
3882
+ c.x <= pane.x + slack &&
3883
+ c.y <= pane.y + slack &&
3884
+ c.x + c.width >= pane.x + pane.width - slack &&
3885
+ c.y + c.height >= pane.y + pane.height - slack
3886
+ );
3887
+ }
3888
+
3889
+ private _wholePane(clip: FlowRect | null): boolean {
3890
+ if (clip === null) return true;
3891
+ const pane = this._pane();
3892
+ const s = this._scale;
3893
+ return clip.width * clip.height >= 0.9 * pane.width * s * pane.height * s;
3894
+ }
3895
+
3896
+ /**
3897
+ * The graph painted once onto a surface of its own for the zoom gesture
3898
+ * under way — made at the gesture's first step that finds none — or null
3899
+ * where there is no surface to paint on.
3900
+ */
3901
+ private _zoomShotFor(
3902
+ palette: FlowPalette,
3903
+ approximateText: boolean,
3904
+ ): ZoomShot | null {
3905
+ const held = this._zoomShot;
3906
+ if (held) {
3907
+ // Kept while it is sharp enough and covers enough: magnified past
3908
+ // `GL_ZOOM_SPAN` it is soft, and a zoom out uncovers a ring the step
3909
+ // paints live, which past `ZOOM_SHOT_LIVE` of the pane costs what a
3910
+ // new picture does.
3911
+ const k = this._viewport().zoom / held.viewport.zoom;
3912
+ const pane = this._pane();
3913
+ const live =
3914
+ this._uncovered(held).reduce((sum, r) => sum + r.width * r.height, 0) /
3915
+ Math.max(1, pane.width * pane.height);
3916
+ if (k <= GL_ZOOM_SPAN && live <= ZOOM_SHOT_LIVE) return held;
3917
+ this._dropZoomShot();
3918
+ }
3919
+ const ctor = (ntk as unknown as { Surface?: ShotCtor }).Surface;
3920
+ if (typeof ctor !== 'function') return null;
3921
+ const pane = this._pane();
3922
+ const scale = this._scale;
3923
+ const width = Math.max(1, Math.ceil(pane.width * scale));
3924
+ const height = Math.max(1, Math.ceil(pane.height * scale));
3925
+ let surface: ShotSurface;
3926
+ try {
3927
+ surface = new ctor(this.app, { width, height, format: 'argb32' });
3928
+ } catch {
3929
+ return null;
3930
+ }
3931
+ const sctx = surface.getContext('2d') as {
3932
+ translate?(x: number, y: number): void;
3933
+ } | null;
3934
+ // its labels drawn as a step inside the gesture draws them — from the
3935
+ // sizes they have, where the window's context scales text — so the one
3936
+ // paint of the gesture shapes nothing the gesture would not
3937
+ const painter = sctx
3938
+ ? createPainter(sctx, { ...this._textOptions(), approximateText })
3939
+ : null;
3940
+ if (!sctx || !painter || typeof sctx.translate !== 'function') {
3941
+ surface.destroy?.();
3942
+ return null;
3943
+ }
3944
+ // The surface's corner is the pane's, and the whole of the pane is in
3945
+ // it whatever this pass's damage, since every step after composites it.
3946
+ // The ground too: a grid at the fractional pitch a zoom passes through
3947
+ // is thousands of runs, 7 ms of Direct2D a step over the stress
3948
+ // lattice's pane once the graph was a picture. Drawn as runs here —
3949
+ // the tile is a pattern on the window's context, phased in its pixels,
3950
+ // and a zoom's pitch is a fraction of a pixel almost always.
3951
+ sctx.translate(-pane.x * scale, -pane.y * scale);
3952
+ const building = now();
3953
+ const whole = buildScene({ ...this._sceneInput(palette), clip: null });
3954
+ const built = now();
3955
+ paintGround(painter, whole);
3956
+ const grounded = now();
3957
+ paintGraph(painter, whole);
3958
+ // What a step would cost live: the scene and the graph. Not the ground,
3959
+ // which is runs here and a tile on the window — on X11 it made a
3960
+ // picture's paint half as dear again as a step, and kept the picture of
3961
+ // a graph that did not need one.
3962
+ this._noteGraphPaint(built - building + (now() - grounded));
3963
+ this._zoomShot = { surface, viewport: this._viewport(), width, height };
3964
+ return this._zoomShot;
3965
+ }
3966
+
3967
+ /** Where the gesture's picture lands at the viewport of the moment, in
3968
+ * the pane's logical pixels: a world point drawn at `w·z₀ + v₀` is at
3969
+ * `w·z + v`, so the picture is scaled by `z / z₀` about the pane's corner
3970
+ * and moved by `v − v₀·z/z₀`. */
3971
+ private _shotRect(shot: ZoomShot): FlowRect {
3972
+ const v = this._viewport();
3973
+ const v0 = shot.viewport;
3974
+ const k = v.zoom / v0.zoom;
3975
+ const pane = this._pane();
3976
+ const s = this._scale;
3977
+ return {
3978
+ x: pane.x + v.x - v0.x * k,
3979
+ y: pane.y + v.y - v0.y * k,
3980
+ width: (shot.width / s) * k,
3981
+ height: (shot.height / s) * k,
3982
+ };
3983
+ }
3984
+
3985
+ private _compositeShot(ctx: Context2D, shot: ZoomShot): void {
3986
+ const draw = (
3987
+ ctx as unknown as {
3988
+ drawImage?(image: unknown, ...args: number[]): void;
3989
+ }
3990
+ ).drawImage;
3991
+ if (typeof draw !== 'function') return;
3992
+ const to = this._shotRect(shot);
3993
+ const s = this._scale;
3994
+ draw.call(
3995
+ ctx,
3996
+ shot.surface,
3997
+ 0,
3998
+ 0,
3999
+ shot.width,
4000
+ shot.height,
4001
+ to.x * s,
4002
+ to.y * s,
4003
+ to.width * s,
4004
+ to.height * s,
4005
+ );
4006
+ }
4007
+
4008
+ /**
4009
+ * The pane this pass reaches that the gesture's picture does not: nothing
4010
+ * while zooming in about a point in the pane, a ring round it zooming out,
4011
+ * a side after a pan. Up to four bands — above, below, and either side
4012
+ * between them — each reaching a pixel under the picture's edge, which
4013
+ * its filtering leaves half there.
4014
+ */
4015
+ private _uncovered(shot: ZoomShot): FlowRect[] {
4016
+ const pane = this._frameClip
4017
+ ? intersectRects(this._pane(), this._frameClip)
4018
+ : this._pane();
4019
+ if (!pane) return [];
4020
+ const inner = inflateRect(this._shotRect(shot), -1);
4021
+ const x0 = Math.max(pane.x, inner.x);
4022
+ const y0 = Math.max(pane.y, inner.y);
4023
+ const x1 = Math.min(pane.x + pane.width, inner.x + inner.width);
4024
+ const y1 = Math.min(pane.y + pane.height, inner.y + inner.height);
4025
+ if (x1 <= x0 || y1 <= y0) return [pane];
4026
+ const right = pane.x + pane.width;
4027
+ const bottom = pane.y + pane.height;
4028
+ const bands: FlowRect[] = [];
4029
+ if (y0 > pane.y) {
4030
+ bands.push({
4031
+ x: pane.x,
4032
+ y: pane.y,
4033
+ width: pane.width,
4034
+ height: y0 - pane.y,
4035
+ });
4036
+ }
4037
+ if (bottom > y1) {
4038
+ bands.push({ x: pane.x, y: y1, width: pane.width, height: bottom - y1 });
4039
+ }
4040
+ if (x0 > pane.x) {
4041
+ bands.push({ x: pane.x, y: y0, width: x0 - pane.x, height: y1 - y0 });
4042
+ }
4043
+ if (right > x1) {
4044
+ bands.push({ x: x1, y: y0, width: right - x1, height: y1 - y0 });
4045
+ }
4046
+ return bands;
4047
+ }
4048
+
4049
+ private _dropZoomShot(): void {
4050
+ this._zoomShot?.surface.destroy?.();
4051
+ this._zoomShot = null;
4052
+ }
4053
+
4054
+ /**
4055
+ * The pictures a 2D drag copies from — made on the first pass of the
4056
+ * gesture that has its nodes lifted, and kept while the graph under them
4057
+ * and the view hold still. A drag step repainted the box round the moved
4058
+ * node and every edge on it, and an edge is as long as it is: the stress
4059
+ * lattice sends its last rows' edges back to its first nodes, so dragging
4060
+ * one repainted the window a step, every card and edge in it, at 46-57
4061
+ * fps on Cocoa. Cutting the damage finer did not help — core merges a
4062
+ * frame's rects down to four, and a long diagonal's pieces merge back to
4063
+ * most of the pane. Copying what did not move is what does.
4064
+ *
4065
+ * Null where there is no offscreen surface, and for a gesture whose view
4066
+ * moved while it held pictures: it paints live from then on, rather than
4067
+ * paint the graph twice a step.
4068
+ */
4069
+ private _liftShotFor(
4070
+ palette: FlowPalette,
4071
+ lift: ReadonlySet<string>,
4072
+ ): LiftShot | null {
4073
+ const gesture = this._liftFor;
4074
+ if (!gesture || this._liftShotRefused === gesture) return null;
4075
+ const v = this._viewport();
4076
+ const pane = this._pane();
4077
+ const scale = this._scale;
4078
+ const width = Math.max(1, Math.ceil(pane.width * scale));
4079
+ const height = Math.max(1, Math.ceil(pane.height * scale));
4080
+ const held = this._liftShot;
4081
+ if (held) {
4082
+ const still =
4083
+ held.viewport.x === v.x &&
4084
+ held.viewport.y === v.y &&
4085
+ held.viewport.zoom === v.zoom;
4086
+ if (
4087
+ held.gesture === gesture &&
4088
+ still &&
4089
+ held.version === this._worldVersion &&
4090
+ held.width === width &&
4091
+ held.height === height
4092
+ ) {
4093
+ return held;
4094
+ }
4095
+ this._dropLiftShot();
4096
+ if (held.gesture === gesture && !still) {
4097
+ this._liftShotRefused = gesture;
4098
+ return null;
4099
+ }
4100
+ }
4101
+ const ctor = (ntk as unknown as { Surface?: ShotCtor }).Surface;
4102
+ if (typeof ctor !== 'function') return null;
4103
+ const make = (): {
4104
+ surface: ShotSurface;
4105
+ painter: FlowPainter;
4106
+ } | null => {
4107
+ let surface: ShotSurface;
4108
+ try {
4109
+ surface = new ctor(this.app, { width, height, format: 'argb32' });
4110
+ } catch {
4111
+ return null;
4112
+ }
4113
+ const sctx = surface.getContext('2d') as {
4114
+ translate?(x: number, y: number): void;
4115
+ clearRect?(x: number, y: number, w: number, h: number): void;
4116
+ } | null;
4117
+ const painter = sctx ? createPainter(sctx, this._textOptions()) : null;
4118
+ if (!sctx || !painter || typeof sctx.translate !== 'function') {
4119
+ surface.destroy?.();
4120
+ return null;
4121
+ }
4122
+ // a clear ground: the cards' picture goes over the edges
4123
+ sctx.clearRect?.(0, 0, width, height);
4124
+ sctx.translate(-pane.x * scale, -pane.y * scale);
4125
+ return { surface, painter };
4126
+ };
4127
+ const under = make();
4128
+ const over = under ? make() : null;
4129
+ if (!under || !over) {
4130
+ under?.surface.destroy?.();
4131
+ return null;
4132
+ }
4133
+ const rest = buildScene({
4134
+ ...this._sceneInput(palette, { lift: { ids: lift, only: false } }),
4135
+ clip: null,
4136
+ });
4137
+ paintGround(under.painter, rest);
4138
+ paintGraphEdges(under.painter, rest);
4139
+ paintGraphNodes(over.painter, rest);
4140
+ this._liftShot = {
4141
+ under: under.surface,
4142
+ over: over.surface,
4143
+ cards: rest.nodes.map((item) => inflateRect(item.rect, CULL_MARGIN)),
4144
+ gesture,
4145
+ version: this._worldVersion,
4146
+ viewport: { ...v },
4147
+ width,
4148
+ height,
4149
+ };
4150
+ return this._liftShot;
4151
+ }
4152
+
4153
+ /**
4154
+ * One of a drag's pictures, copied into this pass's rect of the pane —
4155
+ * the whole pane on a full pass. The ground's picture is opaque and is
4156
+ * *copied*, which the server does as a copy rather than a blend (`copy`
4157
+ * is XRender's `Src`, and a memcpy on Cocoa); the cards' picture is
4158
+ * blended in only where a card is (`only`). On XQuartz the two blends of
4159
+ * the step's box — as big as the moved node's longest edge — were most
4160
+ * of what a drag cost the server.
4161
+ */
4162
+ private _compositeLift(
4163
+ ctx: Context2D,
4164
+ shot: LiftShot,
4165
+ surface: ShotSurface,
4166
+ only: FlowRect[] | null,
4167
+ ): void {
4168
+ const c = ctx as unknown as {
4169
+ drawImage?(image: unknown, ...args: number[]): void;
4170
+ globalCompositeOperation?: string;
4171
+ };
4172
+ if (typeof c.drawImage !== 'function') return;
4173
+ const pane = this._pane();
4174
+ const rect = this._frameClip ? intersectRects(pane, this._frameClip) : pane;
4175
+ if (!rect) return;
4176
+ if (only) {
4177
+ for (const card of only) {
4178
+ const part = intersectRects(rect, card);
4179
+ if (part) this._copyShotRect(c, shot, surface, part);
4180
+ }
4181
+ return;
4182
+ }
4183
+ const before = c.globalCompositeOperation;
4184
+ c.globalCompositeOperation = 'copy';
4185
+ try {
4186
+ this._copyShotRect(c, shot, surface, rect);
4187
+ } finally {
4188
+ c.globalCompositeOperation = before ?? 'source-over';
4189
+ }
4190
+ }
4191
+
4192
+ /** A rect of the pane, logical pixels, drawn from the same rect of a
4193
+ * picture of it — on whole device pixels, so nothing is resampled. */
4194
+ private _copyShotRect(
4195
+ ctx: { drawImage?(image: unknown, ...args: number[]): void },
4196
+ shot: LiftShot,
4197
+ surface: ShotSurface,
4198
+ rect: FlowRect,
4199
+ ): void {
4200
+ const pane = this._pane();
4201
+ const s = this._scale;
4202
+ const ox = pane.x * s;
4203
+ const oy = pane.y * s;
4204
+ const x0 = Math.max(0, Math.floor(rect.x * s - ox));
4205
+ const y0 = Math.max(0, Math.floor(rect.y * s - oy));
4206
+ const x1 = Math.min(shot.width, Math.ceil((rect.x + rect.width) * s - ox));
4207
+ const y1 = Math.min(
4208
+ shot.height,
4209
+ Math.ceil((rect.y + rect.height) * s - oy),
4210
+ );
4211
+ if (x1 <= x0 || y1 <= y0) return;
4212
+ const w = x1 - x0;
4213
+ const h = y1 - y0;
4214
+ ctx.drawImage!(surface, x0, y0, w, h, ox + x0, oy + y0, w, h);
4215
+ }
4216
+
4217
+ private _dropLiftShot(): void {
4218
+ this._liftShot?.under.destroy?.();
4219
+ this._liftShot?.over.destroy?.();
4220
+ this._liftShot = null;
4221
+ }
4222
+
4223
+ /** Whether a zoom gesture is moving: a stream of zoom steps, the last of
4224
+ * them under `GL_ZOOM_REST_MS` ago. A single step — a button, `fitView`,
4225
+ * an app's `setViewport` — is not one. */
4226
+ private _zoomMoving(): boolean {
4227
+ return this._zoomStream && now() - this._zoomAt < GL_ZOOM_REST_MS;
4228
+ }
4229
+
4230
+ /** Whether this pass's damage reaches the minimap's corner at all —
4231
+ * answered from the panel's box alone, without resolving the map. */
4232
+ private _miniMapReached(): boolean {
4233
+ const corner = this._miniMapCorner();
4234
+ if (!corner) return false;
4235
+ const clip = this._frameClip;
4236
+ return !clip || rectsOverlap(corner, clip);
4237
+ }
4238
+
4239
+ /** The minimap's panel, in logical window pixels, or null without one. */
4240
+ private _miniMapCorner(): FlowRect | null {
4241
+ const options = this._miniMapOptions();
4242
+ if (!options) return null;
4243
+ return this._corner(
4244
+ options.position,
4245
+ options.width ?? MINIMAP_W,
4246
+ options.height ?? MINIMAP_H,
4247
+ 'bottom-right',
4248
+ );
4249
+ }
4250
+
4251
+ /**
4252
+ * Everything `buildScene` reads, for one of three uses:
4253
+ *
4254
+ * - `'all'` — the 2D renderer's pass: the pane as it is, culled to this
4255
+ * pass's damage.
4256
+ * - `{ world }` — the GL graph: built at a **pinned origin** (the viewport's
4257
+ * translation and the pane's origin both left out, so a pan does not
4258
+ * change a vertex) and culled to the overscan `world` rather than the
4259
+ * pane, with none of the pane's furniture.
4260
+ * - `'overlay'` — the GL furniture: the pane's background, grid, selection,
4261
+ * minimap and controls, and no graph at all.
4262
+ */
4263
+ /**
4264
+ * The order nodes are drawn in, bottom to top: `zIndex`, then selection,
4265
+ * then declaration (`_order`), with a node being dragged lifted over all
4266
+ * of them. The one order both halves of a node follow — its card in the
4267
+ * scene, its mounted body in `_emitBodies` — so a body is stacked over
4268
+ * the bodies of the cards under its own, and under the ones above.
4269
+ */
4270
+ private _paintOrder(): NodeEntry[] {
4271
+ const dragging = this._dragTo;
4272
+ if (!dragging || dragging.size === 0) return this._order;
4273
+ const order: NodeEntry[] = [];
4274
+ const lifted: NodeEntry[] = [];
4275
+ for (const entry of this._order) {
4276
+ if (dragging.has(entry.node.id)) lifted.push(entry);
4277
+ else order.push(entry);
4278
+ }
4279
+ return order.concat(lifted);
4280
+ }
4281
+
4282
+ private _sceneInput(
4283
+ palette: FlowPalette,
4284
+ layer:
4285
+ | 'all'
4286
+ | 'overlay'
4287
+ | 'card'
4288
+ | {
4289
+ /** The GL world's overscan; left out, the pane as a 2D pass sees
4290
+ * it. */
4291
+ world?: FlowRect;
4292
+ /** Without the lifted nodes and their edges, or those alone
4293
+ * (`only`). */
4294
+ lift?: { ids: ReadonlySet<string>; only: boolean };
4295
+ } = 'all',
4296
+ /** The panels even while `<Flow>` paints them itself — for that paint. */
4297
+ panels = false,
4298
+ ): SceneInput {
4299
+ // One card, for `paintCard`, which hands in the node itself: nothing
4300
+ // about the rest of the graph is read, where the whole input — every
4301
+ // node's source twice over, the minimap's walk for its bounds — was most
4302
+ // of what a card cost to paint, and a zoom repaints every one of them.
4303
+ if (layer === 'card') {
4304
+ const gesture = this._gesture;
4305
+ return {
4306
+ viewport: this._viewport(),
4307
+ pane: this._pane(),
4308
+ clip: null,
4309
+ palette,
4310
+ background: normalizeBackground(undefined),
4311
+ nodes: [],
4312
+ all: [],
4313
+ edges: [],
4314
+ dashPhase: this._dashPhase,
4315
+ hover: this._hover,
4316
+ connection: gesture?.kind === 'connect' ? gesture : null,
4317
+ selection: null,
4318
+ miniMap: null,
4319
+ controls: [],
4320
+ scale: this._scale,
4321
+ measure: this._measureBox,
4322
+ cache: this._sceneCache,
4323
+ batch: this._batch,
4324
+ };
4325
+ }
4326
+ const order = this._paintOrder();
4327
+ const gesture = this._gesture;
4328
+ // The panel cull comes before `_miniMap()`, which walks every node to
4329
+ // find the graph's bounds — during a drag that walk per pass would cost
4330
+ // more than the panel it skips. The dot for a mid-drag node goes stale
4331
+ // until the release repaints in full; that is the trade, and it is
4332
+ // deliberate.
4333
+ const world = typeof layer === 'object' ? (layer.world ?? null) : null;
4334
+ const lift = typeof layer === 'object' ? layer.lift : undefined;
4335
+ const overlay = layer === 'overlay';
4336
+ // the furniture is the overlay's: a lifted layer or the graph under it
4337
+ // has none of its own
4338
+ const map =
4339
+ !world && !lift && this._miniMapReached() ? this._miniMap() : null;
4340
+ // the fit the minimap on screen is painted with, from this pass on
4341
+ if (map && !panels && this._painting && this._panelCanvases.length === 0) {
4342
+ this._miniMapShown = map;
4343
+ }
4344
+ const viewport = this._viewport();
4345
+ const pane = this._pane();
4346
+ // An edge is the lifted layer's when either end is lifted.
4347
+ const edges = lift
4348
+ ? this._edges.filter(
4349
+ (e) =>
4350
+ (lift.ids.has(e.source) || lift.ids.has(e.target)) === lift.only,
4351
+ )
4352
+ : this._edges;
4353
+ let all: SceneNodeSource[];
4354
+ if (lift?.only) {
4355
+ // the lifted nodes and the far ends of their edges: all the lifted
4356
+ // layer routes to, where every node's source was most of a step
4357
+ const ids = new Set(lift.ids);
4358
+ for (const e of edges) {
4359
+ ids.add(e.source);
4360
+ ids.add(e.target);
4361
+ }
4362
+ all = [];
4363
+ for (const id of ids) {
4364
+ const entry = this._byId.get(id);
4365
+ if (entry) all.push(this._sourceOf(entry));
4366
+ }
4367
+ } else if (overlay && !map) {
4368
+ all = [];
4369
+ } else if (layer === 'all' && this._painting) {
4370
+ all = this._frameLists(order).all;
4371
+ } else {
4372
+ all = this._entries.map((entry) => this._sourceOf(entry));
4373
+ }
4374
+ return {
4375
+ viewport: world ? { x: 0, y: 0, zoom: viewport.zoom } : viewport,
4376
+ pane: world
4377
+ ? { x: 0, y: 0, width: pane.width, height: pane.height }
4378
+ : pane,
4379
+ clip: world || overlay ? null : this._frameClip,
4380
+ cull: world,
4381
+ palette,
4382
+ paneBackground: this.style.backgroundColor as string | undefined,
4383
+ background: normalizeBackground(
4384
+ this.props.background as
4385
+ BackgroundOptions | string | boolean | undefined,
4386
+ ),
4387
+ // a card `<Flow>` paints in the bodies' layer is not painted twice
4388
+ nodes: overlay
4389
+ ? []
4390
+ : layer === 'all' && this._painting
4391
+ ? this._frameLists(order).nodes
4392
+ : order
4393
+ .filter(
4394
+ (entry) =>
4395
+ !this._cardInLayer(entry.node.id) &&
4396
+ (!lift || lift.ids.has(entry.node.id) === lift.only),
4397
+ )
4398
+ .map((entry) => this._sourceOf(entry)),
4399
+ all,
4400
+ edges: overlay ? [] : edges,
4401
+ dashPhase: this._dashPhase,
4402
+ hover: this._hover,
4403
+ connection: !overlay && gesture?.kind === 'connect' ? gesture : null,
4404
+ selection:
4405
+ !world && gesture?.kind === 'select' ? selectBoxRect(gesture) : null,
4406
+ miniMap:
4407
+ map && (panels || this._panelCanvases.length === 0)
4408
+ ? {
4409
+ panel: map.panel,
4410
+ bounds: map.bounds,
4411
+ scale: map.scale,
4412
+ nodeColor: map.options.nodeColor,
4413
+ maskColor: map.options.maskColor,
4414
+ // the pane's own pass: the part of the minimap it repaints
4415
+ reach: panels ? null : this._frameClip,
4416
+ }
4417
+ : null,
4418
+ controls:
4419
+ world || lift || (!panels && this._panelCanvases.length > 0)
4420
+ ? []
4421
+ : this._controlButtons(),
4422
+ scale: this._scale,
4423
+ measure: this._measureBox,
4424
+ cache: this._sceneCache,
4425
+ batch: this._batch,
4426
+ };
4427
+ }
4428
+
4429
+ /**
4430
+ * Where every mounted node body belongs, and where the graph's origin is.
4431
+ *
4432
+ * The two are sent apart because they change apart. A body's rect is
4433
+ * relative to the **graph's origin on screen** — its position times the
4434
+ * zoom — which a pan does not move; the origin is the viewport's
4435
+ * translation, which is all a pan moves. So a pan re-sends the *same*
4436
+ * bodies array with a new origin, `<Flow>` moves the one box they are
4437
+ * laid out in, and React re-renders none of them: the per-step commit of
4438
+ * every mounted body's position is what held a pan over 48 of them to 57
4439
+ * frames a second. A drag, a zoom, a selection or a body entering the pane
4440
+ * sends a new array, as before.
4441
+ */
4442
+ private _emitBodies(): void {
4443
+ const notify =
4444
+ this._prop<
4445
+ (
4446
+ bodies: readonly NodeBodyRect[],
4447
+ sync: boolean,
4448
+ origin: XYPosition,
4449
+ held: boolean,
4450
+ ) => void
4451
+ >('onNodeBodies');
4452
+ if (!notify) return;
4453
+ const v = this._viewport();
4454
+ if (this._holdBodies(v.zoom)) {
4455
+ if (!this._bodiesHeld) {
4456
+ // The 2D pane draws the cards again from this frame, not from the
4457
+ // commit that hides the bodies. The GL world never stopped drawing
4458
+ // them (`_cardInLayer`).
4459
+ this._bodiesHeld = true;
4460
+ notify(
4461
+ this._bodies,
4462
+ this._gestureSync || this._handleSync,
4463
+ this._bodiesOrigin,
4464
+ true,
4465
+ );
4466
+ }
4467
+ return;
4468
+ }
4469
+ const pane = this._pane();
4470
+ // Exact, and every rect below relative to it and free of the pan: a
4471
+ // card's offset from the graph's origin is its graph position times the
4472
+ // zoom, so a pan re-sends the same array and moves only the origin.
4473
+ // Nothing here is rounded — `<Flow>` puts the boxes on the device-pixel
4474
+ // grid, once. Rounded here as well, to logical pixels against a rounded
4475
+ // origin, a card's offset flipped by a pixel as the pan's fraction
4476
+ // cycled against the device grid `_screenRect` snaps to: the array was
4477
+ // new on most steps of a pan, and at 1.25× the layer changed size with
4478
+ // it — every body repainted, where core would have moved them.
4479
+ const origin = { x: v.x, y: v.y };
4480
+ const sv = screenViewport(v, pane);
4481
+ const was = this._bodies;
4482
+ const bodies: NodeBodyRect[] = [];
4483
+ let changed = false;
4484
+ for (const entry of this._paintOrder()) {
4485
+ if (entry.node.hidden || !this._mounted(entry)) continue;
4486
+ if (!rectsOverlap(this._screenRect(entry, sv), pane)) continue;
4487
+ const graph = this.rectOf(entry);
4488
+ const x = graph.x * v.zoom;
4489
+ const y = graph.y * v.zoom;
4490
+ const cardWidth = graph.width * v.zoom;
4491
+ const cardHeight = graph.height * v.zoom;
4492
+ const header = this._headerHeight(entry) * v.zoom;
4493
+ const inset = NODE_BODY_INSET * v.zoom;
4494
+ const width = cardWidth - inset * 2;
4495
+ const height = cardHeight - header - inset;
4496
+ if (width <= 1 || height <= 1) continue;
4497
+ const id = entry.node.id;
4498
+ const hovered = this._hover.nodeId === id;
4499
+ const selected = entry.node.selected ?? false;
4500
+ // The zoom counts as well as the rect it produced: it is the body's
4501
+ // subtree scale, and two zooms a hair apart can leave a box where it
4502
+ // was while the text inside it should have moved.
4503
+ const old = this._bodiesById.get(id);
4504
+ const body =
4505
+ old &&
4506
+ old.zoom === v.zoom &&
4507
+ old.selected === selected &&
4508
+ old.hovered === hovered &&
4509
+ old.card.x === x &&
4510
+ old.card.y === y &&
4511
+ old.card.width === cardWidth &&
4512
+ old.card.height === cardHeight &&
4513
+ old.height === height
4514
+ ? old
4515
+ : {
4516
+ id,
4517
+ card: { x, y, width: cardWidth, height: cardHeight },
4518
+ hovered,
4519
+ // logical, and relative to the graph's origin on screen — the
4520
+ // box they are laid out in sits at `origin` in the pane
4521
+ x: x + inset,
4522
+ y: y + header,
4523
+ width,
4524
+ height,
4525
+ zoom: v.zoom,
4526
+ selected,
4527
+ };
4528
+ if (body !== was[bodies.length]) changed = true;
4529
+ bodies.push(body);
4530
+ }
4531
+ if (bodies.length !== was.length) changed = true;
4532
+ // An origin with no bodies laid out at it is nobody's news: a pan over a
4533
+ // graph whose node types mount bodies, with none on screen, sent one per
4534
+ // step, and `<Flow>` re-rendered for it — its panels' canvases with it.
4535
+ // The origin is still kept, and goes out with the first body to arrive.
4536
+ const moved =
4537
+ was.length > 0 &&
4538
+ (origin.x !== this._bodiesOrigin.x || origin.y !== this._bodiesOrigin.y);
4539
+ const released = this._bodiesHeld;
4540
+ if (!changed && !moved && !released) return;
4541
+ if (changed) {
4542
+ this._bodies = bodies;
4543
+ this._bodiesById = new Map(bodies.map((body) => [body.id, body]));
4544
+ }
4545
+ this._bodiesOrigin = origin;
4546
+ this._bodiesHeld = false;
4547
+ notify(this._bodies, this._gestureSync || this._handleSync, origin, false);
4548
+ }
4549
+
4550
+ /**
4551
+ * Whether mounted bodies sit this zoom out.
4552
+ *
4553
+ * A zoom changes every body's scale, and a scale is not a transform: each
4554
+ * body is styled, laid out, its text shaped and its pixels painted again,
4555
+ * at the new size, per step. That is about a millisecond a body on this
4556
+ * class of machine — 42 bodies held a zoom to 15 frames a second where
4557
+ * the graph alone costs 13 ms a step. So each step of a zoom *gesture*
4558
+ * predicts what re-scaling the bodies on screen would add, and when that
4559
+ * is over `BODY_BUDGET_MS` they sit the gesture out: still mounted — no
4560
+ * state is lost — but hidden (`display: 'none'`) and untouched, while the
4561
+ * cards, their labels and the edges carry it. Once the zoom has held still
4562
+ * for
4563
+ * `BODY_ZOOM_REST_MS` they come back, laid out once at the new scale. A
4564
+ * handful of bodies fits the budget and zooms live.
4565
+ *
4566
+ * The prediction is bodies × what one costs, measured from the pane's own
4567
+ * frames (`_frameTick`) — so a slower machine holds sooner. Once held, a
4568
+ * gesture stays held until it rests: bodies blinking in and out as the
4569
+ * count crosses the line would be worse than either.
4570
+ *
4571
+ * Only a stream counts: a gesture's steps — the wheel, a pinch, the keys
4572
+ * (`_gestureSync`) — or an animation's, `setViewport` a frame at a time,
4573
+ * each zoom under `GL_ZOOM_REST_MS` after the one before (`_zoomStream`,
4574
+ * the rule the zoom's picture and its scaled labels already follow). A
4575
+ * single programmatic jump — `fitView`, `setViewport`, a control's button
4576
+ * — applies at once, so an app that sets the viewport and reads the result
4577
+ * sees it; the pane never animates a viewport of its own. An animation
4578
+ * that stepped every body at every frame held a zoom over the stress
4579
+ * example's charts to 10 frames a second.
4580
+ */
4581
+ private _holdBodies(zoom: number): boolean {
4582
+ if (zoom !== this._seenZoom) {
4583
+ const first = Number.isNaN(this._seenZoom);
4584
+ this._seenZoom = zoom;
4585
+ if (
4586
+ first ||
4587
+ (!this._gestureSync && !this._zoomStream && !this._bodiesHeld)
4588
+ ) {
4589
+ return false;
4590
+ }
4591
+ const bodies = this._bodies.length;
4592
+ if (!this._bodiesHeld && bodies * this._bodyStepMs <= this._budgetMs()) {
4593
+ this._zoomStep = { live: true, bodies };
4594
+ return false;
4595
+ }
4596
+ this._zoomStep = { live: false, bodies: 0 };
4597
+ this._bodiesAt = now();
4598
+ this._restBodies();
4599
+ return true;
4600
+ }
4601
+ return this._bodiesHeld && this._bodiesRest != null;
4602
+ }
4603
+
4604
+ /** Bring the held bodies back once the zoom has held still for
4605
+ * `BODY_ZOOM_REST_MS` — judged on the pane's clock, like the zoom's own
4606
+ * rest (`_restZoom`): a timer that comes due while steps are still
4607
+ * arriving waits out what is left of the rest instead. */
4608
+ private _restBodies(): void {
4609
+ if (this._bodiesRest != null) return;
4610
+ const wait = this._restFrom() + BODY_ZOOM_REST_MS - now();
4611
+ this._bodiesRest = timers.setTimeout?.(
4612
+ () => {
4613
+ this._bodiesRest = null;
4614
+ if (now() - this._restFrom() < BODY_ZOOM_REST_MS) this._restBodies();
4615
+ else this._emitBodies();
4616
+ },
4617
+ Math.max(0, wait) + 1,
4618
+ );
4619
+ }
4620
+
4621
+ /**
4622
+ * What the bodies' rest is counted from: the last step of the zoom, or
4623
+ * the frame that drew it where that came later. A step the window has not
4624
+ * drawn yet is a zoom still moving — the frame is late, not the gesture
4625
+ * over — and counted from the step alone, a frame a server took half a
4626
+ * second to answer brought the bodies back between two steps of one
4627
+ * animation, whose next frame re-scaled all of them and was late in turn.
4628
+ * Past `BODY_REST_CAP_MS` without that frame they come back anyway: a
4629
+ * surface that stopped drawing must not keep them out for good.
4630
+ */
4631
+ private _restFrom(): number {
4632
+ const drawn = this._zoomDrawnAt;
4633
+ if (drawn !== -Infinity) return Math.max(this._bodiesAt, drawn);
4634
+ return now() - this._bodiesAt < BODY_REST_CAP_MS ? now() : this._bodiesAt;
4635
+ }
4636
+
4637
+ /**
4638
+ * Paint one mounted node's card — fill, border, header, handles, grips —
4639
+ * into `ctx`, a `<canvas>` in the bodies' layer (`<Flow>`), exactly as the
4640
+ * scene paints it. `ctx` is at the canvas's origin, `abs` its place in
4641
+ * the window in device pixels.
4642
+ *
4643
+ * Why the card is painted twice: the bodies share one layer over every
4644
+ * card — over the GL surface, or beside the 2D pane — so a body could not
4645
+ * go under a card that was over its own. It painted over that card's
4646
+ * header, border and handles: a selected node's outline hidden behind the
4647
+ * body of the node under it. A card painted in the bodies' layer, just
4648
+ * under its own body, is stacked with it in the cards' paint order, and
4649
+ * covers the card the graph drew for it.
4650
+ */
4651
+ paintCard(id: string, ctx: unknown, abs: XYPosition): void {
4652
+ const entry = this._byId.get(id);
4653
+ if (!entry) return;
4654
+ const painter = createPainter(ctx, this._textOptions());
4655
+ if (!painter) return;
4656
+ const palette = this._palette();
4657
+ const input = this._sceneInput(palette, 'card');
4658
+ const [item] = buildNodeItems({
4659
+ ...input,
4660
+ nodes: [this._source(entry)],
4661
+ clip: null,
4662
+ cull: undefined,
4663
+ });
4664
+ if (!item) return;
4665
+ const c = ctx as {
4666
+ save(): void;
4667
+ restore(): void;
4668
+ translate(x: number, y: number): void;
4669
+ };
4670
+ c.save();
4671
+ try {
4672
+ c.translate(-abs.x, -abs.y);
4673
+ paintNodeItem(painter, item, { viewport: input.viewport, palette });
4674
+ } finally {
4675
+ c.restore();
4676
+ }
4677
+ }
4678
+
4679
+ /**
4680
+ * Whether a card is the bodies' layer's to draw and not the pane's: on
4681
+ * the 2D renderer, shown there and the bodies not held out of a zoom.
4682
+ *
4683
+ * **Never under GL.** The layer and the GL frame reach the screen by two
4684
+ * paths — the layer is the surface's overlay, painted and presented with
4685
+ * the window's frame, and a GL frame is swapped on the surface's own
4686
+ * turn — and this element cannot order them. So a card left to the layer
4687
+ * was, on some path, a card nobody drew: edges crossing the empty place
4688
+ * where it should be. The first paint of a scene of charts did that until
4689
+ * the layer had painted; a GL frame landing between a zoom's hold and the
4690
+ * commit that hid the bodies did it; and on macOS a wheel notch did it,
4691
+ * painting the overlay inside its own dispatch 20–25 ms before the GL
4692
+ * frame it asked for — a frame of bare edges at the start of every zoom
4693
+ * that held the bodies. Each was fixed by ordering one more path.
4694
+ *
4695
+ * The world draws every card instead, mounted or not, and the layer only
4696
+ * ever adds to it: its card and body land on the world's copy of that
4697
+ * card. Whatever order the two paths take, the worst the screen can show
4698
+ * is a body a frame early or late, over a card that is there. What it
4699
+ * costs is a few dozen cards more in a world build, drawn under their
4700
+ * copies. The 2D pane is painted in the same pass as the layer, in one
4701
+ * frame, and it leaves them out.
4702
+ */
4703
+ private _cardInLayer(id: string): boolean {
4704
+ // held: hidden, or on the way to it, whatever `<Flow>` last reported
4705
+ return !this._gl && !this._bodiesHeld && this._shownBodies.has(id);
4706
+ }
4707
+
4708
+ /**
4709
+ * The cards `<Flow>` has on screen in the bodies' layer (`paintCard`),
4710
+ * which the 2D pane then leaves out. Told after the commit that shows
4711
+ * them, so no 2D frame lacks both; told the empty set while bodies are
4712
+ * held out of a zoom, when their cards are the pane's again. Under GL it
4713
+ * changes nothing drawn: the world draws every card (`_cardInLayer`).
4714
+ */
4715
+ setShownBodies(ids: ReadonlySet<string>): void {
4716
+ const was = this._shownBodies;
4717
+ if (was.size === ids.size && [...ids].every((id) => was.has(id))) return;
4718
+ this._shownBodies = ids;
4719
+ // the pane, not the window
4720
+ if (!this._gl) this._repaint('content');
2852
4721
  }
2853
4722
 
2854
4723
  /**
2855
- * Where every mounted node body belongs, relative to the pane's own
2856
- * top-left. Sent only when the list changed, so a repaint that moved
2857
- * nothing does not re-render React — but a pan does, once per frame,
2858
- * which is the cost `render` is documented to carry.
4724
+ * The canvases `<Flow>` paints the minimap and controls on, over its
4725
+ * bodies (`paintPanels`): the bodies' layer is over the graph, so panels
4726
+ * the graph drew went under any card that reached them. While there are
4727
+ * any the graph leaves the panels out, and every change to the pane asks
4728
+ * them to repaint. Presses still land on the pane: the canvases take
4729
+ * none.
2859
4730
  */
2860
- private _emitBodies(): void {
2861
- const notify =
2862
- this._prop<(bodies: readonly NodeBodyRect[], sync: boolean) => void>(
2863
- 'onNodeBodies',
2864
- );
2865
- if (!notify) return;
2866
- const v = this._viewport();
4731
+ /** The panel canvases the minimap is painted on — the ones whose box
4732
+ * reaches its corner. */
4733
+ private _miniMapCanvases(): readonly PanelCanvas[] {
4734
+ const canvases = this._panelCanvases;
4735
+ if (canvases.length === 0) return canvases;
4736
+ const corner = this._miniMapCorner();
4737
+ if (!corner) return [];
4738
+ const box = this._device(corner);
4739
+ return canvases.filter((c) => !c.abs || rectsOverlap(c.abs, box));
4740
+ }
4741
+
4742
+ /**
4743
+ * The box `<Flow>` lays the node bodies out in, beside this pane, under
4744
+ * the 2D renderer — null under GL, and with no bodies to lay out. A pan
4745
+ * hands it to `scrollContents` as a rider: its pixels move with the
4746
+ * pane's.
4747
+ */
4748
+ setBodiesLayer(layer: unknown): void {
4749
+ this._bodiesLayer = (layer as Node | null) ?? null;
4750
+ }
4751
+
4752
+ setPanelCanvases(canvases: readonly PanelCanvas[]): void {
4753
+ const had = this._panelCanvases.length > 0;
4754
+ this._panelCanvases = canvases;
4755
+ if (had !== canvases.length > 0) this._repaint('props');
4756
+ }
4757
+
4758
+ /** Where the minimap and the controls are, relative to the pane's
4759
+ * top-left, logical — with a pixel round for their borders. */
4760
+ panelRects(): FlowRect[] {
2867
4761
  const pane = this._pane();
2868
- const bodies: NodeBodyRect[] = [];
2869
- for (const entry of this._order) {
2870
- if (entry.node.hidden || !this._mounted(entry)) continue;
2871
- const rect = this._screenRect(entry);
2872
- if (!rectsOverlap(rect, pane)) continue;
2873
- const header = this._headerHeight(entry) * v.zoom;
2874
- const inset = NODE_BODY_INSET * v.zoom;
2875
- const width = rect.width - inset * 2;
2876
- const height = rect.height - header - inset;
2877
- if (width <= 1 || height <= 1) continue;
2878
- bodies.push({
2879
- id: entry.node.id,
2880
- // pane-relative and logical, because that is what an absolutely
2881
- // positioned box beside the pane is laid out against, and in
2882
- x: Math.round(rect.x + inset - pane.x),
2883
- y: Math.round(rect.y + header - pane.y),
2884
- width: Math.round(width),
2885
- height: Math.round(height),
2886
- zoom: v.zoom,
2887
- selected: entry.node.selected ?? false,
4762
+ const rects: FlowRect[] = [];
4763
+ const map = this._miniMapOptions();
4764
+ if (map) {
4765
+ const panel = this._corner(
4766
+ map.position,
4767
+ map.width ?? MINIMAP_W,
4768
+ map.height ?? MINIMAP_H,
4769
+ 'bottom-right',
4770
+ );
4771
+ if (pane.width >= panel.width * 1.6 && pane.height >= panel.height * 1.6)
4772
+ rects.push(panel);
4773
+ }
4774
+ const buttons = this._controlButtons();
4775
+ if (buttons.length > 0) {
4776
+ rects.push(unionRects(buttons[0].rect, buttons[buttons.length - 1].rect));
4777
+ }
4778
+ return rects.map((r) => ({
4779
+ x: Math.floor(r.x - pane.x) - 1,
4780
+ y: Math.floor(r.y - pane.y) - 1,
4781
+ width: Math.ceil(r.width) + 2,
4782
+ height: Math.ceil(r.height) + 2,
4783
+ }));
4784
+ }
4785
+
4786
+ /** Paint the minimap and the controls into `ctx`, a canvas whose place in
4787
+ * the window, in device pixels, is `abs` — as the scene paints them. */
4788
+ paintPanels(ctx: unknown, abs: FlowRect): void {
4789
+ const painter = createPainter(ctx, this._textOptions());
4790
+ if (!painter) return;
4791
+ // Only the panels this canvas's box reaches: the minimap's canvas and
4792
+ // the controls' each built and painted both, which for the controls
4793
+ // was every node in the graph, clipped away. The frame clip is set to
4794
+ // the box too, for the minimap's own reach test — a canvas paints
4795
+ // outside this element's paint, where the clip is the last pass's.
4796
+ const bounds = abs.width > 0 && abs.height > 0 ? this._logical(abs) : null;
4797
+ const clip = this._frameClip;
4798
+ this._frameClip = bounds;
4799
+ let scene: FlowScene;
4800
+ try {
4801
+ scene = buildScene({
4802
+ ...this._sceneInput(this._palette(), 'overlay', true),
4803
+ clip: bounds,
2888
4804
  });
4805
+ } finally {
4806
+ this._frameClip = clip;
2889
4807
  }
2890
- // The zoom is in the key as well as the rect it produced: it is the
2891
- // body's subtree scale now, and two zooms a rounding apart can leave a
2892
- // box on the same integers while the text inside it should have moved.
2893
- const key = bodies
2894
- .map(
2895
- (b) =>
2896
- `${b.id}:${b.x},${b.y},${b.width},${b.height},${b.zoom},${b.selected}`,
2897
- )
4808
+ const c = ctx as {
4809
+ save(): void;
4810
+ restore(): void;
4811
+ translate(x: number, y: number): void;
4812
+ };
4813
+ c.save();
4814
+ try {
4815
+ c.translate(-abs.x, -abs.y);
4816
+ paintPanels(painter, scene);
4817
+ } finally {
4818
+ c.restore();
4819
+ }
4820
+ }
4821
+
4822
+ /** Tell `<Flow>` where the panels are when that changed (`onPanels`). */
4823
+ private _emitPanels(): void {
4824
+ const notify = this._prop<(rects: readonly FlowRect[]) => void>('onPanels');
4825
+ if (!notify) return;
4826
+ const rects = this.panelRects();
4827
+ const key = rects
4828
+ .map((r) => `${r.x},${r.y},${r.width},${r.height}`)
2898
4829
  .join('|');
2899
- if (key === this._bodiesKey) return;
2900
- this._bodiesKey = key;
2901
- notify(bodies, this._gestureSync);
4830
+ if (key === this._panelsKey) return;
4831
+ this._panelsKey = key;
4832
+ notify(rects);
2902
4833
  }
2903
4834
 
2904
- /** The grid, drawn only inside `region` — the pass's damage on a partial
2905
- * repaint, the pane on a full one. Alignment stays anchored to the
2906
- * viewport origin, so the region never changes where a dot falls. */
2907
- private _paintGrid(
2908
- painter: FlowPainter,
2909
- palette: FlowPalette,
2910
- region: FlowRect,
2911
- ): void {
2912
- const options = normalizeBackground(
2913
- this.props.background as BackgroundOptions | string | boolean | undefined,
2914
- );
2915
- if (options.variant === 'none') return;
2916
- const v = this._viewport();
2917
- let step = options.gap * v.zoom;
2918
- if (!(step > 0)) return;
2919
- // Doubling rather than clamping keeps the grid *aligned* to the graph
2920
- // while zooming out: every visible line is still a real one.
2921
- while (step < MIN_GRID_PX) step *= 2;
2922
- const color = options.color ?? palette.grid;
2923
- const pane = this._pane();
2924
- const { x, y, width, height } = region;
2925
- const originX = pane.x + v.x;
2926
- const originY = pane.y + v.y;
2927
- if (this._paintGridPattern(painter, options, step, color, region)) return;
2928
- const firstX = originX + Math.ceil((x - originX) / step) * step;
2929
- const firstY = originY + Math.ceil((y - originY) / step) * step;
2930
-
2931
- if (options.variant === 'lines') {
2932
- const runs: XYPosition[][] = [];
2933
- for (let gx = firstX; gx <= x + width; gx += step) {
2934
- runs.push([
2935
- { x: gx, y },
2936
- { x: gx, y: y + height },
2937
- ]);
2938
- }
2939
- for (let gy = firstY; gy <= y + height; gy += step) {
2940
- runs.push([
2941
- { x, y: gy },
2942
- { x: x + width, y: gy },
2943
- ]);
2944
- }
2945
- painter.strokeRuns(runs, { stroke: color, lineWidth: 1 });
2946
- return;
4835
+ /** `adaptive` as a number of milliseconds: `Infinity` never holds. */
4836
+ private _budgetMs(): number {
4837
+ const adaptive = this._prop<boolean | { budgetMs?: number }>('adaptive');
4838
+ if (adaptive === false) return Infinity;
4839
+ if (typeof adaptive === 'object' && adaptive?.budgetMs != null) {
4840
+ return Math.max(0, adaptive.budgetMs);
2947
4841
  }
4842
+ return BODY_BUDGET_MS;
4843
+ }
2948
4844
 
2949
- if (options.variant === 'cross') {
2950
- const arm = Math.max(2, options.size * 3 * v.zoom);
2951
- const runs: XYPosition[][] = [];
2952
- for (let gx = firstX; gx <= x + width; gx += step) {
2953
- for (let gy = firstY; gy <= y + height; gy += step) {
2954
- runs.push([
2955
- { x: gx - arm, y: gy },
2956
- { x: gx + arm, y: gy },
2957
- ]);
2958
- runs.push([
2959
- { x: gx, y: gy - arm },
2960
- { x: gx, y: gy + arm },
2961
- ]);
2962
- }
2963
- }
2964
- painter.strokeRuns(runs, { stroke: color, lineWidth: 1 });
2965
- return;
2966
- }
4845
+ /** Where the mounted bodies stand against the budget — reported with
4846
+ * every frame (`FlowFrameStats.bodies`). */
4847
+ bodyBudget(): FlowBodyBudget {
4848
+ const count = this._bodies.length;
4849
+ return {
4850
+ count,
4851
+ held: this._bodiesHeld,
4852
+ perBodyMs: this._bodyStepMs,
4853
+ predictedMs: count * this._bodyStepMs,
4854
+ budgetMs: this._budgetMs(),
4855
+ };
4856
+ }
2967
4857
 
2968
- const centres: XYPosition[] = [];
2969
- for (let gx = firstX; gx <= x + width; gx += step) {
2970
- for (let gy = firstY; gy <= y + height; gy += step) {
2971
- centres.push({ x: gx, y: gy });
2972
- }
4858
+ /**
4859
+ * A frame was drawn: time the zoom step it carried, into the model
4860
+ * `_holdBodies` predicts from. A step with no body re-scaled is the
4861
+ * baseline; one that re-scaled some says what each cost over it. Passes
4862
+ * a millisecond or two apart are one frame (the 2D renderer paints a
4863
+ * frame's damage as several), and a gap long enough to be the user
4864
+ * pausing is not a step at all.
4865
+ */
4866
+ private _frameTick(): void {
4867
+ const t = now();
4868
+ // the first frame to draw the latest zoom step, and when: the GL
4869
+ // renderer's is over as it starts — the bodies' cost is the window's
4870
+ // flush before it — and the 2D one's ends with its last pass (`paint`)
4871
+ if (this._zoomFrameAt === -Infinity && this._zoomAt !== -Infinity) {
4872
+ this._zoomFrameAt = t;
4873
+ this._zoomDrawnAt = t;
4874
+ }
4875
+ const dt = t - this._lastFrameAt;
4876
+ if (dt < 2) return;
4877
+ this._lastFrameAt = t;
4878
+ const step = this._zoomStep;
4879
+ this._zoomStep = null;
4880
+ if (!step || dt > 250) return;
4881
+ if (!step.live || step.bodies === 0) {
4882
+ this._baseStepMs = Number.isNaN(this._baseStepMs)
4883
+ ? dt
4884
+ : this._baseStepMs * 0.7 + dt * 0.3;
4885
+ } else if (!Number.isNaN(this._baseStepMs)) {
4886
+ const each = Math.max(0, dt - this._baseStepMs) / step.bodies;
4887
+ this._bodyStepMs = this._bodyStepMs * 0.7 + each * 0.3;
2973
4888
  }
2974
- painter.dots(
2975
- centres,
2976
- Math.max(1, Math.round(options.size * 2 * v.zoom)),
2977
- color,
2978
- );
2979
4889
  }
2980
4890
 
2981
4891
  /**
2982
4892
  * The grid as one repeating fill (ntk#263): a step-sized tile drawn once,
2983
4893
  * a `createPattern('repeat')` over it, and one composite for the whole
2984
- * region — where the runs path pays a region-sized coverage mask.
4894
+ * region — where the runs path in `./paint.ts` pays a region-sized
4895
+ * coverage mask.
2985
4896
  *
2986
- * Two decisions with reasons:
4897
+ * Handed to `paintScene` as its grid painter, and answering **false** when
4898
+ * it does not apply, so the universal path takes over. Two decisions with
4899
+ * reasons:
2987
4900
  *
2988
4901
  * - **Integral device steps only.** The tile is a pixmap, so its size is
2989
- * whole pixels; at a fractional step the pattern would drift against
2990
- * the graph's own coordinates — against `snapToGrid`, against node
2991
- * positions — by a fraction per tile, and a grid that slides under the
2992
- * content it grids is worse than a slower exact one.
4902
+ * whole pixels; at a fractional step the pattern would drift against the
4903
+ * graph's own coordinates — against `snapToGrid`, against node positions
4904
+ * — by a fraction per tile, and a grid that slides under the content it
4905
+ * grids is worse than a slower exact one.
2993
4906
  * - **The phase lives in the tile, not in a picture transform.** An
2994
4907
  * untransformed repeat is anchored to the window origin, so the grid's
2995
4908
  * alignment is baked in by drawing the mark at `origin mod tile` — and
2996
4909
  * re-baking when the origin moves. A `setTransform` translate says the
2997
4910
  * same thing in one request, but a *transformed* repeat forfeits the
2998
- * server's untransformed fast path — on the in-process test server
2999
- * that was ~180 ms a frame of per-pixel arithmetic, and re-rendering a
3000
- * 24px tile is a handful of requests on any server.
4911
+ * server's untransformed fast path — on the in-process test server that
4912
+ * was ~180 ms a frame of per-pixel arithmetic, and re-rendering a 24px
4913
+ * tile is a handful of requests on any server.
4914
+ *
4915
+ * An arrow property rather than a method: it is passed as a value, and a
4916
+ * method would arrive with no `this`.
3001
4917
  */
3002
- private _paintGridPattern(
3003
- painter: FlowPainter,
3004
- options: { variant: BackgroundVariant; size: number },
3005
- step: number,
3006
- color: string,
3007
- region: FlowRect,
3008
- ): boolean {
4918
+ private _grid = (painter: FlowPainter, grid: SceneGrid): boolean => {
4919
+ const options = { variant: grid.variant, size: grid.size };
4920
+ const { step, color, region } = grid;
3009
4921
  // The tile is a pixmap on the panel's grid, so the pitch has to be a
3010
4922
  // whole number of *device* pixels, and the phase is where the viewport
3011
4923
  // origin lands on that grid.
@@ -3023,8 +4935,8 @@ export class FlowGraphNode extends Node implements FlowInstance {
3023
4935
  const v = this._viewport();
3024
4936
  const pane = this._pane();
3025
4937
  const mod = (a: number, m: number): number => ((a % m) + m) % m;
3026
- const px = Math.round(mod(toDevice(pane.x + v.x, s), tileSize)) % tileSize;
3027
- const py = Math.round(mod(toDevice(pane.y + v.y, s), tileSize)) % tileSize;
4938
+ const px = snapToGrid(mod(toDevice(pane.x + v.x, s), tileSize)) % tileSize;
4939
+ const py = snapToGrid(mod(toDevice(pane.y + v.y, s), tileSize)) % tileSize;
3028
4940
  // device pixels per graph unit — what a mark's size is drawn in
3029
4941
  const unit = v.zoom * s;
3030
4942
  const key = `${options.variant}|${Math.round(options.size * unit * 4)}|${color}|${px},${py}`;
@@ -3050,7 +4962,7 @@ export class FlowGraphNode extends Node implements FlowInstance {
3050
4962
  const fill = this._device(region);
3051
4963
  raw.fillRect?.(fill.x, fill.y, fill.width, fill.height);
3052
4964
  return true;
3053
- }
4965
+ };
3054
4966
 
3055
4967
  private _makeGridSurface(tileSize: number): SurfaceLike | null {
3056
4968
  const ctor = (ntk as unknown as { Surface?: SurfaceCtor }).Surface;
@@ -3078,6 +4990,16 @@ export class FlowGraphNode extends Node implements FlowInstance {
3078
4990
  py: number,
3079
4991
  ): void {
3080
4992
  const t = tile.size;
4993
+ // Every mark on whole pixels. A backend cuts a fractional edge its own
4994
+ // way, and X11 cut a mark's position and its size separately, so a mark
4995
+ // that straddled the tile's edge lost a column: at phase 0 a 3px dot
4996
+ // came out as two pixels a column apart and a 1px line not at all. The
4997
+ // grid changed shape as the view panned, and a pan's blit kept the old
4998
+ // shape where a repaint drew the new one. The phase point `(px, py)` is
4999
+ // a pixel edge; a line and a cross are centred on the pixel before it,
5000
+ // and a dot is `size` pixels ending on it, where the cut had put them.
5001
+ const arm = Math.max(1, Math.floor(Math.max(2, options.size * 3 * unit)));
5002
+ const dot = Math.max(1, Math.round(options.size * 2 * unit));
3081
5003
  tile.surface.render((ctx) => {
3082
5004
  ctx.clearRect?.(0, 0, t, t);
3083
5005
  ctx.fillStyle = color;
@@ -3086,15 +5008,15 @@ export class FlowGraphNode extends Node implements FlowInstance {
3086
5008
  const cx = px + i * t;
3087
5009
  const cy = py + j * t;
3088
5010
  if (options.variant === 'lines') {
3089
- if (j === 0) ctx.fillRect(cx - 0.5, 0, 1, t);
3090
- if (i === 0) ctx.fillRect(0, cy - 0.5, t, 1);
5011
+ if (j === 0) ctx.fillRect(cx - 1, 0, 1, t);
5012
+ if (i === 0) ctx.fillRect(0, cy - 1, t, 1);
3091
5013
  } else if (options.variant === 'cross') {
3092
- const arm = Math.max(2, options.size * 3 * unit);
3093
- ctx.fillRect(cx - arm, cy - 0.5, arm * 2, 1);
3094
- ctx.fillRect(cx - 0.5, cy - arm, 1, arm * 2);
5014
+ ctx.fillRect(cx - 1 - arm, cy - 1, arm * 2 + 1, 1);
5015
+ ctx.fillRect(cx - 1, cy - 1 - arm, 1, arm * 2 + 1);
3095
5016
  } else {
3096
- const dot = Math.max(1, Math.round(options.size * 2 * unit));
3097
- ctx.fillRect(cx - dot / 2, cy - dot / 2, dot, dot);
5017
+ const x0 = Math.floor(cx - dot / 2);
5018
+ const y0 = Math.floor(cy - dot / 2);
5019
+ ctx.fillRect(x0, y0, dot, dot);
3098
5020
  }
3099
5021
  }
3100
5022
  }
@@ -3108,686 +5030,4 @@ export class FlowGraphNode extends Node implements FlowInstance {
3108
5030
  tile.surface.destroy?.();
3109
5031
  this._gridTile = null;
3110
5032
  }
3111
-
3112
- /** Draws every visible edge; answers whether any of them is animated, so
3113
- * the caller knows whether to keep a timer alive. */
3114
- private _paintEdges(painter: FlowPainter, palette: FlowPalette): boolean {
3115
- const v = this._viewport();
3116
- const pane = this._pane();
3117
- const labels = v.zoom >= LABEL_ZOOM;
3118
- let animated = false;
3119
- // Collect, then draw. A stroke has no fast path in ntk — every one is a
3120
- // mask rasterized in JS, uploaded with `PutImage` and composited — so a
3121
- // graph of seven hundred edges was seven hundred round trips and about
3122
- // four megabytes a frame. Bucketed by the style they will be stroked
3123
- // with, the same graph is one path per distinct pen, which for almost
3124
- // every graph is one.
3125
- const strokes = new StrokeBuckets();
3126
- const markers = new Map<string, MarkerBucket>();
3127
- const labelChips: { rect: FlowRect; radius: number; fill: string }[] = [];
3128
- const pendingLabels: {
3129
- text: string;
3130
- x: number;
3131
- y: number;
3132
- size: number;
3133
- color: string;
3134
- }[] = [];
3135
-
3136
- const clip = this._frameClip;
3137
- let animBox: FlowRect | null = null;
3138
- for (const edge of this._edges) {
3139
- if (edge.hidden) continue;
3140
- // two rejects: one from the nodes alone, one from the route it took
3141
- const coarse = this._edgeCoarseBox(edge);
3142
- if (!coarse || !rectsOverlap(coarse, pane)) continue;
3143
- // Tracked before the damage skip, deliberately: whether the dash
3144
- // timer runs is a question about the viewport, not about what this
3145
- // particular pass repaints — deciding it after the skip is how a drag
3146
- // in one corner would stop the dash marching in the other. The box
3147
- // the ticks invalidate is collected below from the *drawn* geometry:
3148
- // the coarse box carries the bezier slack, and a tick that repaints
3149
- // slack repaints a card-sized halo of neighbours sixteen times a
3150
- // second.
3151
- if (edge.animated) animated = true;
3152
- if (clip && !rectsOverlap(coarse, clip)) continue;
3153
- const geometry = this._edgeGeometry(edge);
3154
- if (!geometry) continue;
3155
- if (!rectsOverlap(pathBounds(geometry.points), pane)) continue;
3156
-
3157
- const selected = edge.selected ?? false;
3158
- const hovered = this._hover.edgeId === edge.id;
3159
- const stroke =
3160
- edge.style?.stroke ??
3161
- (selected
3162
- ? palette.edgeSelected
3163
- : hovered
3164
- ? palette.text
3165
- : palette.edge);
3166
- const lineWidth = Math.max(
3167
- 1,
3168
- (edge.style?.strokeWidth ?? (selected ? 2 : 1.5)) * v.zoom,
3169
- );
3170
- // `markerEnd` left out means an arrow: a directed graph whose edges do
3171
- // not say which way they point is a set of lines. `null` opts out.
3172
- const markerEnd = normalizeMarker(
3173
- edge.markerEnd === undefined ? 'arrowclosed' : edge.markerEnd,
3174
- );
3175
- const markerStart = normalizeMarker(edge.markerStart);
3176
-
3177
- // The tip stops short of the handle rather than at it: the handle dot
3178
- // is drawn *over* the edges, with the nodes, so an arrow aimed at the
3179
- // handle's centre is an arrow mostly hidden under a white circle.
3180
- const inset = v.zoom >= HANDLE_ZOOM ? this._handleRadius() + 1 : 1;
3181
- const last = geometry.points[geometry.points.length - 1];
3182
- const outAngle = endAngle(geometry.points);
3183
- const endTip = {
3184
- x: last.x - Math.cos(outAngle) * inset,
3185
- y: last.y - Math.sin(outAngle) * inset,
3186
- };
3187
- let points = geometry.points;
3188
- if (markerEnd) {
3189
- // and the stroke stops behind the head, so a filled triangle is a
3190
- // triangle rather than a triangle with a line through it
3191
- points = trimEnd(
3192
- points,
3193
- (markerEnd.size ?? DEFAULT_MARKER_SIZE) * v.zoom * 0.8 + inset,
3194
- );
3195
- }
3196
- const dash =
3197
- edge.style?.dash ?? (edge.animated ? DEFAULT_DASH : undefined);
3198
- if (edge.animated) {
3199
- const tight = inflateRect(pathBounds(geometry.points), CULL_MARGIN);
3200
- animBox = animBox ? unionRects(animBox, tight) : tight;
3201
- }
3202
- strokes
3203
- .bucket(
3204
- stroke,
3205
- lineWidth,
3206
- dash,
3207
- edge.animated ? this._dashPhase : 0,
3208
- v.zoom,
3209
- )
3210
- .push(points);
3211
-
3212
- if (markerEnd) {
3213
- this._collectMarker(
3214
- markers,
3215
- endTip,
3216
- outAngle,
3217
- markerEnd.type,
3218
- markerEnd.color ?? stroke,
3219
- (markerEnd.size ?? DEFAULT_MARKER_SIZE) * v.zoom,
3220
- lineWidth,
3221
- );
3222
- }
3223
- if (markerStart) {
3224
- const inAngle = startAngle(geometry.points);
3225
- this._collectMarker(
3226
- markers,
3227
- {
3228
- x: geometry.points[0].x - Math.cos(inAngle) * inset,
3229
- y: geometry.points[0].y - Math.sin(inAngle) * inset,
3230
- },
3231
- inAngle,
3232
- markerStart.type,
3233
- markerStart.color ?? stroke,
3234
- (markerStart.size ?? DEFAULT_MARKER_SIZE) * v.zoom,
3235
- lineWidth,
3236
- );
3237
- }
3238
-
3239
- if (labels && edge.label) {
3240
- const at = pointAtFraction(geometry.points, 0.5);
3241
- const size = Math.max(8, 11 * v.zoom);
3242
- const metrics = painter.measureText(edge.label, { size });
3243
- const padX = 5 * v.zoom;
3244
- const padY = 2 * v.zoom;
3245
- // The chip is collected with the rest; the text is not, because a
3246
- // glyph run is already one request and nothing is gained by holding
3247
- // it. Both still land above every edge, which is the point of the
3248
- // chip.
3249
- labelChips.push({
3250
- rect: {
3251
- x: Math.round(at.x - metrics.width / 2 - padX),
3252
- y: Math.round(at.y - metrics.height / 2 - padY),
3253
- width: Math.round(metrics.width + padX * 2),
3254
- height: Math.round(metrics.height + padY * 2),
3255
- },
3256
- radius: Math.max(2, Math.round(3 * v.zoom)),
3257
- fill: edge.style?.labelBackground ?? tint(palette.background, 0.92),
3258
- });
3259
- pendingLabels.push({
3260
- text: edge.label,
3261
- x: at.x,
3262
- y: at.y,
3263
- size,
3264
- color: edge.style?.labelColor ?? palette.text,
3265
- });
3266
- }
3267
- }
3268
-
3269
- strokes.paint(painter);
3270
- for (const bucket of markers.values()) {
3271
- // the same threshold, for the same reason: a handful of arrowheads
3272
- // scattered over the pane is cheaper drawn as a handful
3273
- if (bucket.filled.length >= MARKER_BATCH_MIN) {
3274
- painter.polygons(bucket.filled, { fill: bucket.color });
3275
- } else {
3276
- for (const head of bucket.filled) {
3277
- painter.polygon(head, { fill: bucket.color });
3278
- }
3279
- }
3280
- if (bucket.open.length >= MARKER_BATCH_MIN) {
3281
- painter.strokeRuns(bucket.open, {
3282
- stroke: bucket.color,
3283
- lineWidth: bucket.lineWidth,
3284
- });
3285
- } else {
3286
- for (const head of bucket.open) {
3287
- painter.strokeRuns([head], {
3288
- stroke: bucket.color,
3289
- lineWidth: bucket.lineWidth,
3290
- });
3291
- }
3292
- }
3293
- }
3294
- for (const chip of labelChips) {
3295
- painter.rect(
3296
- chip.rect.x,
3297
- chip.rect.y,
3298
- chip.rect.width,
3299
- chip.rect.height,
3300
- chip.radius,
3301
- { fill: chip.fill },
3302
- );
3303
- }
3304
- for (const l of pendingLabels) {
3305
- painter.text(l.text, l.x, l.y, {
3306
- size: l.size,
3307
- color: l.color,
3308
- align: 'center',
3309
- baseline: 'middle',
3310
- });
3311
- }
3312
- // A pass that drew an animated edge knows exactly where its dash is; a
3313
- // pass that culled them all keeps the previous box — the endpoints did
3314
- // not move, or the move's own damage would have redrawn them here.
3315
- if (animBox) this._animBox = animBox;
3316
- return animated;
3317
- }
3318
-
3319
- /** An arrowhead's three or four points, added to the pile for its colour
3320
- * rather than drawn — see {@link StrokeBuckets} for why. */
3321
- private _collectMarker(
3322
- markers: Map<string, MarkerBucket>,
3323
- at: XYPosition,
3324
- angle: number,
3325
- type: 'arrow' | 'arrowclosed',
3326
- color: string,
3327
- size: number,
3328
- lineWidth: number,
3329
- ): void {
3330
- const spread = 0.42; // radians off the shaft — a ~24° half-angle
3331
- const back = {
3332
- x: at.x - Math.cos(angle) * size,
3333
- y: at.y - Math.sin(angle) * size,
3334
- };
3335
- const left = {
3336
- x: at.x - Math.cos(angle - spread) * size,
3337
- y: at.y - Math.sin(angle - spread) * size,
3338
- };
3339
- const right = {
3340
- x: at.x - Math.cos(angle + spread) * size,
3341
- y: at.y - Math.sin(angle + spread) * size,
3342
- };
3343
- const key = `${color}|${lineWidth}`;
3344
- let bucket = markers.get(key);
3345
- if (!bucket) {
3346
- bucket = { color, lineWidth, filled: [], open: [] };
3347
- markers.set(key, bucket);
3348
- }
3349
- if (type === 'arrowclosed') bucket.filled.push([at, left, back, right]);
3350
- else bucket.open.push([left, at, right]);
3351
- }
3352
-
3353
- private _paintNodes(painter: FlowPainter, palette: FlowPalette): void {
3354
- const dragging = this._dragTo;
3355
- const order: NodeEntry[] = [];
3356
- const deferred: NodeEntry[] = [];
3357
- for (const entry of this._order) {
3358
- // a dragged node comes to the top
3359
- if (dragging?.has(entry.node.id)) deferred.push(entry);
3360
- else order.push(entry);
3361
- }
3362
- order.push(...deferred);
3363
-
3364
- // Nodes are drawn one at a time, in z-order, and both halves of that
3365
- // were measured rather than assumed — see `_paintHandles` for why
3366
- // batching a card or a handle costs more than it saves.
3367
- for (const entry of order) this._paintNode(painter, palette, entry);
3368
- }
3369
-
3370
- private _paintNode(
3371
- painter: FlowPainter,
3372
- palette: FlowPalette,
3373
- entry: NodeEntry,
3374
- ): void {
3375
- if (entry.node.hidden) return;
3376
- const rect = this._screenRect(entry);
3377
- if (!rectsOverlap(rect, this._pane())) return;
3378
- // inflated by the margin its handles and grips can ink outside the box —
3379
- // the same margin every invalidate grew by, so the two agree
3380
- const clip = this._frameClip;
3381
- if (clip && !rectsOverlap(inflateRect(rect, CULL_MARGIN), clip)) return;
3382
- const v = this._viewport();
3383
- const selected = entry.node.selected ?? false;
3384
- const hovered = this._hover.nodeId === entry.node.id;
3385
- const handles = this._handlesOf(entry).map((anchor) => {
3386
- const s = this._toScreen(anchor);
3387
- return { ...anchor, x: s.x, y: s.y };
3388
- });
3389
-
3390
- if (entry.type?.paint) {
3391
- // Never batched: a type that draws its own body is drawing whatever it
3392
- // likes, in an order only it knows.
3393
- entry.type.paint({
3394
- node: entry.node,
3395
- rect,
3396
- zoom: v.zoom,
3397
- selected,
3398
- hovered,
3399
- palette,
3400
- painter,
3401
- handles,
3402
- });
3403
- } else {
3404
- this._paintCardShape(painter, palette, entry, rect, selected, hovered);
3405
- this._paintCardInk(painter, palette, entry, rect);
3406
- }
3407
-
3408
- if (this._connectable(entry) && (v.zoom >= HANDLE_ZOOM || hovered)) {
3409
- this._paintHandles(painter, palette, handles);
3410
- }
3411
- this._paintGrips(painter, palette, entry, rect);
3412
- }
3413
-
3414
- private _mounted(entry: NodeEntry): boolean {
3415
- return entry.type?.render != null && this._viewport().zoom >= RENDER_ZOOM;
3416
- }
3417
-
3418
- private _paintGrips(
3419
- painter: FlowPainter,
3420
- palette: FlowPalette,
3421
- entry: NodeEntry,
3422
- rect: FlowRect,
3423
- ): void {
3424
- const grips = this._grips(entry);
3425
- if (grips.length === 0) return;
3426
- const size = Math.max(4, RESIZE_GRIP * 2 * this._viewport().zoom);
3427
- for (const dir of grips) {
3428
- const at = gripPoint(rect, dir);
3429
- painter.rect(at.x - size / 2, at.y - size / 2, size, size, 1, {
3430
- fill: palette.nodeBackground,
3431
- stroke: palette.accent,
3432
- lineWidth: 1.5,
3433
- });
3434
- }
3435
- }
3436
-
3437
- /** The built-in node: a card with a label, an optional second line and an
3438
- * optional accent stripe down its leading edge. */
3439
- /** The card itself: one rounded box, and the accent stripe if it has one.
3440
- * With a `batch` the box joins the pile for its appearance instead of
3441
- * being drawn on its own. */
3442
- private _paintCardShape(
3443
- painter: FlowPainter,
3444
- palette: FlowPalette,
3445
- entry: NodeEntry,
3446
- rect: FlowRect,
3447
- selected: boolean,
3448
- hovered: boolean,
3449
- ): void {
3450
- const v = this._viewport();
3451
- const style = entry.node.style;
3452
- const radius = Math.round((style?.borderRadius ?? 6) * v.zoom);
3453
- const border = selected
3454
- ? palette.accent
3455
- : hovered
3456
- ? tint(palette.accent, 0.55)
3457
- : (style?.borderColor ?? palette.nodeBorder);
3458
- const options: ShapeOptions = {
3459
- fill: style?.background ?? palette.nodeBackground,
3460
- stroke: border,
3461
- lineWidth: Math.max(
3462
- 1,
3463
- Math.round((style?.borderWidth ?? (selected ? 2 : 1)) * v.zoom),
3464
- ),
3465
- };
3466
- painter.rect(rect.x, rect.y, rect.width, rect.height, radius, options);
3467
-
3468
- if (style?.accent) {
3469
- // Inset past the rounded corners instead of clipped to them: a
3470
- // non-rectangular clip forfeits ntk's rounded-box fast path for every
3471
- // fill under it — measured as a pixmap create/free and a trapezoid
3472
- // pass per card per repaint.
3473
- painter.rect(
3474
- rect.x + Math.max(1, v.zoom),
3475
- rect.y + radius,
3476
- Math.max(2, 3 * v.zoom),
3477
- rect.height - radius * 2,
3478
- 0,
3479
- { fill: style.accent },
3480
- );
3481
- }
3482
- }
3483
-
3484
- /** The label and its second line — the half of a card that has to come
3485
- * after every card when they are batched. */
3486
- private _paintCardInk(
3487
- painter: FlowPainter,
3488
- palette: FlowPalette,
3489
- entry: NodeEntry,
3490
- rect: FlowRect,
3491
- ): void {
3492
- const v = this._viewport();
3493
- if (v.zoom < LABEL_ZOOM) return;
3494
- const style = entry.node.style;
3495
- const header = this._mounted(entry) ? this._headerHeight(entry) : 0;
3496
- const data = entry.node.data as FlowNodeData | undefined;
3497
- const label = data?.label ?? entry.node.id;
3498
- const description = data?.description;
3499
- const color = style?.color ?? palette.text;
3500
- const padX = NODE_PAD_X * v.zoom;
3501
- const centre = rect.x + rect.width / 2;
3502
- const maxWidth = Math.max(8, rect.width - padX * 2);
3503
- const showDescription = Boolean(description) && v.zoom >= DESC_ZOOM;
3504
-
3505
- if (header > 0) {
3506
- // The title bar of a node whose body is somebody else's: left-aligned,
3507
- // with a hairline under it so the strip reads as the thing to grab.
3508
- const band = header * v.zoom;
3509
- painter.text(label, rect.x + padX, rect.y + band / 2, {
3510
- size: NODE_LABEL_SIZE * v.zoom,
3511
- weight: 'bold',
3512
- color,
3513
- baseline: 'middle',
3514
- maxWidth,
3515
- });
3516
- painter.strokeRuns(
3517
- [
3518
- [
3519
- { x: rect.x + 1, y: rect.y + band },
3520
- { x: rect.x + rect.width - 1, y: rect.y + band },
3521
- ],
3522
- ],
3523
- { stroke: palette.nodeBorder, lineWidth: 1 },
3524
- );
3525
- return;
3526
- }
3527
-
3528
- if (!showDescription) {
3529
- painter.text(label, centre, rect.y + rect.height / 2, {
3530
- size: NODE_LABEL_SIZE * v.zoom,
3531
- weight: 'bold',
3532
- color,
3533
- align: 'center',
3534
- baseline: 'middle',
3535
- maxWidth,
3536
- });
3537
- return;
3538
- }
3539
- const top = rect.y + NODE_PAD_Y * v.zoom;
3540
- painter.text(label, centre, top, {
3541
- size: NODE_LABEL_SIZE * v.zoom,
3542
- weight: 'bold',
3543
- color,
3544
- align: 'center',
3545
- maxWidth,
3546
- });
3547
- painter.text(description!, centre, top + NODE_LABEL_SIZE * 1.35 * v.zoom, {
3548
- size: NODE_DESC_SIZE * v.zoom,
3549
- color: palette.dim,
3550
- align: 'center',
3551
- maxWidth,
3552
- });
3553
- }
3554
-
3555
- /**
3556
- * Handles are drawn one disc at a time, and that is the measured answer
3557
- * rather than the obvious one.
3558
- *
3559
- * Batching them into a single path halved the requests and made the frame
3560
- * *slower*: a path's mask is its bounding box, so forty dots scattered
3561
- * across the pane rasterize to a paneful of mask — three quarters of a
3562
- * megabyte — where forty small ones cost a kilobyte each. Batching pays
3563
- * for the edges because an edge already spans that box; it does not pay
3564
- * for anything small and scattered.
3565
- */
3566
- private _paintHandles(
3567
- painter: FlowPainter,
3568
- palette: FlowPalette,
3569
- handles: readonly HandleAnchor[],
3570
- ): void {
3571
- const v = this._viewport();
3572
- const radius = this._handleRadius();
3573
- const gesture = this._gesture;
3574
- const connecting = gesture?.kind === 'connect' ? gesture : null;
3575
- const lineWidth = Math.max(1, 1.5 * v.zoom);
3576
- for (const anchor of handles) {
3577
- const active =
3578
- sameHandle(this._hover.handle, anchor) ||
3579
- sameHandle(connecting?.to ?? null, anchor) ||
3580
- sameHandle(connecting?.from ?? null, anchor);
3581
- const options: ShapeOptions = {
3582
- fill: active ? palette.accent : palette.nodeBackground,
3583
- stroke: active ? palette.accent : palette.handle,
3584
- lineWidth,
3585
- };
3586
- painter.circle(
3587
- anchor.x,
3588
- anchor.y,
3589
- active ? radius * 1.4 : radius,
3590
- options,
3591
- );
3592
- if (anchor.label && v.zoom >= DESC_ZOOM) {
3593
- const outside = anchor.position === 'left' || anchor.position === 'top';
3594
- painter.text(
3595
- anchor.label,
3596
- anchor.x +
3597
- (anchor.position === 'left'
3598
- ? -radius - 4 * v.zoom
3599
- : radius + 4 * v.zoom),
3600
- anchor.y,
3601
- {
3602
- size: 10 * v.zoom,
3603
- color: palette.dim,
3604
- align: outside ? 'right' : 'left',
3605
- baseline: 'middle',
3606
- },
3607
- );
3608
- }
3609
- }
3610
- }
3611
-
3612
- private _paintConnection(painter: FlowPainter, palette: FlowPalette): void {
3613
- const gesture = this._gesture;
3614
- if (gesture?.kind !== 'connect') return;
3615
- const v = this._viewport();
3616
- const target = gesture.to ?? gesture.pointer;
3617
- const to = this._toScreen(target);
3618
- const points = this._connectionPath(gesture);
3619
- const invalid = gesture.to != null && !gesture.valid;
3620
- painter.polyline(points, {
3621
- stroke: invalid ? palette.dim : palette.accent,
3622
- lineWidth: Math.max(1.5, 2 * v.zoom),
3623
- dash: gesture.to && gesture.valid ? undefined : [5 * v.zoom, 4 * v.zoom],
3624
- });
3625
- painter.circle(to.x, to.y, this._handleRadius(), {
3626
- fill: invalid ? palette.dim : palette.accent,
3627
- });
3628
- }
3629
-
3630
- private _paintSelectionBox(painter: FlowPainter, palette: FlowPalette): void {
3631
- const gesture = this._gesture;
3632
- if (gesture?.kind !== 'select') return;
3633
- const x = Math.min(gesture.startX, gesture.x);
3634
- const y = Math.min(gesture.startY, gesture.y);
3635
- painter.rect(
3636
- x,
3637
- y,
3638
- Math.abs(gesture.x - gesture.startX),
3639
- Math.abs(gesture.y - gesture.startY),
3640
- 2,
3641
- { fill: palette.selection, stroke: palette.accent, lineWidth: 1 },
3642
- );
3643
- }
3644
-
3645
- private _paintMiniMap(painter: FlowPainter, palette: FlowPalette): void {
3646
- const opts = this._miniMapOptions();
3647
- if (!opts) return;
3648
- // The panel cull comes before `_miniMap()`, which walks every node to
3649
- // find the graph's bounds — during a drag that walk per pass would cost
3650
- // more than the panel it skips. The dot for a mid-drag node goes stale
3651
- // until the release repaints in full; that is the trade, and it is
3652
- // deliberate.
3653
- const clip = this._frameClip;
3654
- if (clip) {
3655
- const quick = this._corner(
3656
- opts.position,
3657
- opts.width ?? MINIMAP_W,
3658
- opts.height ?? MINIMAP_H,
3659
- 'bottom-right',
3660
- );
3661
- if (!rectsOverlap(quick, clip)) return;
3662
- }
3663
- const map = this._miniMap();
3664
- if (!map) return;
3665
- const { panel, options } = map;
3666
- painter.rect(panel.x, panel.y, panel.width, panel.height, 4, {
3667
- fill: tint(palette.surface, 0.92),
3668
- stroke: palette.surfaceBorder,
3669
- lineWidth: 1,
3670
- });
3671
- painter.save();
3672
- painter.clipRect(panel.x, panel.y, panel.width, panel.height, 4);
3673
-
3674
- const nodeColor = options.nodeColor;
3675
- for (const entry of this._entries) {
3676
- if (entry.node.hidden) continue;
3677
- const rect = this.rectOf(entry);
3678
- const at = this._miniToScreen(map, rect);
3679
- const fill =
3680
- typeof nodeColor === 'function'
3681
- ? nodeColor(entry.node as FlowNode<never>)
3682
- : (nodeColor ??
3683
- (entry.node.selected ? palette.accent : palette.nodeBorder));
3684
- painter.rect(
3685
- at.x,
3686
- at.y,
3687
- Math.max(1, rect.width * map.scale),
3688
- Math.max(1, rect.height * map.scale),
3689
- 0,
3690
- { fill },
3691
- );
3692
- }
3693
-
3694
- // The viewport, as an outline over a wash on everything outside it —
3695
- // cheaper than four mask rectangles and reads the same.
3696
- const v = this._viewport();
3697
- const paneRect = this._pane();
3698
- const view = this._miniToScreen(map, {
3699
- x: -v.x / v.zoom,
3700
- y: -v.y / v.zoom,
3701
- });
3702
- painter.rect(
3703
- view.x,
3704
- view.y,
3705
- (paneRect.width / v.zoom) * map.scale,
3706
- (paneRect.height / v.zoom) * map.scale,
3707
- 2,
3708
- {
3709
- fill: options.maskColor ?? tint(palette.accent, 0.1),
3710
- stroke: palette.accent,
3711
- lineWidth: 1,
3712
- },
3713
- );
3714
- painter.restore();
3715
- }
3716
-
3717
- private _paintControls(painter: FlowPainter, palette: FlowPalette): void {
3718
- const buttons = this._controlButtons();
3719
- if (buttons.length === 0) return;
3720
- const clip = this._frameClip;
3721
- if (clip && !buttons.some((b) => rectsOverlap(b.rect, clip))) return;
3722
- const first = buttons[0].rect;
3723
- const last = buttons[buttons.length - 1].rect;
3724
- painter.rect(
3725
- first.x,
3726
- first.y,
3727
- first.width,
3728
- last.y + last.height - first.y,
3729
- 4,
3730
- {
3731
- fill: tint(palette.surface, 0.94),
3732
- stroke: palette.surfaceBorder,
3733
- lineWidth: 1,
3734
- },
3735
- );
3736
- for (let i = 0; i < buttons.length; i++) {
3737
- const { rect, action } = buttons[i];
3738
- if (i > 0) {
3739
- painter.strokeRuns(
3740
- [
3741
- [
3742
- { x: rect.x + 4, y: rect.y },
3743
- { x: rect.x + rect.width - 4, y: rect.y },
3744
- ],
3745
- ],
3746
- { stroke: palette.surfaceBorder, lineWidth: 1 },
3747
- );
3748
- }
3749
- const cx = rect.x + rect.width / 2;
3750
- const cy = rect.y + rect.height / 2;
3751
- const arm = 5;
3752
- const runs: XYPosition[][] = [];
3753
- if (action === 'in' || action === 'out') {
3754
- runs.push([
3755
- { x: cx - arm, y: cy },
3756
- { x: cx + arm, y: cy },
3757
- ]);
3758
- if (action === 'in') {
3759
- runs.push([
3760
- { x: cx, y: cy - arm },
3761
- { x: cx, y: cy + arm },
3762
- ]);
3763
- }
3764
- } else {
3765
- // a frame with its sides opened out: "fit what is there into this"
3766
- const a = 5;
3767
- runs.push(
3768
- [
3769
- { x: cx - a, y: cy - a + 3 },
3770
- { x: cx - a, y: cy - a },
3771
- { x: cx - a + 3, y: cy - a },
3772
- ],
3773
- [
3774
- { x: cx + a - 3, y: cy - a },
3775
- { x: cx + a, y: cy - a },
3776
- { x: cx + a, y: cy - a + 3 },
3777
- ],
3778
- [
3779
- { x: cx + a, y: cy + a - 3 },
3780
- { x: cx + a, y: cy + a },
3781
- { x: cx + a - 3, y: cy + a },
3782
- ],
3783
- [
3784
- { x: cx - a + 3, y: cy + a },
3785
- { x: cx - a, y: cy + a },
3786
- { x: cx - a, y: cy + a - 3 },
3787
- ],
3788
- );
3789
- }
3790
- painter.strokeRuns(runs, { stroke: palette.text, lineWidth: 1.5 });
3791
- }
3792
- }
3793
5033
  }