@dxos/react-ui-canvas 0.13.0 → 0.14.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 (341) hide show
  1. package/README.md +1 -1
  2. package/dist/lib/index.mjs +9044 -1135
  3. package/dist/lib/index.mjs.map +1 -1
  4. package/dist/types/src/components/About/About.d.ts +13 -0
  5. package/dist/types/src/components/About/About.d.ts.map +1 -0
  6. package/dist/types/src/components/About/About.stories.d.ts +7 -0
  7. package/dist/types/src/components/About/About.stories.d.ts.map +1 -0
  8. package/dist/types/src/components/About/index.d.ts +2 -0
  9. package/dist/types/src/components/About/index.d.ts.map +1 -0
  10. package/dist/types/src/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -1
  11. package/dist/types/src/components/ControlFrame/ControlFrame.d.ts +8 -2
  12. package/dist/types/src/components/ControlFrame/ControlFrame.d.ts.map +1 -1
  13. package/dist/types/src/components/Layers/Layers.d.ts +30 -0
  14. package/dist/types/src/components/Layers/Layers.d.ts.map +1 -0
  15. package/dist/types/src/components/Layers/Layers.stories.d.ts +11 -0
  16. package/dist/types/src/components/Layers/Layers.stories.d.ts.map +1 -0
  17. package/dist/types/src/components/Layers/index.d.ts +2 -0
  18. package/dist/types/src/components/Layers/index.d.ts.map +1 -0
  19. package/dist/types/src/components/Palette/Palette.d.ts +20 -4
  20. package/dist/types/src/components/Palette/Palette.d.ts.map +1 -1
  21. package/dist/types/src/components/Palette/Palette.stories.d.ts +22 -0
  22. package/dist/types/src/components/Palette/Palette.stories.d.ts.map +1 -0
  23. package/dist/types/src/components/PartEditor/PartEditor.d.ts.map +1 -1
  24. package/dist/types/src/components/Properties/AlignField.d.ts +23 -0
  25. package/dist/types/src/components/Properties/AlignField.d.ts.map +1 -0
  26. package/dist/types/src/components/Properties/ClassField.d.ts +23 -0
  27. package/dist/types/src/components/Properties/ClassField.d.ts.map +1 -0
  28. package/dist/types/src/components/Properties/FontField.d.ts +7 -0
  29. package/dist/types/src/components/Properties/FontField.d.ts.map +1 -0
  30. package/dist/types/src/components/Properties/LayerField.d.ts +17 -0
  31. package/dist/types/src/components/Properties/LayerField.d.ts.map +1 -0
  32. package/dist/types/src/components/Properties/Properties.d.ts +27 -3
  33. package/dist/types/src/components/Properties/Properties.d.ts.map +1 -1
  34. package/dist/types/src/components/Properties/Properties.stories.d.ts +20 -0
  35. package/dist/types/src/components/Properties/Properties.stories.d.ts.map +1 -0
  36. package/dist/types/src/components/Properties/SceneField.d.ts +8 -0
  37. package/dist/types/src/components/Properties/SceneField.d.ts.map +1 -0
  38. package/dist/types/src/components/Properties/StyleGrid.d.ts +15 -5
  39. package/dist/types/src/components/Properties/StyleGrid.d.ts.map +1 -1
  40. package/dist/types/src/components/Properties/StyleGrid.stories.d.ts +7 -0
  41. package/dist/types/src/components/Properties/StyleGrid.stories.d.ts.map +1 -0
  42. package/dist/types/src/components/SceneLayer/SceneLayer.d.ts +31 -7
  43. package/dist/types/src/components/SceneLayer/SceneLayer.d.ts.map +1 -1
  44. package/dist/types/src/components/SceneView/Dock.d.ts +22 -0
  45. package/dist/types/src/components/SceneView/Dock.d.ts.map +1 -0
  46. package/dist/types/src/components/SceneView/Dock.stories.d.ts +15 -0
  47. package/dist/types/src/components/SceneView/Dock.stories.d.ts.map +1 -0
  48. package/dist/types/src/components/SceneView/SceneView.d.ts +33 -7
  49. package/dist/types/src/components/SceneView/SceneView.d.ts.map +1 -1
  50. package/dist/types/src/components/SceneView/SceneView.stories.d.ts +16 -6
  51. package/dist/types/src/components/SceneView/SceneView.stories.d.ts.map +1 -1
  52. package/dist/types/src/components/SceneView/SceneViewContext.d.ts +36 -4
  53. package/dist/types/src/components/SceneView/SceneViewContext.d.ts.map +1 -1
  54. package/dist/types/src/components/SceneView/Scored.stories.d.ts +4 -4
  55. package/dist/types/src/components/SceneView/Scored.stories.d.ts.map +1 -1
  56. package/dist/types/src/components/SceneView/usePointerMachine.d.ts +3 -1
  57. package/dist/types/src/components/SceneView/usePointerMachine.d.ts.map +1 -1
  58. package/dist/types/src/components/SceneView/useSceneCamera.d.ts +2 -2
  59. package/dist/types/src/components/SceneView/useSceneCamera.d.ts.map +1 -1
  60. package/dist/types/src/components/SceneView/useSceneClipboard.d.ts.map +1 -1
  61. package/dist/types/src/components/SceneView/useSceneKeys.d.ts.map +1 -1
  62. package/dist/types/src/components/SceneView/useSceneNavigation.d.ts +7 -4
  63. package/dist/types/src/components/SceneView/useSceneNavigation.d.ts.map +1 -1
  64. package/dist/types/src/components/Toolbar/Toolbar.d.ts +2 -0
  65. package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
  66. package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
  67. package/dist/types/src/components/index.d.ts +2 -0
  68. package/dist/types/src/components/index.d.ts.map +1 -1
  69. package/dist/types/src/hooks/useWheel.d.ts +5 -0
  70. package/dist/types/src/hooks/useWheel.d.ts.map +1 -1
  71. package/dist/types/src/index.d.ts +4 -1
  72. package/dist/types/src/index.d.ts.map +1 -1
  73. package/dist/types/src/model/atoms.d.ts +21 -3
  74. package/dist/types/src/model/atoms.d.ts.map +1 -1
  75. package/dist/types/src/model/projection.d.ts.map +1 -1
  76. package/dist/types/src/model/projections/constrained.d.ts +2 -1
  77. package/dist/types/src/model/projections/constrained.d.ts.map +1 -1
  78. package/dist/types/src/model/projections/dynamic.d.ts.map +1 -1
  79. package/dist/types/src/model/projections/lattice.d.ts.map +1 -1
  80. package/dist/types/src/model/registry.d.ts +21 -3
  81. package/dist/types/src/model/registry.d.ts.map +1 -1
  82. package/dist/types/src/model/store.d.ts +10 -2
  83. package/dist/types/src/model/store.d.ts.map +1 -1
  84. package/dist/types/src/model/types.d.ts +699 -338
  85. package/dist/types/src/model/types.d.ts.map +1 -1
  86. package/dist/types/src/playwright/SceneManager.d.ts +1 -1
  87. package/dist/types/src/playwright/SceneManager.d.ts.map +1 -1
  88. package/dist/types/src/utils/builder.d.ts +22 -41
  89. package/dist/types/src/utils/builder.d.ts.map +1 -1
  90. package/dist/types/src/utils/camera.d.ts +19 -10
  91. package/dist/types/src/utils/camera.d.ts.map +1 -1
  92. package/dist/types/src/utils/clipboard.d.ts +4 -2
  93. package/dist/types/src/utils/clipboard.d.ts.map +1 -1
  94. package/dist/types/src/utils/diagram.d.ts.map +1 -1
  95. package/dist/types/src/utils/group.d.ts +14 -0
  96. package/dist/types/src/utils/group.d.ts.map +1 -0
  97. package/dist/types/src/utils/group.test.d.ts +2 -0
  98. package/dist/types/src/utils/group.test.d.ts.map +1 -0
  99. package/dist/types/src/utils/gutter-route.d.ts +7 -0
  100. package/dist/types/src/utils/gutter-route.d.ts.map +1 -1
  101. package/dist/types/src/utils/hit.d.ts +3 -20
  102. package/dist/types/src/utils/hit.d.ts.map +1 -1
  103. package/dist/types/src/utils/index.d.ts +3 -0
  104. package/dist/types/src/utils/index.d.ts.map +1 -1
  105. package/dist/types/src/utils/lattice.d.ts +5 -0
  106. package/dist/types/src/utils/lattice.d.ts.map +1 -1
  107. package/dist/types/src/utils/layers.d.ts +35 -0
  108. package/dist/types/src/utils/layers.d.ts.map +1 -0
  109. package/dist/types/src/utils/layers.test.d.ts +2 -0
  110. package/dist/types/src/utils/layers.test.d.ts.map +1 -0
  111. package/dist/types/src/utils/parts.d.ts +25 -9
  112. package/dist/types/src/utils/parts.d.ts.map +1 -1
  113. package/dist/types/src/utils/ports.d.ts +2 -0
  114. package/dist/types/src/utils/ports.d.ts.map +1 -1
  115. package/dist/types/src/utils/properties.d.ts +4 -3
  116. package/dist/types/src/utils/properties.d.ts.map +1 -1
  117. package/dist/types/src/utils/route.d.ts +5 -0
  118. package/dist/types/src/utils/route.d.ts.map +1 -1
  119. package/dist/types/src/utils/scenes.d.ts +14 -0
  120. package/dist/types/src/utils/scenes.d.ts.map +1 -0
  121. package/dist/types/src/utils/scenes.test.d.ts +2 -0
  122. package/dist/types/src/utils/scenes.test.d.ts.map +1 -0
  123. package/dist/types/src/utils/shapes.d.ts +23 -9
  124. package/dist/types/src/utils/shapes.d.ts.map +1 -1
  125. package/dist/types/src/utils/style.d.ts +29 -6
  126. package/dist/types/src/utils/style.d.ts.map +1 -1
  127. package/dist/types/src/utils/style.test.d.ts +2 -0
  128. package/dist/types/src/utils/style.test.d.ts.map +1 -0
  129. package/dist/types/src/utils/testing.d.ts +2 -2
  130. package/dist/types/src/utils/testing.d.ts.map +1 -1
  131. package/dist/types/src/utils/undo.d.ts +15 -4
  132. package/dist/types/src/utils/undo.d.ts.map +1 -1
  133. package/dist/types/tsconfig.tsbuildinfo +1 -1
  134. package/package.json +20 -26
  135. package/src/components/About/About.stories.tsx +32 -0
  136. package/src/components/About/About.tsx +38 -0
  137. package/src/components/About/index.ts +5 -0
  138. package/src/components/Breadcrumbs/Breadcrumbs.tsx +17 -3
  139. package/src/components/ControlFrame/ControlFrame.tsx +138 -82
  140. package/src/components/Grid/Grid.stories.tsx +1 -1
  141. package/src/components/Layers/Layers.stories.tsx +131 -0
  142. package/src/components/Layers/Layers.tsx +216 -0
  143. package/src/components/Layers/index.ts +5 -0
  144. package/src/components/Palette/Palette.stories.tsx +135 -0
  145. package/src/components/Palette/Palette.tsx +230 -39
  146. package/src/components/PartEditor/PartEditor.tsx +4 -3
  147. package/src/components/Properties/AlignField.tsx +117 -0
  148. package/src/components/Properties/ClassField.tsx +44 -0
  149. package/src/components/Properties/FontField.tsx +73 -0
  150. package/src/components/Properties/LayerField.tsx +61 -0
  151. package/src/components/Properties/Properties.stories.tsx +113 -0
  152. package/src/components/Properties/Properties.tsx +240 -60
  153. package/src/components/Properties/SceneField.tsx +22 -0
  154. package/src/components/Properties/StyleGrid.stories.tsx +50 -0
  155. package/src/components/Properties/StyleGrid.tsx +54 -26
  156. package/src/components/SceneLayer/SceneLayer.tsx +497 -188
  157. package/src/components/SceneView/Constrained.stories.tsx +1 -1
  158. package/src/components/SceneView/Dock.stories.tsx +95 -0
  159. package/src/components/SceneView/Dock.tsx +146 -0
  160. package/src/components/SceneView/Dynamic.stories.tsx +1 -1
  161. package/src/components/SceneView/SceneView.stories.tsx +170 -18
  162. package/src/components/SceneView/SceneView.tsx +565 -151
  163. package/src/components/SceneView/SceneViewContext.ts +23 -2
  164. package/src/components/SceneView/Scored.stories.tsx +11 -11
  165. package/src/components/SceneView/usePointerMachine.ts +80 -20
  166. package/src/components/SceneView/useSceneCamera.ts +15 -8
  167. package/src/components/SceneView/useSceneClipboard.ts +4 -0
  168. package/src/components/SceneView/useSceneKeys.ts +6 -4
  169. package/src/components/SceneView/useSceneNavigation.ts +44 -33
  170. package/src/components/Toolbar/Toolbar.stories.tsx +14 -15
  171. package/src/components/Toolbar/Toolbar.tsx +15 -4
  172. package/src/components/index.ts +2 -0
  173. package/src/hooks/useWheel.ts +35 -0
  174. package/src/index.ts +4 -3
  175. package/src/model/atoms.ts +27 -3
  176. package/src/model/projection.ts +5 -0
  177. package/src/model/projections/constrained.test.ts +18 -10
  178. package/src/model/projections/constrained.ts +19 -8
  179. package/src/model/projections/dynamic.ts +5 -2
  180. package/src/model/projections/lattice.ts +15 -3
  181. package/src/model/registry.test.ts +14 -9
  182. package/src/model/registry.ts +39 -34
  183. package/src/model/store.ts +12 -2
  184. package/src/model/types.ts +124 -36
  185. package/src/playwright/SceneManager.ts +2 -2
  186. package/src/playwright/create.spec.ts +5 -3
  187. package/src/playwright/demo.spec.ts +2 -2
  188. package/src/playwright/lattice.spec.ts +4 -5
  189. package/src/playwright/scene.spec.ts +27 -25
  190. package/src/utils/builder.test.ts +46 -9
  191. package/src/utils/builder.ts +42 -18
  192. package/src/utils/camera.test.ts +22 -51
  193. package/src/utils/camera.ts +39 -32
  194. package/src/utils/clipboard.test.ts +23 -2
  195. package/src/utils/clipboard.ts +8 -4
  196. package/src/utils/diagram.test.ts +3 -1
  197. package/src/utils/diagram.ts +3 -25
  198. package/src/utils/group.test.ts +69 -0
  199. package/src/utils/group.ts +94 -0
  200. package/src/utils/gutter-route.test.ts +49 -0
  201. package/src/utils/gutter-route.ts +142 -16
  202. package/src/utils/hit.test.ts +4 -38
  203. package/src/utils/hit.ts +4 -26
  204. package/src/utils/index.ts +3 -0
  205. package/src/utils/lattice.ts +7 -1
  206. package/src/utils/layers.test.ts +139 -0
  207. package/src/utils/layers.ts +175 -0
  208. package/src/utils/parts.test.ts +66 -32
  209. package/src/utils/parts.ts +63 -41
  210. package/src/utils/ports.ts +7 -4
  211. package/src/utils/properties.test.ts +23 -6
  212. package/src/utils/properties.ts +66 -16
  213. package/src/utils/route.test.ts +51 -2
  214. package/src/utils/route.ts +133 -23
  215. package/src/utils/scenes.test.ts +38 -0
  216. package/src/utils/scenes.ts +54 -0
  217. package/src/utils/shapes.test.ts +33 -8
  218. package/src/utils/shapes.ts +51 -35
  219. package/src/utils/style.test.ts +54 -0
  220. package/src/utils/style.ts +153 -40
  221. package/src/utils/testing.test.ts +3 -3
  222. package/src/utils/testing.ts +21 -30
  223. package/src/utils/undo.test.ts +25 -2
  224. package/src/utils/undo.ts +50 -13
  225. package/dist/lib/chunk-Grid.mjs +0 -99
  226. package/dist/lib/chunk-Grid.mjs.map +0 -1
  227. package/dist/lib/scene.mjs +0 -6835
  228. package/dist/lib/scene.mjs.map +0 -1
  229. package/dist/lib/types.mjs +0 -16
  230. package/dist/lib/types.mjs.map +0 -1
  231. package/dist/types/src/archive/components/Canvas/Canvas.d.ts +0 -15
  232. package/dist/types/src/archive/components/Canvas/Canvas.d.ts.map +0 -1
  233. package/dist/types/src/archive/components/Canvas/Canvas.stories.d.ts +0 -18
  234. package/dist/types/src/archive/components/Canvas/Canvas.stories.d.ts.map +0 -1
  235. package/dist/types/src/archive/components/Canvas/index.d.ts +0 -2
  236. package/dist/types/src/archive/components/Canvas/index.d.ts.map +0 -1
  237. package/dist/types/src/archive/components/CellGrid/CellGrid.d.ts +0 -21
  238. package/dist/types/src/archive/components/CellGrid/CellGrid.d.ts.map +0 -1
  239. package/dist/types/src/archive/components/CellGrid/CellGrid.stories.d.ts +0 -21
  240. package/dist/types/src/archive/components/CellGrid/CellGrid.stories.d.ts.map +0 -1
  241. package/dist/types/src/archive/components/CellGrid/headers/Ruler.d.ts +0 -15
  242. package/dist/types/src/archive/components/CellGrid/headers/Ruler.d.ts.map +0 -1
  243. package/dist/types/src/archive/components/CellGrid/headers/TrackHeader.d.ts +0 -19
  244. package/dist/types/src/archive/components/CellGrid/headers/TrackHeader.d.ts.map +0 -1
  245. package/dist/types/src/archive/components/CellGrid/headers/index.d.ts +0 -3
  246. package/dist/types/src/archive/components/CellGrid/headers/index.d.ts.map +0 -1
  247. package/dist/types/src/archive/components/CellGrid/index.d.ts +0 -6
  248. package/dist/types/src/archive/components/CellGrid/index.d.ts.map +0 -1
  249. package/dist/types/src/archive/components/CellGrid/input/index.d.ts +0 -3
  250. package/dist/types/src/archive/components/CellGrid/input/index.d.ts.map +0 -1
  251. package/dist/types/src/archive/components/CellGrid/input/pointer.d.ts +0 -33
  252. package/dist/types/src/archive/components/CellGrid/input/pointer.d.ts.map +0 -1
  253. package/dist/types/src/archive/components/CellGrid/input/wheel.d.ts +0 -14
  254. package/dist/types/src/archive/components/CellGrid/input/wheel.d.ts.map +0 -1
  255. package/dist/types/src/archive/components/CellGrid/render/index.d.ts +0 -3
  256. package/dist/types/src/archive/components/CellGrid/render/index.d.ts.map +0 -1
  257. package/dist/types/src/archive/components/CellGrid/render/overlay-layer.d.ts +0 -21
  258. package/dist/types/src/archive/components/CellGrid/render/overlay-layer.d.ts.map +0 -1
  259. package/dist/types/src/archive/components/CellGrid/render/static-layer.d.ts +0 -36
  260. package/dist/types/src/archive/components/CellGrid/render/static-layer.d.ts.map +0 -1
  261. package/dist/types/src/archive/components/CellGrid/state/atoms.d.ts +0 -23
  262. package/dist/types/src/archive/components/CellGrid/state/atoms.d.ts.map +0 -1
  263. package/dist/types/src/archive/components/CellGrid/state/index.d.ts +0 -4
  264. package/dist/types/src/archive/components/CellGrid/state/index.d.ts.map +0 -1
  265. package/dist/types/src/archive/components/CellGrid/state/types.d.ts +0 -39
  266. package/dist/types/src/archive/components/CellGrid/state/types.d.ts.map +0 -1
  267. package/dist/types/src/archive/components/CellGrid/state/viewport.d.ts +0 -52
  268. package/dist/types/src/archive/components/CellGrid/state/viewport.d.ts.map +0 -1
  269. package/dist/types/src/archive/components/CellGrid/state/viewport.test.d.ts +0 -2
  270. package/dist/types/src/archive/components/CellGrid/state/viewport.test.d.ts.map +0 -1
  271. package/dist/types/src/archive/components/FPS.d.ts +0 -9
  272. package/dist/types/src/archive/components/FPS.d.ts.map +0 -1
  273. package/dist/types/src/archive/components/Grid/Grid.d.ts +0 -6
  274. package/dist/types/src/archive/components/Grid/Grid.d.ts.map +0 -1
  275. package/dist/types/src/archive/components/Grid/index.d.ts +0 -2
  276. package/dist/types/src/archive/components/Grid/index.d.ts.map +0 -1
  277. package/dist/types/src/archive/components/index.d.ts +0 -5
  278. package/dist/types/src/archive/components/index.d.ts.map +0 -1
  279. package/dist/types/src/archive/hooks/index.d.ts +0 -5
  280. package/dist/types/src/archive/hooks/index.d.ts.map +0 -1
  281. package/dist/types/src/archive/hooks/projection.d.ts +0 -60
  282. package/dist/types/src/archive/hooks/projection.d.ts.map +0 -1
  283. package/dist/types/src/archive/hooks/useCanvasContext.d.ts +0 -13
  284. package/dist/types/src/archive/hooks/useCanvasContext.d.ts.map +0 -1
  285. package/dist/types/src/archive/hooks/useDrag.d.ts +0 -6
  286. package/dist/types/src/archive/hooks/useDrag.d.ts.map +0 -1
  287. package/dist/types/src/archive/hooks/useWheel.d.ts +0 -8
  288. package/dist/types/src/archive/hooks/useWheel.d.ts.map +0 -1
  289. package/dist/types/src/archive/index.d.ts +0 -5
  290. package/dist/types/src/archive/index.d.ts.map +0 -1
  291. package/dist/types/src/archive/types.d.ts +0 -19
  292. package/dist/types/src/archive/types.d.ts.map +0 -1
  293. package/dist/types/src/archive/util/index.d.ts +0 -4
  294. package/dist/types/src/archive/util/index.d.ts.map +0 -1
  295. package/dist/types/src/archive/util/svg-path.d.ts +0 -4
  296. package/dist/types/src/archive/util/svg-path.d.ts.map +0 -1
  297. package/dist/types/src/archive/util/svg.d.ts +0 -32
  298. package/dist/types/src/archive/util/svg.d.ts.map +0 -1
  299. package/dist/types/src/archive/util/svg.stories.d.ts +0 -15
  300. package/dist/types/src/archive/util/svg.stories.d.ts.map +0 -1
  301. package/dist/types/src/archive/util/util.d.ts +0 -17
  302. package/dist/types/src/archive/util/util.d.ts.map +0 -1
  303. package/dist/types/src/scene.d.ts +0 -5
  304. package/dist/types/src/scene.d.ts.map +0 -1
  305. package/src/archive/components/Canvas/Canvas.stories.tsx +0 -111
  306. package/src/archive/components/Canvas/Canvas.tsx +0 -87
  307. package/src/archive/components/Canvas/index.ts +0 -5
  308. package/src/archive/components/CellGrid/CellGrid.stories.tsx +0 -239
  309. package/src/archive/components/CellGrid/CellGrid.tsx +0 -270
  310. package/src/archive/components/CellGrid/headers/Ruler.tsx +0 -71
  311. package/src/archive/components/CellGrid/headers/TrackHeader.tsx +0 -58
  312. package/src/archive/components/CellGrid/headers/index.ts +0 -6
  313. package/src/archive/components/CellGrid/index.ts +0 -9
  314. package/src/archive/components/CellGrid/input/index.ts +0 -6
  315. package/src/archive/components/CellGrid/input/pointer.ts +0 -236
  316. package/src/archive/components/CellGrid/input/wheel.ts +0 -68
  317. package/src/archive/components/CellGrid/render/index.ts +0 -6
  318. package/src/archive/components/CellGrid/render/overlay-layer.ts +0 -66
  319. package/src/archive/components/CellGrid/render/static-layer.ts +0 -112
  320. package/src/archive/components/CellGrid/state/atoms.ts +0 -43
  321. package/src/archive/components/CellGrid/state/index.ts +0 -7
  322. package/src/archive/components/CellGrid/state/types.ts +0 -40
  323. package/src/archive/components/CellGrid/state/viewport.test.ts +0 -50
  324. package/src/archive/components/CellGrid/state/viewport.ts +0 -94
  325. package/src/archive/components/FPS.tsx +0 -98
  326. package/src/archive/components/Grid/Grid.tsx +0 -17
  327. package/src/archive/components/Grid/index.ts +0 -5
  328. package/src/archive/components/index.ts +0 -8
  329. package/src/archive/hooks/index.ts +0 -8
  330. package/src/archive/hooks/projection.tsx +0 -156
  331. package/src/archive/hooks/useCanvasContext.ts +0 -32
  332. package/src/archive/hooks/useDrag.tsx +0 -96
  333. package/src/archive/hooks/useWheel.tsx +0 -80
  334. package/src/archive/index.ts +0 -8
  335. package/src/archive/types.ts +0 -14
  336. package/src/archive/util/index.ts +0 -7
  337. package/src/archive/util/svg-path.ts +0 -13
  338. package/src/archive/util/svg.stories.tsx +0 -46
  339. package/src/archive/util/svg.tsx +0 -128
  340. package/src/archive/util/util.ts +0 -50
  341. package/src/scene.ts +0 -9
@@ -3,65 +3,87 @@
3
3
  //
4
4
 
5
5
  //
6
- // Text parts of a node: the pieces a node type renders as text and lets the user edit in place. A
7
- // part is named by the node property it edits; list properties read and write as one entry per line.
6
+ // Text parts of a node: the properties a node type renders as text and lets the user edit in place. Each
7
+ // type declares its parts in its registry definition, so a type a host adds edits the same way as a built-in
8
+ // one. A part is named by the property it edits; a `lines` part reads and writes a list one entry per line.
8
9
  //
9
10
 
10
- import { type Node, type NodeValues, isBoxNode, isClassNode, isEllipseNode, isNoteNode } from '../model/types.ts';
11
+ import { nodeDef } from '../model/node-def.ts';
12
+ import { type NodeRegistry } from '../model/registry.ts';
13
+ import { type Node, type NodeValues } from '../model/types.ts';
11
14
 
12
- export type PartKey = 'label' | 'text' | 'name' | 'attributes' | 'methods';
15
+ /** A part's name: the node property it edits. */
16
+ export type PartKey = string;
17
+
18
+ /** A text property of a node type, as its definition declares it. */
19
+ export type PartField = {
20
+ /** The node property the part edits. */
21
+ field: PartKey;
22
+ /** A list of strings, one per line; implies `multiline`. */
23
+ lines?: boolean;
24
+ /** Enter breaks the line and Mod-Enter commits, rather than Enter committing. */
25
+ multiline?: boolean;
26
+ };
13
27
 
14
28
  /** A part edited in place: which one, and how the editor hands its result back. */
15
29
  export type PartEditing = {
16
30
  part: PartKey;
31
+ multiline: boolean;
17
32
  commit: (text: string) => void;
18
33
  cancel: () => void;
19
34
  };
20
35
 
21
- const PARTS: readonly PartKey[] = ['label', 'text', 'name', 'attributes', 'methods'];
22
-
23
- /** The part a `data-part` attribute names, if it names one. */
24
- export const partKey = (value: string | null | undefined): PartKey | undefined => PARTS.find((part) => part === value);
25
-
26
- const LINES: readonly PartKey[] = ['text', 'attributes', 'methods'];
27
-
28
- /** Enter inserts a line in a multi-line part; Mod-Enter commits. */
29
- export const isMultiline = (part: PartKey): boolean => LINES.includes(part);
30
-
31
- const lines = (text: string): string[] =>
36
+ const toLines = (text: string): string[] =>
32
37
  text
33
38
  .split('\n')
34
39
  .map((line) => line.trim())
35
40
  .filter((line) => line.length > 0);
36
41
 
37
- /** The part's text, or none when the node type has no such part. */
38
- export const partText = (node: Node, part: PartKey): string | undefined => {
39
- switch (part) {
40
- case 'label':
41
- return isBoxNode(node) || isEllipseNode(node) ? (node.label ?? '') : undefined;
42
- case 'text':
43
- return isNoteNode(node) ? node.text : undefined;
44
- case 'name':
45
- return isClassNode(node) ? node.name : undefined;
46
- case 'attributes':
47
- return isClassNode(node) ? node.attributes.join('\n') : undefined;
48
- case 'methods':
49
- return isClassNode(node) ? node.methods.join('\n') : undefined;
42
+ /** The core base's one part: the label a node of an unknown type is edited by (see `BaseNode`). */
43
+ const BASE_PARTS: readonly PartField[] = [{ field: 'label' }];
44
+
45
+ /** The parts a node's type declares; a type the registry does not know has the core base's label. */
46
+ export const nodeParts = (registry: NodeRegistry, node: Node): readonly PartField[] => {
47
+ const def = nodeDef(registry, node);
48
+ return def ? (def.parts ?? []) : BASE_PARTS;
49
+ };
50
+
51
+ const partOf = (registry: NodeRegistry, node: Node, part: PartKey): PartField | undefined =>
52
+ nodeParts(registry, node).find(({ field }) => field === part);
53
+
54
+ /** Whether the part takes several lines. */
55
+ export const isMultiline = (part: PartField): boolean => part.lines === true || part.multiline === true;
56
+
57
+ /** The part's text, or none when the node's type has no such part. */
58
+ export const partText = (registry: NodeRegistry, node: Node, part: PartKey): string | undefined => {
59
+ const field = partOf(registry, node, part);
60
+ if (!field) {
61
+ return undefined;
62
+ }
63
+ const value: unknown = Reflect.get(node, field.field);
64
+ if (field.lines) {
65
+ return Array.isArray(value) ? value.filter((line) => typeof line === 'string').join('\n') : '';
50
66
  }
67
+ return typeof value === 'string' ? value : '';
51
68
  };
52
69
 
53
- /** The `update` values that set the part's text, or none when the node type has no such part. */
54
- export const partValues = (node: Node, part: PartKey, text: string): NodeValues | undefined => {
55
- switch (part) {
56
- case 'label':
57
- return isBoxNode(node) || isEllipseNode(node) ? { label: text } : undefined;
58
- case 'text':
59
- return isNoteNode(node) ? { text } : undefined;
60
- case 'name':
61
- return isClassNode(node) ? { name: text.trim() } : undefined;
62
- case 'attributes':
63
- return isClassNode(node) ? { attributes: lines(text) } : undefined;
64
- case 'methods':
65
- return isClassNode(node) ? { methods: lines(text) } : undefined;
70
+ /** The `update` values that set the part's text, or none when the node's type has no such part. */
71
+ export const partValues = (registry: NodeRegistry, node: Node, part: PartKey, text: string): NodeValues | undefined => {
72
+ const field = partOf(registry, node, part);
73
+ if (!field) {
74
+ return undefined;
66
75
  }
76
+ return { [field.field]: field.lines ? toLines(text) : text };
77
+ };
78
+
79
+ /** A node's main text: its first part's, which a diagram or a DSL export shows as the node's name. */
80
+ export const nodeTitle = (registry: NodeRegistry, node: Node): string | undefined => {
81
+ const [first] = nodeParts(registry, node);
82
+ return first ? partText(registry, node, first.field) : undefined;
83
+ };
84
+
85
+ /** The node with its main text (its first part) set; a type without parts is returned as is. */
86
+ export const withTitle = <N extends Node>(registry: NodeRegistry, node: N, text: string): N => {
87
+ const [first] = nodeParts(registry, node);
88
+ return first ? { ...node, ...partValues(registry, node, first.field, text) } : node;
67
89
  };
@@ -128,8 +128,8 @@ const distance2 = (left: Point, right: Point) => (left.x - right.x) ** 2 + (left
128
128
  * source end only leaves a port that accepts `out`, the target end only lands on one that accepts `in`.
129
129
  */
130
130
  export const pairPorts = (source: PortTerminal, target: PortTerminal): PortPair | undefined => {
131
- const sources = candidates(source, 'out');
132
- const targets = candidates(target, 'in');
131
+ const sources = portCandidates(source, 'out');
132
+ const targets = portCandidates(target, 'in');
133
133
  if (sources.length === 0 || targets.length === 0) {
134
134
  return undefined;
135
135
  }
@@ -156,7 +156,7 @@ export const nearestPort = (
156
156
  ): Port | undefined => {
157
157
  let best: Port | undefined;
158
158
  let bestDistance = Infinity;
159
- for (const port of candidates(terminal, direction)) {
159
+ for (const port of portCandidates(terminal, direction)) {
160
160
  const value = distance2(portPoint(terminal.bounds, port), point);
161
161
  if (value < bestDistance) {
162
162
  bestDistance = value;
@@ -167,7 +167,10 @@ export const nearestPort = (
167
167
  };
168
168
 
169
169
  /** The ports an end may use: those accepting its direction, narrowed to the pinned one when it is among them. */
170
- const candidates = ({ ports, port }: PortTerminal, direction: Exclude<PortDirection, 'any'>): readonly Port[] => {
170
+ export const portCandidates = (
171
+ { ports, port }: PortTerminal,
172
+ direction: Exclude<PortDirection, 'any'>,
173
+ ): readonly Port[] => {
171
174
  const allowed = ports.filter((candidate) => portAccepts(candidate, direction));
172
175
  if (port === undefined) {
173
176
  return allowed;
@@ -7,7 +7,7 @@ import { describe, test } from 'vitest';
7
7
 
8
8
  import { getPropertySignatures } from '@dxos/effect/SchemaAST';
9
9
 
10
- import { ClassNode, EllipseNode, LineLink, RectNode } from '../model/types.ts';
10
+ import { EllipseNode, LineLink, NoteNode, RectNode } from '../model/types.ts';
11
11
  import { commonSchema, mergeValues, patchValues } from './properties.ts';
12
12
 
13
13
  const names = (schema: Schema.Codec<any, any> | undefined) =>
@@ -24,12 +24,20 @@ describe('properties', () => {
24
24
  expect(rectEllipse).toContain('style');
25
25
  expect(rectEllipse).not.toContain('type');
26
26
 
27
- const rectClass = names(commonSchema([RectNode, ClassNode]));
28
- expect(rectClass).toContain('style');
29
- expect(rectClass).not.toContain('label');
27
+ const rectNote = names(commonSchema([RectNode, NoteNode]));
28
+ expect(rectNote).toContain('style');
29
+ expect(rectNote).not.toContain('label');
30
30
 
31
- // A node and a link share only what both declare: here `locked` (and the hidden `id`/`z`).
32
- expect(names(commonSchema([RectNode, LineLink]))).toEqual(['id', 'z', 'locked']);
31
+ // A node and a link share only what both declare: `locked`, `layer`, `class` and `style` (and the hidden `id`/`z`),
32
+ // the style narrowed to the common base both kinds' styles extend.
33
+ const shared = commonSchema([RectNode, LineLink]);
34
+ expect(names(shared)).toEqual(['id', 'z', 'locked', 'layer', 'class', 'style']);
35
+ const style = shared && getPropertySignatures(shared.ast).find((property) => property.name === 'style');
36
+ const struct = style?.type._tag === 'Union' ? style.type.types.find((type) => type._tag === 'Objects') : undefined;
37
+ expect(struct?._tag === 'Objects' && struct.propertySignatures.map((property) => property.name)).toEqual([
38
+ 'hue',
39
+ 'lineStyle',
40
+ ]);
33
41
  expect(commonSchema([Schema.Struct({ a: Schema.String }), Schema.Struct({ b: Schema.String })])).toBeUndefined();
34
42
  });
35
43
 
@@ -52,6 +60,15 @@ describe('properties', () => {
52
60
  expect([...mixed].sort()).toEqual(['center.x', 'style.hue']);
53
61
  });
54
62
 
63
+ test('an instance value (a reference) is kept whole, not merged field by field', ({ expect }) => {
64
+ class Reference {
65
+ constructor(readonly uri: string) {}
66
+ }
67
+ const reference = new Reference('echo://space/object');
68
+ const { values } = mergeValues([{ drawing: reference }], ['drawing']);
69
+ expect(values.drawing).toBe(reference);
70
+ });
71
+
55
72
  test('a patch writes only the changed paths and keeps the rest of a nested value', ({ expect }) => {
56
73
  const element = { label: 'A', style: { hue: 'red', rounded: true }, center: { x: 0, y: 16 } };
57
74
  const values = { label: 'B', style: { hue: 'blue', rounded: false }, center: { x: 48, y: 0 } };
@@ -8,14 +8,20 @@
8
8
  //
9
9
 
10
10
  import * as Schema from 'effect/Schema';
11
- import type * as SchemaAST from 'effect/SchemaAST';
11
+ import * as SchemaAST from 'effect/SchemaAST';
12
12
 
13
- import { getChecks, getPropertySignatures, pick } from '@dxos/effect/SchemaAST';
13
+ import { getChecks, getPropertySignatures } from '@dxos/effect/SchemaAST';
14
14
 
15
15
  type Values = Readonly<Record<string, unknown>>;
16
16
 
17
- const isPlainObject = (value: unknown): value is Values =>
18
- typeof value === 'object' && value !== null && !Array.isArray(value);
17
+ /** A record of fields to merge into; an instance (an ECHO `Ref`, a date) is a value, compared and kept whole. */
18
+ const isPlainObject = (value: unknown): value is Values => {
19
+ if (typeof value !== 'object' || value === null || Array.isArray(value)) {
20
+ return false;
21
+ }
22
+ const prototype = Object.getPrototypeOf(value);
23
+ return prototype === Object.prototype || prototype === null;
24
+ };
19
25
 
20
26
  const isOptional = (ast: SchemaAST.AST) => ast.context?.isOptional ?? false;
21
27
 
@@ -76,24 +82,68 @@ const sameList = (left: readonly SchemaAST.AST[], right: readonly SchemaAST.AST[
76
82
  left.length === right.length && left.every((type, index) => sameType(type, right[index]));
77
83
 
78
84
  /**
79
- * The properties every schema declares alike, as one struct over the first schema's declarations;
80
- * `undefined` when they share none. One schema is its own common schema.
85
+ * What two property types share: the type itself when they are the same, and for two structs (a node's style and a
86
+ * link's, say) the struct of the fields they declare alike, recursively; none when they share nothing.
87
+ */
88
+ const commonType = (left: SchemaAST.AST, right: SchemaAST.AST): SchemaAST.AST | undefined => {
89
+ if (sameType(left, right)) {
90
+ return left;
91
+ }
92
+ // An optional struct is a union with `undefined`: intersect member by member.
93
+ if (left._tag === 'Union' && right._tag === 'Union' && left.types.length === right.types.length) {
94
+ const types = left.types.map((type, index) => commonType(type, right.types[index]));
95
+ return types.every((type) => type !== undefined)
96
+ ? new SchemaAST.Union(
97
+ types.flatMap((type) => (type ? [type] : [])),
98
+ left.options,
99
+ left.annotations,
100
+ left.checks,
101
+ left.encoding,
102
+ left.context,
103
+ left.encodingChecks,
104
+ )
105
+ : undefined;
106
+ }
107
+ if (left._tag !== 'Objects' || right._tag !== 'Objects' || isOptional(left) !== isOptional(right)) {
108
+ return undefined;
109
+ }
110
+ const shared = left.propertySignatures.flatMap((property) => {
111
+ const other = right.propertySignatures.find((candidate) => candidate.name === property.name);
112
+ const type = other && commonType(property.type, other.type);
113
+ return type ? [new SchemaAST.PropertySignature(property.name, type)] : [];
114
+ });
115
+ return shared.length > 0
116
+ ? new SchemaAST.Objects(
117
+ shared,
118
+ left.indexSignatures,
119
+ left.annotations,
120
+ left.checks,
121
+ left.encoding,
122
+ left.context,
123
+ left.encodingChecks,
124
+ )
125
+ : undefined;
126
+ };
127
+
128
+ /**
129
+ * The properties every schema declares alike, as one struct over the first schema's declarations, a struct
130
+ * property narrowed to the fields every schema's declares alike; `undefined` when they share none. One schema is
131
+ * its own common schema.
81
132
  */
82
133
  export const commonSchema = (schemas: readonly Schema.Codec<any, any>[]): Schema.Codec<any, any> | undefined => {
83
134
  const [first, ...rest] = schemas;
84
135
  if (!first) {
85
136
  return undefined;
86
137
  }
87
- const shared = getPropertySignatures(first.ast)
88
- .filter((property) =>
89
- rest.every((schema) =>
90
- getPropertySignatures(schema.ast).some(
91
- (other) => other.name === property.name && sameType(property.type, other.type),
92
- ),
93
- ),
94
- )
95
- .map((property) => property.name);
96
- return shared.length > 0 ? Schema.make<Schema.Codec<any, any>>(pick(first.ast, shared)) : undefined;
138
+ const shared = getPropertySignatures(first.ast).flatMap((property) => {
139
+ let type: SchemaAST.AST | undefined = property.type;
140
+ for (const schema of rest) {
141
+ const other = getPropertySignatures(schema.ast).find((candidate) => candidate.name === property.name);
142
+ type = type && other ? commonType(type, other.type) : undefined;
143
+ }
144
+ return type ? [new SchemaAST.PropertySignature(property.name, type)] : [];
145
+ });
146
+ return shared.length > 0 ? Schema.make<Schema.Codec<any, any>>(new SchemaAST.Objects(shared, [])) : undefined;
97
147
  };
98
148
 
99
149
  const equal = (left: unknown, right: unknown): boolean => {
@@ -5,9 +5,18 @@
5
5
  import { describe, test } from 'vitest';
6
6
 
7
7
  import { defaultNodeRegistry } from '../model/registry.ts';
8
- import { type Link } from '../model/types.ts';
8
+ import { type Link, type Scene } from '../model/types.ts';
9
9
  import { SceneBuilder } from './builder.ts';
10
- import { insertIndex, linkGeometry, linkPath, sideToward, smartPoints, splinePath } from './route.ts';
10
+ import { DEFAULT_LATTICE } from './lattice.ts';
11
+ import {
12
+ insertIndex,
13
+ linkGeometry,
14
+ linkPath,
15
+ sceneLinkGeometry,
16
+ sideToward,
17
+ smartPoints,
18
+ splinePath,
19
+ } from './route.ts';
11
20
 
12
21
  const from = { point: { x: 0, y: 0 }, side: 'e' as const };
13
22
  const to = { point: { x: 300, y: 0 }, side: 'w' as const };
@@ -98,4 +107,44 @@ describe('route', () => {
98
107
  expect(half?.target).toEqual({ point: { x: 256, y: 64 }, side: 'e' });
99
108
  expect(half?.source.side).toBe('w');
100
109
  });
110
+
111
+ test('a route runs straight to a free end rather than turning to its side', ({ expect }) => {
112
+ const port = { point: { x: 0, y: 0 }, side: 'e' as const };
113
+ const free = { point: { x: 100, y: 37 }, side: 'w' as const, free: true };
114
+ // A curve's control point at a free end is the end itself, and a smart link has no stub there.
115
+ expect(
116
+ linkPath({ type: 'curve', id: 'c', z: 'a', source: { node: 'n' }, target: { point: free.point } }, port, free),
117
+ ).toMatch(/, 100 37, 100 37$/);
118
+ expect(smartPoints(port, free)).toEqual([port.point, { x: 32, y: 0 }, free.point]);
119
+ });
120
+
121
+ test('on a lattice a smart link takes the ports whose gutter route bends least', ({ expect }) => {
122
+ // MESH on (4, 1) and EDGE on (3, 4), as the Composer overview has them: the nearest ports (MESH's bottom-left,
123
+ // EDGE's top-right) need two bends round EDGE's corner; down the free column and in from the east needs one.
124
+ const box = (id: string, col: number, row: number) => ({
125
+ type: 'rect',
126
+ id,
127
+ z: 'a0',
128
+ center: { x: col * 384, y: row * 192 },
129
+ size: { width: 256, height: 128 },
130
+ });
131
+ const link: Link = { type: 'smart', id: 'link', z: 'a1', source: { node: 'mesh' }, target: { node: 'edge' } };
132
+ const scene: Scene = {
133
+ id: 'root',
134
+ nodes: { mesh: box('mesh', 4, 1), edge: box('edge', 3, 4) },
135
+ links: { link },
136
+ };
137
+ const [geometry] = sceneLinkGeometry(scene, defaultNodeRegistry, [link], DEFAULT_LATTICE);
138
+ expect([geometry.source.side, geometry.source.point, geometry.target.side, geometry.target.point]).toEqual([
139
+ 's',
140
+ { x: 1536, y: 256 },
141
+ 'e',
142
+ { x: 1280, y: 768 },
143
+ ]);
144
+ // One bend: a single rounded corner between the two straight runs.
145
+ expect(geometry.path.match(/Q/g)).toHaveLength(1);
146
+ // The same choice for the link on its own, so hit-testing and handles agree with what is drawn.
147
+ const single = linkGeometry(scene, defaultNodeRegistry, link, DEFAULT_LATTICE);
148
+ expect([single?.source.side, single?.target.side]).toEqual(['s', 'e']);
149
+ });
101
150
  });
@@ -25,9 +25,17 @@ import {
25
25
  type Side,
26
26
  isPointEndpoint,
27
27
  } from '../model/types.ts';
28
- import { gutterRoute } from './gutter-route.ts';
28
+ import { gutterRoute, gutterRouteWithCost } from './gutter-route.ts';
29
29
  import { type LatticeSpec } from './lattice.ts';
30
- import { type PortTerminal, nearestPort, nodePorts, pairPorts, portPoint, sideNormal } from './ports.ts';
30
+ import {
31
+ type PortTerminal,
32
+ nearestPort,
33
+ nodePorts,
34
+ pairPorts,
35
+ portCandidates,
36
+ portPoint,
37
+ sideNormal,
38
+ } from './ports.ts';
31
39
  import { nodeBounds } from './shapes.ts';
32
40
 
33
41
  const MIN_TANGENT = 40;
@@ -42,7 +50,11 @@ const CORNER_RADIUS = DEFAULT_GRID;
42
50
  /** How far a smart link's stub leaves its port, in scene px (half a major cell). */
43
51
  const SMART_STUB = MAJOR_GRID / 2;
44
52
 
45
- export type RouteEnd = { point: Point; side: Side };
53
+ /**
54
+ * A resolved link end. `free` marks a point end (one being dragged, or left on empty canvas): it has no port to
55
+ * leave along, so a route runs straight to it rather than turning to its side's normal.
56
+ */
57
+ export type RouteEnd = { point: Point; side: Side; free?: boolean };
46
58
 
47
59
  const pt = ({ x, y }: Point) => `${x} ${y}`;
48
60
 
@@ -52,12 +64,12 @@ const curveControls = (from: RouteEnd, to: RouteEnd): [Point, Point] => {
52
64
  const dx = to.point.x - from.point.x;
53
65
  const dy = to.point.y - from.point.y;
54
66
  const tangent = Math.max(MIN_TANGENT, Math.hypot(dx, dy) * TANGENT_RATIO);
55
- const fromNormal = sideNormal(from.side);
56
- const toNormal = sideNormal(to.side);
57
- return [
58
- { x: from.point.x + fromNormal.x * tangent, y: from.point.y + fromNormal.y * tangent },
59
- { x: to.point.x + toNormal.x * tangent, y: to.point.y + toNormal.y * tangent },
60
- ];
67
+ // A free end takes no tangent: its control point sits on the end, so the curve arrives straight at it.
68
+ const control = (end: RouteEnd): Point => {
69
+ const normal = sideNormal(end.side);
70
+ return end.free ? end.point : { x: end.point.x + normal.x * tangent, y: end.point.y + normal.y * tangent };
71
+ };
72
+ return [control(from), control(to)];
61
73
  };
62
74
 
63
75
  /** Cubic Bézier whose control points sit `tangent` px out along each port's normal. */
@@ -122,7 +134,14 @@ export const linkPath = (link: Link, from: RouteEnd, to: RouteEnd, lattice?: Lat
122
134
  case 'spline':
123
135
  return splinePath([from.point, ...link.points, to.point]);
124
136
  case 'smart':
125
- return splinePath((lattice && gutterRoute(lattice.nodes, lattice.spec, from, to)) ?? smartPoints(from, to));
137
+ // A free end (one being dragged) takes no gutter: the route runs straight to it.
138
+ return splinePath(
139
+ (lattice && !from.free && !to.free && gutterRoute(lattice.nodes, lattice.spec, from, to)) ||
140
+ smartPoints(from, to),
141
+ );
142
+ default:
143
+ // A link type this engine does not know (saved by a newer one) draws as the core base: a straight line.
144
+ return linePath(from.point, to.point);
126
145
  }
127
146
  };
128
147
 
@@ -135,14 +154,12 @@ export type LatticeRoute = { spec: LatticeSpec; nodes: readonly Node[] };
135
154
  * ports imply rather than a straight line cutting across their own nodes.
136
155
  */
137
156
  export const smartPoints = (from: RouteEnd, to: RouteEnd): Point[] => {
138
- const fromNormal = sideNormal(from.side);
139
- const toNormal = sideNormal(to.side);
140
- return [
141
- from.point,
142
- { x: from.point.x + fromNormal.x * SMART_STUB, y: from.point.y + fromNormal.y * SMART_STUB },
143
- { x: to.point.x + toNormal.x * SMART_STUB, y: to.point.y + toNormal.y * SMART_STUB },
144
- to.point,
145
- ];
157
+ // A free end has no stub: the route runs straight to it.
158
+ const stub = (end: RouteEnd): Point[] => {
159
+ const normal = sideNormal(end.side);
160
+ return end.free ? [] : [{ x: end.point.x + normal.x * SMART_STUB, y: end.point.y + normal.y * SMART_STUB }];
161
+ };
162
+ return [from.point, ...stub(from), ...stub(to).reverse(), to.point];
146
163
  };
147
164
 
148
165
  export type LinkGeometry = { link: Link; path: string; source: RouteEnd; target: RouteEnd };
@@ -165,6 +182,90 @@ const routeEnd = (terminal: PortTerminal, port: Port): RouteEnd => ({
165
182
  side: port.side,
166
183
  });
167
184
 
185
+ /** Port pairs searched per tier of fewest possible bends. */
186
+ const PAIRS_PER_TIER = 3;
187
+
188
+ /**
189
+ * The fewest bends any right-angled route between two ports could take: none when they face each other on one line,
190
+ * one when their sides are perpendicular and each points toward the corner they share, otherwise at least two.
191
+ */
192
+ const leastBends = (from: RouteEnd, to: RouteEnd): number => {
193
+ const out = sideNormal(from.side);
194
+ // A route arrives at its target moving against the target side's normal.
195
+ const arrive = sideNormal(to.side);
196
+ const [dx, dy] = [to.point.x - from.point.x, to.point.y - from.point.y];
197
+ const ahead = (normal: Point, x: number, y: number) => normal.x * x + normal.y * y > 0;
198
+ if (out.x === -arrive.x && out.y === -arrive.y) {
199
+ const aligned = out.x !== 0 ? dy === 0 : dx === 0;
200
+ return aligned && ahead(out, dx, dy) ? 0 : 2;
201
+ }
202
+ if (out.x * arrive.x + out.y * arrive.y === 0) {
203
+ // The corner is where the source's line meets the target's.
204
+ const corner = out.x !== 0 ? { x: to.point.x, y: from.point.y } : { x: from.point.x, y: to.point.y };
205
+ const first = ahead(out, corner.x - from.point.x, corner.y - from.point.y);
206
+ const second = ahead({ x: -arrive.x, y: -arrive.y }, to.point.x - corner.x, to.point.y - corner.y);
207
+ return first && second ? 1 : 2;
208
+ }
209
+ return 2;
210
+ };
211
+
212
+ /**
213
+ * The ends and gutter route of a smart link between two nodes on a lattice: of the pairs of side-centre ports (or the
214
+ * pinned ones), the one whose route is cheapest, fewest bends first, so a link leaves straight down a free column
215
+ * rather than from the nearest port round a corner. Pairs are tried nearest first, so a tie keeps the nearest.
216
+ */
217
+ const latticeEnds = (
218
+ scene: Scene,
219
+ registry: NodeRegistry,
220
+ link: Link,
221
+ route: LatticeRoute,
222
+ ): { from: RouteEnd; to: RouteEnd; points: Point[] } | undefined => {
223
+ if (link.type !== 'smart' || isPointEndpoint(link.source) || isPointEndpoint(link.target)) {
224
+ return undefined;
225
+ }
226
+ const source = terminalOf(scene, registry, link.source);
227
+ const target = terminalOf(scene, registry, link.target);
228
+ if (!source || !target) {
229
+ return undefined;
230
+ }
231
+ // A side's centre, where a side has one; a pinned port or a custom layout keeps what it offers.
232
+ const centres = (ports: readonly Port[]) => {
233
+ const middle = ports.filter((port) => port.offset === 0.5);
234
+ return middle.length > 0 ? middle : ports;
235
+ };
236
+ const sources = centres(portCandidates(source, 'out'));
237
+ const targets = centres(portCandidates(target, 'in'));
238
+ // Fewest possible bends first, then nearest: the search stops once no pair left could bend less than the best found.
239
+ const pairs = sources
240
+ .flatMap((from) => targets.map((to) => ({ from: routeEnd(source, from), to: routeEnd(target, to) })))
241
+ .map((pair) => ({
242
+ ...pair,
243
+ least: leastBends(pair.from, pair.to),
244
+ distance: Math.hypot(pair.from.point.x - pair.to.point.x, pair.from.point.y - pair.to.point.y),
245
+ }))
246
+ .sort((left, right) => left.least - right.least || left.distance - right.distance);
247
+ let best: { from: RouteEnd; to: RouteEnd; points: Point[]; bends: number } | undefined;
248
+ // Within a tier only the nearest few are searched: a farther pair seldom routes better, and each search has a cost.
249
+ const tried = new Map<number, number>();
250
+ for (const pair of pairs) {
251
+ if (best && pair.least >= best.bends) {
252
+ break;
253
+ }
254
+ const count = tried.get(pair.least) ?? 0;
255
+ if (count >= PAIRS_PER_TIER) {
256
+ continue;
257
+ }
258
+ tried.set(pair.least, count + 1);
259
+ const routed = gutterRouteWithCost(route.nodes, route.spec, pair.from, pair.to);
260
+ // The route's points are its ends and its corners.
261
+ const bends = routed ? routed.points.length - 2 : Infinity;
262
+ if (routed && (!best || bends < best.bends)) {
263
+ best = { from: pair.from, to: pair.to, points: routed.points, bends };
264
+ }
265
+ }
266
+ return best;
267
+ };
268
+
168
269
  /**
169
270
  * Resolve a link's ends and route it by its type: two node ends take the automatic (or pinned) port pair,
170
271
  * a node end facing a free point takes the port nearest that point, and two free points face each other.
@@ -175,12 +276,16 @@ export const linkGeometry = (
175
276
  link: Link,
176
277
  lattice?: LatticeSpec,
177
278
  ): LinkGeometry | undefined => {
279
+ const route = lattice ? { spec: lattice, nodes: Object.values(scene.nodes) } : undefined;
280
+ const routed = route && latticeEnds(scene, registry, link, route);
281
+ if (routed) {
282
+ return { link, path: splinePath(routed.points), source: routed.from, target: routed.to };
283
+ }
178
284
  const ends = resolveEnds(scene, registry, link.source, link.target);
179
285
  if (!ends) {
180
286
  return undefined;
181
287
  }
182
288
  const [from, to] = ends;
183
- const route = lattice ? { spec: lattice, nodes: Object.values(scene.nodes) } : undefined;
184
289
  return { link, path: linkPath(link, from, to, route), source: from, target: to };
185
290
  };
186
291
 
@@ -193,8 +298,8 @@ const resolveEnds = (
193
298
  if (isPointEndpoint(source)) {
194
299
  if (isPointEndpoint(target)) {
195
300
  return [
196
- { point: source.point, side: sideToward(source.point, target.point) },
197
- { point: target.point, side: sideToward(target.point, source.point) },
301
+ { point: source.point, side: sideToward(source.point, target.point), free: true },
302
+ { point: target.point, side: sideToward(target.point, source.point), free: true },
198
303
  ];
199
304
  }
200
305
  const terminal = terminalOf(scene, registry, target);
@@ -203,7 +308,7 @@ const resolveEnds = (
203
308
  return undefined;
204
309
  }
205
310
  const to = routeEnd(terminal, port);
206
- return [{ point: source.point, side: sideToward(source.point, to.point) }, to];
311
+ return [{ point: source.point, side: sideToward(source.point, to.point), free: true }, to];
207
312
  }
208
313
  if (isPointEndpoint(target)) {
209
314
  const terminal = terminalOf(scene, registry, source);
@@ -212,7 +317,7 @@ const resolveEnds = (
212
317
  return undefined;
213
318
  }
214
319
  const from = routeEnd(terminal, port);
215
- return [from, { point: target.point, side: sideToward(target.point, from.point) }];
320
+ return [from, { point: target.point, side: sideToward(target.point, from.point), free: true }];
216
321
  }
217
322
  const sourceTerminal = terminalOf(scene, registry, source);
218
323
  const targetTerminal = terminalOf(scene, registry, target);
@@ -249,7 +354,12 @@ export const sceneLinkGeometry = (
249
354
  const node = isPointEndpoint(end) ? undefined : scene.nodes[end.node];
250
355
  return node ? rect(nodeBounds(node)) : undefined;
251
356
  };
357
+ const route = { spec: lattice, nodes };
252
358
  const routed = links.flatMap((link) => {
359
+ const chosen = latticeEnds(scene, registry, link, route);
360
+ if (chosen) {
361
+ return [{ link, from: chosen.from, to: chosen.to, points: chosen.points }];
362
+ }
253
363
  const ends = resolveEnds(scene, registry, link.source, link.target);
254
364
  if (!ends) {
255
365
  return [];