@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
@@ -67,7 +67,7 @@ describe('clipboard', () => {
67
67
  nodeZ: (index) => `n${index}`,
68
68
  linkZ: (index) => `l${index}`,
69
69
  });
70
- expect(ids).toEqual(['ellipse-1', 'class-2', 'spline-3']);
70
+ expect(ids).toEqual(['ellipse-1', 'rect-2', 'spline-3']);
71
71
  const next = reduceIntent(scene, intent);
72
72
  expect(Object.keys(next.nodes).length).toBe(6);
73
73
  // Offsets are read against the fixture, whose coordinates are a layout and change with it.
@@ -78,11 +78,32 @@ describe('clipboard', () => {
78
78
  const spline = next.links['spline-3'];
79
79
  expect(spline.type === 'spline' && spline.points).toEqual(controls.map(({ x, y }) => ({ x: x + 64, y: y + 64 })));
80
80
  expect(endpointNode(spline.source)).toBe('ellipse-1');
81
- expect(endpointNode(spline.target)).toBe('class-2');
81
+ expect(endpointNode(spline.target)).toBe('rect-2');
82
82
  // The originals are untouched.
83
83
  expect(next.nodes['scene:r/b'].center).toEqual(scene.nodes['scene:r/b'].center);
84
84
  });
85
85
 
86
+ test('paste puts every element on the given layer', ({ expect }) => {
87
+ const scene = fixture();
88
+ const clipboard = copySelection(scene, ['scene:r/b', 'scene:r/c']);
89
+ if (!clipboard) {
90
+ throw new Error('nothing copied');
91
+ }
92
+ let counter = 0;
93
+ const { intent } = pasteFragment({
94
+ clipboard,
95
+ offset: { x: 64, y: 64 },
96
+ createId: (prefix) => `${prefix}-${++counter}`,
97
+ nodeZ: (index) => `n${index}`,
98
+ linkZ: (index) => `l${index}`,
99
+ layer: 'top',
100
+ });
101
+ const next = reduceIntent(scene, intent);
102
+ expect(
103
+ [next.nodes['ellipse-1'], next.nodes['rect-2'], next.links['spline-3']].map((element) => element.layer),
104
+ ).toEqual(['top', 'top', 'top']);
105
+ });
106
+
86
107
  test('a free end is copied with its node and moves with the paste', ({ expect }) => {
87
108
  const scene = fixture();
88
109
  const free: Link = {
@@ -13,6 +13,7 @@ import {
13
13
  type ElementId,
14
14
  type Endpoint,
15
15
  type Intent,
16
+ type LayerId,
16
17
  type Link,
17
18
  type Node,
18
19
  type Point,
@@ -60,19 +61,22 @@ export type PasteOptions = {
60
61
  /** Z keys for the pasted nodes and links, above the scene. */
61
62
  nodeZ: (index: number) => string;
62
63
  linkZ: (index: number) => string;
64
+ /** The layer the pasted elements go on; unset, each keeps its own. */
65
+ layer?: LayerId;
63
66
  };
64
67
 
65
68
  export type Paste = { intent: Intent; ids: ElementId[] };
66
69
 
67
70
  /** The batch that recreates the fragment with fresh ids at `offset`, and the ids it will create. */
68
- export const pasteFragment = ({ clipboard, offset, createId, nodeZ, linkZ }: PasteOptions): Paste => {
71
+ export const pasteFragment = ({ clipboard, offset, createId, nodeZ, linkZ, layer }: PasteOptions): Paste => {
69
72
  const idMap = new Map<ElementId, ElementId>();
70
73
  const shift = (point: Point): Point => ({ x: point.x + offset.x, y: point.y + offset.y });
74
+ const onLayer = layer !== undefined ? { layer } : {};
71
75
  const nodes: Node[] = clipboard.nodes.map((node, index) => {
72
76
  const id = createId(node.type);
73
77
  idMap.set(node.id, id);
74
78
  // A pasted portal shares the child scene: two portals to one scene are Muse "linked cards" (§4).
75
- return { ...node, id, z: nodeZ(index), center: shift(node.center) };
79
+ return { ...node, ...onLayer, id, z: nodeZ(index), center: shift(node.center) };
76
80
  });
77
81
  const links: Link[] = clipboard.links.map((link, index) => {
78
82
  const id = createId(link.type);
@@ -81,8 +85,8 @@ export const pasteFragment = ({ clipboard, offset, createId, nodeZ, linkZ }: Pas
81
85
  isPointEndpoint(end) ? { point: shift(end.point) } : { ...end, node: idMap.get(end.node) ?? end.node };
82
86
  const ends = { source: remap(link.source), target: remap(link.target) };
83
87
  return link.type === 'spline'
84
- ? { ...link, id, z: linkZ(index), ...ends, points: link.points.map(shift) }
85
- : { ...link, id, z: linkZ(index), ...ends };
88
+ ? { ...link, ...onLayer, id, z: linkZ(index), ...ends, points: link.points.map(shift) }
89
+ : { ...link, ...onLayer, id, z: linkZ(index), ...ends };
86
90
  });
87
91
  return {
88
92
  intent: {
@@ -19,7 +19,9 @@ describe('diagram', () => {
19
19
  const scene = build([
20
20
  rect('scene:test/a', box(0, 0), 'A'),
21
21
  rect('scene:test/b', box(256, 0), 'B'),
22
- SceneBuilder.link('line', 'scene:test/a', 'scene:test/b').id('scene:test/ab').properties({ directed: true }),
22
+ SceneBuilder.link('line', 'scene:test/a', 'scene:test/b')
23
+ .id('scene:test/ab')
24
+ .properties({ ends: { end: 'arrow' } }),
23
25
  ]);
24
26
  const { metrics, diagnostics } = Diagnostics.analyze(toDiagramObjects(scene, defaultNodeRegistry).objects);
25
27
  expect(metrics.nodes).toBe(2);
@@ -10,17 +10,8 @@
10
10
  import { type Scene as Diagram } from '@dxos/diagram';
11
11
 
12
12
  import { type NodeRegistry } from '../model/registry.ts';
13
- import {
14
- type ElementId,
15
- type Link,
16
- type Node,
17
- type Point,
18
- type Scene,
19
- isClassNode,
20
- isEllipseNode,
21
- isNoteNode,
22
- isRectNode,
23
- } from '../model/types.ts';
13
+ import { type ElementId, type Link, type Point, type Scene, isEllipseNode } from '../model/types.ts';
14
+ import { nodeTitle } from './parts.ts';
24
15
  import { curvePoint, linkGeometry, smartPoints } from './route.ts';
25
16
  import { nodeBounds } from './shapes.ts';
26
17
 
@@ -39,19 +30,6 @@ export type DiagramObjects = {
39
30
  */
40
31
  const objectId = (id: ElementId): string => encodeURIComponent(id);
41
32
 
42
- const nodeText = (node: Node): string | undefined => {
43
- if (isRectNode(node) || isEllipseNode(node)) {
44
- return node.label;
45
- }
46
- if (isClassNode(node)) {
47
- return node.name;
48
- }
49
- if (isNoteNode(node)) {
50
- return node.text;
51
- }
52
- return undefined;
53
- };
54
-
55
33
  /** The polyline a link is drawn along, in scene coordinates. */
56
34
  const linkPoints = (scene: Scene, registry: NodeRegistry, link: Link): Point[] | undefined => {
57
35
  const geometry = linkGeometry(scene, registry, link);
@@ -93,7 +71,7 @@ export const toDiagramObjects = (scene: Scene, registry: NodeRegistry): DiagramO
93
71
  y,
94
72
  w: width,
95
73
  h: height,
96
- ...(node.style?.guide ? {} : { text: nodeText(node) }),
74
+ ...(node.style?.guide ? {} : { text: nodeTitle(registry, node) }),
97
75
  },
98
76
  ],
99
77
  });
@@ -0,0 +1,69 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import { describe, test } from 'vitest';
6
+
7
+ import { reduceIntent } from '../model/projection.ts';
8
+ import { type Layer } from '../model/types.ts';
9
+ import { SceneBuilder } from './builder.ts';
10
+ import { groupIntoScene } from './group.ts';
11
+ import { DEFAULT_LAYER } from './layers.ts';
12
+ import { between } from './order.ts';
13
+
14
+ describe('groupIntoScene', () => {
15
+ const box = (x: number) => ({ x, y: 0, width: 100, height: 50 });
16
+ const {
17
+ scenes: [scene],
18
+ } = SceneBuilder.scene('s', [
19
+ SceneBuilder.rect('a', box(0)),
20
+ SceneBuilder.rect('b', box(200)),
21
+ SceneBuilder.rect('c', box(400)),
22
+ SceneBuilder.link('line', 'a', 'b').id('ab'),
23
+ SceneBuilder.link('line', 'b', 'c').id('bc'),
24
+ ]).build();
25
+
26
+ test('the selection moves into a scene that a shape over its bounds opens', ({ expect }) => {
27
+ const group = groupIntoScene(scene, ['a', 'b', 'ab'], 'g');
28
+ expect(group).toBeDefined();
29
+ if (!group) {
30
+ return;
31
+ }
32
+ expect(Object.keys(group.child.nodes)).toEqual(['a', 'b']);
33
+ expect(Object.keys(group.child.links)).toEqual(['ab']);
34
+
35
+ const parent = reduceIntent(scene, { kind: 'batch', intents: group.intents });
36
+ expect(Object.keys(parent.nodes).sort()).toEqual(['c', 'g']);
37
+ expect(parent.nodes.g).toMatchObject({
38
+ type: 'frame',
39
+ scene: 'g',
40
+ center: { x: 150, y: 25 },
41
+ size: { width: 300, height: 50 },
42
+ });
43
+ // The link out of the group stays, now from the shape.
44
+ expect(Object.keys(parent.links)).toEqual(['bc']);
45
+ expect(parent.links.bc.source).toEqual({ node: 'g' });
46
+ });
47
+
48
+ test('the shape goes on the given layer, and the new scene keeps the layers its elements were on', ({ expect }) => {
49
+ const top: Layer = { id: 'top', name: 'Top', z: between(DEFAULT_LAYER.z) };
50
+ const layered = reduceIntent(reduceIntent(scene, { kind: 'layer', layer: top }), {
51
+ kind: 'update',
52
+ id: 'b',
53
+ values: { layer: 'top' },
54
+ });
55
+ const group = groupIntoScene(layered, ['a', 'b', 'ab'], 'g', 'top');
56
+ expect(group?.intents[0]).toMatchObject({ kind: 'create', node: { id: 'g', layer: 'top' } });
57
+ expect(group?.child.layers).toEqual({ [DEFAULT_LAYER.id]: DEFAULT_LAYER, top });
58
+ expect([group?.child.nodes.a.layer, group?.child.nodes.b.layer]).toEqual([DEFAULT_LAYER.id, 'top']);
59
+ });
60
+
61
+ test('a selection of links alone groups nothing', ({ expect }) => {
62
+ expect(groupIntoScene(scene, ['ab'], 'g')).toBeUndefined();
63
+ });
64
+
65
+ test('a selection holding a locked node is not grouped', ({ expect }) => {
66
+ const locked = { ...scene, nodes: { ...scene.nodes, a: { ...scene.nodes.a, locked: true } } };
67
+ expect(groupIntoScene(locked, ['a', 'b'], 'g')).toBeUndefined();
68
+ });
69
+ });
@@ -0,0 +1,94 @@
1
+ //
2
+ // Copyright 2026 DXOS.org
3
+ //
4
+
5
+ import {
6
+ type Element,
7
+ type ElementId,
8
+ type Endpoint,
9
+ type FrameNode,
10
+ type Intent,
11
+ type LayerId,
12
+ type Link,
13
+ type Scene,
14
+ endpointNode,
15
+ } from '../model/types.ts';
16
+ import { unionBounds } from './hit.ts';
17
+ import { elementLayer, sceneLayers } from './layers.ts';
18
+ import { topZ } from './order.ts';
19
+ import { nodeBounds } from './shapes.ts';
20
+
21
+ /** What grouping a selection makes: the new scene, and the intents that replace the selection with its shape. */
22
+ export type SceneGroup = { child: Scene; intents: Intent[] };
23
+
24
+ /** `end` moved onto the scene shape when it was on a grouped node; a port of the old node means nothing there. */
25
+ const reattach = (end: Endpoint, inside: ReadonlySet<string>, portal: string): Endpoint => {
26
+ const node = endpointNode(end);
27
+ return node !== undefined && inside.has(node) ? { node: portal } : end;
28
+ };
29
+
30
+ /**
31
+ * Moves the selected nodes into a new scene `id`, opened by a scene shape of the same id where they were. A link
32
+ * between two grouped nodes moves with them; one crossing out of the group stays here, re-attached to the shape.
33
+ * The nodes keep their coordinates: the shape frames whatever the child holds. None when no node is selected, or
34
+ * when a selected node is locked. The shape goes on `layer`; the new scene has the layers its elements were on.
35
+ */
36
+ export const groupIntoScene = (
37
+ scene: Scene,
38
+ ids: Iterable<ElementId>,
39
+ id: string,
40
+ layer?: LayerId,
41
+ ): SceneGroup | undefined => {
42
+ const selected = new Set(ids);
43
+ const nodes = Object.values(scene.nodes).filter((node) => selected.has(node.id));
44
+ const bounds = unionBounds(nodes.map(nodeBounds));
45
+ // A locked node may not leave its scene, so a selection holding one is not grouped at all.
46
+ if (nodes.length === 0 || !bounds || nodes.some((node) => node.locked)) {
47
+ return undefined;
48
+ }
49
+
50
+ const inside = new Set(nodes.map((node) => node.id));
51
+ const isInside = (end: Endpoint) => {
52
+ const node = endpointNode(end);
53
+ return node === undefined || inside.has(node);
54
+ };
55
+ const touches = (link: Link) => [link.source, link.target].some((end) => inside.has(endpointNode(end) ?? ''));
56
+ const links = Object.values(scene.links).filter(touches);
57
+ // A link with both ends in the group (a free end counts as either side) goes with it; the rest cross out.
58
+ const moved = links.filter((link) => isInside(link.source) && isInside(link.target));
59
+ const crossing = links.filter((link) => !moved.includes(link));
60
+
61
+ const portal: FrameNode = {
62
+ type: 'frame',
63
+ id,
64
+ z: topZ(Object.values(scene.nodes)),
65
+ center: { x: bounds.x + bounds.width / 2, y: bounds.y + bounds.height / 2 },
66
+ size: { width: bounds.width, height: bounds.height },
67
+ scene: id,
68
+ ...(layer !== undefined ? { layer } : {}),
69
+ };
70
+ // Each element names the layer it resolved to here, and the new scene has those layers, in their order.
71
+ const layers = sceneLayers(scene);
72
+ const placed = <T extends Element>(element: T): T => ({ ...element, layer: elementLayer(element, layers) });
73
+ const childNodes = nodes.map(placed);
74
+ const childLinks = moved.map(placed);
75
+ const used = new Set([...childNodes, ...childLinks].map((element) => element.layer));
76
+ return {
77
+ child: {
78
+ id,
79
+ nodes: Object.fromEntries(childNodes.map((node) => [node.id, node])),
80
+ links: Object.fromEntries(childLinks.map((link) => [link.id, link])),
81
+ layers: Object.fromEntries(layers.filter((entry) => used.has(entry.id)).map((entry) => [entry.id, entry])),
82
+ },
83
+ intents: [
84
+ { kind: 'create', node: portal },
85
+ // Re-attached before the nodes go, since deleting a node takes the links on it.
86
+ ...crossing.map((link): Intent => ({
87
+ kind: 'update',
88
+ id: link.id,
89
+ values: { source: reattach(link.source, inside, id), target: reattach(link.target, inside, id) },
90
+ })),
91
+ { kind: 'delete', ids: [...inside] },
92
+ ],
93
+ };
94
+ };
@@ -116,6 +116,55 @@ describe('gutter route', () => {
116
116
  }
117
117
  });
118
118
 
119
+ test('a wall wider than the search margin is still routed round', ({ expect }) => {
120
+ // Eleven cells wide across row 0, between two ends in column 0: the way round lies beyond its ends.
121
+ const wall = cellNode('wall', -5, 0, 11, 1);
122
+ const route = gutterRoute(
123
+ [wall],
124
+ spec,
125
+ { point: { x: 0, y: -128 }, side: 's' },
126
+ { point: { x: 0, y: 128 }, side: 'n' },
127
+ );
128
+ expect(route).toBeDefined();
129
+ const { x, y, width, height } = cellBounds({ col: -5, row: 0, spanX: 11, spanY: 1 }, spec);
130
+ for (const [from, to] of (route ?? []).slice(1).map((point, index) => [route?.[index] ?? point, point])) {
131
+ // No segment passes through the wall's interior.
132
+ const crosses =
133
+ Math.min(from.x, to.x) < x + width &&
134
+ Math.max(from.x, to.x) > x &&
135
+ Math.min(from.y, to.y) < y + height &&
136
+ Math.max(from.y, to.y) > y;
137
+ expect(crosses).toBe(false);
138
+ }
139
+ });
140
+
141
+ test('a distant shape adds no lines to the search', ({ expect }) => {
142
+ // A shape a million pitches away is no obstacle; the route between B and D is found as without it.
143
+ const far = cellNode('far', 1_000_000, 1_000_000);
144
+ const ends = [
145
+ { point: { x: -256, y: 0 }, side: 'e' },
146
+ { point: { x: -128, y: 0 }, side: 'w' },
147
+ ] as const;
148
+ const started = performance.now();
149
+ const route = gutterRoute([...nodes, far], spec, ...ends);
150
+ expect(performance.now() - started).toBeLessThan(1_000);
151
+ expect(route).toEqual(gutterRoute(nodes, spec, ...ends));
152
+ });
153
+
154
+ test('a chain of abutting shapes does not widen a local search', ({ expect }) => {
155
+ // A row of cells running far to the right, each within reach of the next: only a failed local search
156
+ // may follow the chain, and B to D routes without it.
157
+ const chain = Array.from({ length: 400 }, (_, index) => cellNode(`chain-${index}`, index, 3));
158
+ const ends = [
159
+ { point: { x: -256, y: 0 }, side: 'e' },
160
+ { point: { x: -128, y: 0 }, side: 'w' },
161
+ ] as const;
162
+ const started = performance.now();
163
+ const route = gutterRoute([...nodes, ...chain], spec, ...ends);
164
+ expect(performance.now() - started).toBeLessThan(1_000);
165
+ expect(route).toEqual(gutterRoute(nodes, spec, ...ends));
166
+ });
167
+
119
168
  test('links sharing a gutter are nudged into separate lanes', ({ expect }) => {
120
169
  const scene: Scene = { id: 's', nodes: Object.fromEntries(nodes.map((node) => [node.id, node])), links: {} };
121
170
  const link = (id: string, source: string, sourcePort: string, target: string, targetPort: string): Link =>
@@ -11,7 +11,7 @@
11
11
  //
12
12
 
13
13
  import { type Bounds, type Node, type Point } from '../model/types.ts';
14
- import { type LatticeSpec } from './lattice.ts';
14
+ import { type LatticeSpec, onLattice } from './lattice.ts';
15
15
  import { sideNormal } from './ports.ts';
16
16
  import { type RouteEnd } from './route.ts';
17
17
  import { nodeBounds } from './shapes.ts';
@@ -19,7 +19,7 @@ import { nodeBounds } from './shapes.ts';
19
19
  /** A bend costs as much as this much distance: turns dominate, length breaks ties. */
20
20
  const TURN_COST = 100_000;
21
21
 
22
- /** Gutter lines beyond the two ends the search may use, so a route can go round an obstacle. */
22
+ /** Gutter lines beyond the ends and the shapes the search may use, so a route can go round the outermost. */
23
23
  const MARGIN = 3;
24
24
 
25
25
  const EPSILON = 1e-6;
@@ -49,6 +49,53 @@ const gutterLines = (low: number, high: number, pitch: number): number[] => {
49
49
  return Array.from({ length: last - first + 1 }, (_, index) => (first + index + 0.5) * pitch);
50
50
  };
51
51
 
52
+ type Region = { left: number; right: number; top: number; bottom: number };
53
+
54
+ /**
55
+ * The span the search covers: the ends, grown by every frame within `MARGIN` pitches of them, so a way round
56
+ * an obstacle is in range while a distant shape adds no lines to the grid. `transitive` keeps growing by the
57
+ * frames near the grown span until none is left, for the rare detour round a chain of abutting shapes.
58
+ */
59
+ const searchRegion = (
60
+ start: Point,
61
+ end: Point,
62
+ frames: readonly Bounds[],
63
+ pitchX: number,
64
+ pitchY: number,
65
+ transitive: boolean,
66
+ ): Region => {
67
+ const region: Region = {
68
+ left: Math.min(start.x, end.x),
69
+ right: Math.max(start.x, end.x),
70
+ top: Math.min(start.y, end.y),
71
+ bottom: Math.max(start.y, end.y),
72
+ };
73
+ const [reachX, reachY] = [MARGIN * pitchX, MARGIN * pitchY];
74
+ const pending = new Set(frames);
75
+ for (let grown = true; grown;) {
76
+ grown = false;
77
+ // A single pass measures nearness against the ends' span only, so one frame cannot pull in the next.
78
+ const reach = { ...region };
79
+ for (const frame of pending) {
80
+ const span = transitive ? region : reach;
81
+ const near =
82
+ frame.x <= span.right + reachX &&
83
+ frame.x + frame.width >= span.left - reachX &&
84
+ frame.y <= span.bottom + reachY &&
85
+ frame.y + frame.height >= span.top - reachY;
86
+ if (near) {
87
+ region.left = Math.min(region.left, frame.x);
88
+ region.right = Math.max(region.right, frame.x + frame.width);
89
+ region.top = Math.min(region.top, frame.y);
90
+ region.bottom = Math.max(region.bottom, frame.y + frame.height);
91
+ pending.delete(frame);
92
+ grown = transitive;
93
+ }
94
+ }
95
+ }
96
+ return region;
97
+ };
98
+
52
99
  const sortedUnique = (values: number[]): number[] =>
53
100
  [...new Set(values.map((value) => Math.round(value * 1000) / 1000))].sort((left, right) => left - right);
54
101
 
@@ -63,15 +110,43 @@ export const gutterRoute = (
63
110
  spec: LatticeSpec,
64
111
  from: RouteEnd,
65
112
  to: RouteEnd,
66
- ): Point[] | undefined => {
113
+ ): Point[] | undefined => gutterRouteWithCost(nodes, spec, from, to)?.points;
114
+
115
+ /** A gutter route and what it cost the search: `TURN_COST` a bend, plus its length. */
116
+ export type GutterRoute = { points: Point[]; cost: number };
117
+
118
+ /** {@link gutterRoute} with its cost, so routes between different ports can be compared. */
119
+ export const gutterRouteWithCost = (
120
+ nodes: readonly Node[],
121
+ spec: LatticeSpec,
122
+ from: RouteEnd,
123
+ to: RouteEnd,
124
+ ): GutterRoute | undefined => {
67
125
  const [pitchX, pitchY] = [spec.width + spec.gutterX, spec.height + spec.gutterY];
68
126
  const start = gutterExit(from, spec);
69
127
  const end = gutterExit(to, spec);
70
- const verticals = gutterLines(Math.min(start.x, end.x), Math.max(start.x, end.x), pitchX);
71
- const horizontals = gutterLines(Math.min(start.y, end.y), Math.max(start.y, end.y), pitchY);
128
+ const frames = nodes.filter(onLattice).map(nodeBounds);
129
+ // The local grid finds almost every route; only when it offers no way round does the search widen.
130
+ const search = (transitive: boolean) =>
131
+ searchGrid(from, to, start, end, frames, searchRegion(start, end, frames, pitchX, pitchY, transitive), spec);
132
+ return search(false) ?? search(true);
133
+ };
134
+
135
+ /** Dijkstra over the gutter lines and port lines in `region`, from the gutter exit `start` to `end`. */
136
+ const searchGrid = (
137
+ from: RouteEnd,
138
+ to: RouteEnd,
139
+ start: Point,
140
+ end: Point,
141
+ frames: readonly Bounds[],
142
+ region: Region,
143
+ spec: LatticeSpec,
144
+ ): GutterRoute | undefined => {
145
+ const [pitchX, pitchY] = [spec.width + spec.gutterX, spec.height + spec.gutterY];
146
+ const verticals = gutterLines(region.left, region.right, pitchX);
147
+ const horizontals = gutterLines(region.top, region.bottom, pitchY);
72
148
  const xs = sortedUnique([...verticals, start.x, end.x]);
73
149
  const ys = sortedUnique([...horizontals, start.y, end.y]);
74
- const frames = nodes.map(nodeBounds);
75
150
  const clear = (a: Point, b: Point) => !frames.some((frame) => crosses(a, b, frame));
76
151
 
77
152
  const indexOf = (values: number[], value: number) => values.indexOf(Math.round(value * 1000) / 1000);
@@ -80,25 +155,29 @@ export const gutterRoute = (
80
155
  // The stub leaves along the side's normal, so the first run continuing that axis is not a bend.
81
156
  const axisOf = (end: RouteEnd): Axis => (end.side === 'e' || end.side === 'w' ? 'h' : 'v');
82
157
 
83
- // Dijkstra over (column, row, arriving axis); the grid is a few dozen lines a side, so a linear scan of
84
- // the open set is cheap enough.
158
+ // Dijkstra over (column, row, arriving axis), the open set a binary heap: a route is searched for each candidate
159
+ // pair of ports, so each search must be cheap.
85
160
  type State = { i: number; j: number; axis: Axis };
86
161
  const key = ({ i, j, axis }: State) => `${i},${j},${axis}`;
87
162
  const cost = new Map<string, number>();
88
163
  const previous = new Map<string, State>();
89
- const open: State[] = [];
164
+ const open = new MinHeap<State>();
90
165
  const startState: State = { i: startI, j: startJ, axis: axisOf(from) };
91
166
  cost.set(key(startState), 0);
92
- open.push(startState);
167
+ open.push(startState, 0);
93
168
  let best: State | undefined;
94
169
  let bestCost = Infinity;
95
- while (open.length > 0) {
96
- open.sort((left, right) => (cost.get(key(left)) ?? Infinity) - (cost.get(key(right)) ?? Infinity));
97
- const current = open.shift();
98
- if (!current) {
170
+ while (open.size > 0) {
171
+ const entry = open.pop();
172
+ if (!entry) {
99
173
  break;
100
174
  }
175
+ const current = entry.value;
101
176
  const currentCost = cost.get(key(current)) ?? Infinity;
177
+ // A state queued again at a lower cost leaves its older entry behind; skip the stale one.
178
+ if (entry.priority > currentCost) {
179
+ continue;
180
+ }
102
181
  if (currentCost >= bestCost) {
103
182
  break;
104
183
  }
@@ -132,7 +211,7 @@ export const gutterRoute = (
132
211
  if (nextCost < (cost.get(key(next)) ?? Infinity)) {
133
212
  cost.set(key(next), nextCost);
134
213
  previous.set(key(next), current);
135
- open.push(next);
214
+ open.push(next, nextCost);
136
215
  }
137
216
  }
138
217
  }
@@ -144,9 +223,56 @@ export const gutterRoute = (
144
223
  for (let state: State | undefined = best; state; state = previous.get(key(state))) {
145
224
  corners.unshift({ x: xs[state.i], y: ys[state.j] });
146
225
  }
147
- return collapse([from.point, ...corners, to.point]);
226
+ return { points: collapse([from.point, ...corners, to.point]), cost: bestCost };
148
227
  };
149
228
 
229
+ /** A binary min-heap of values by priority. */
230
+ class MinHeap<T> {
231
+ #items: { value: T; priority: number }[] = [];
232
+
233
+ get size(): number {
234
+ return this.#items.length;
235
+ }
236
+
237
+ push(value: T, priority: number): void {
238
+ const items = this.#items;
239
+ items.push({ value, priority });
240
+ for (let index = items.length - 1; index > 0;) {
241
+ const parent = (index - 1) >> 1;
242
+ if (items[parent].priority <= items[index].priority) {
243
+ break;
244
+ }
245
+ [items[parent], items[index]] = [items[index], items[parent]];
246
+ index = parent;
247
+ }
248
+ }
249
+
250
+ pop(): { value: T; priority: number } | undefined {
251
+ const items = this.#items;
252
+ const top = items[0];
253
+ const last = items.pop();
254
+ if (items.length > 0 && last) {
255
+ items[0] = last;
256
+ for (let index = 0; ;) {
257
+ const [left, right] = [index * 2 + 1, index * 2 + 2];
258
+ let smallest = index;
259
+ if (left < items.length && items[left].priority < items[smallest].priority) {
260
+ smallest = left;
261
+ }
262
+ if (right < items.length && items[right].priority < items[smallest].priority) {
263
+ smallest = right;
264
+ }
265
+ if (smallest === index) {
266
+ break;
267
+ }
268
+ [items[smallest], items[index]] = [items[index], items[smallest]];
269
+ index = smallest;
270
+ }
271
+ }
272
+ return top;
273
+ }
274
+ }
275
+
150
276
  /** Drops repeated points and the middle of any three collinear ones, so only the real bends remain. */
151
277
  const collapse = (points: Point[]): Point[] => {
152
278
  const unique = points.filter(
@@ -4,8 +4,8 @@
4
4
 
5
5
  import { describe, test } from 'vitest';
6
6
 
7
- import { type BuiltinNode, MAJOR_GRID, type Scene } from '../model/types.ts';
8
- import { DEFAULT_EXTENT, boundsFromPoints, contentBounds, hitTest, nodesIntersecting, sceneBounds } from './hit.ts';
7
+ import { type BuiltinNode, type Scene } from '../model/types.ts';
8
+ import { boundsFromPoints, contentBounds, hitTest, nodesIntersecting } from './hit.ts';
9
9
 
10
10
  const nodes: Record<string, BuiltinNode> = {
11
11
  a: { type: 'rect', id: 'a', z: 'M', center: { x: 100, y: 100 }, size: { width: 100, height: 100 } },
@@ -40,44 +40,10 @@ describe('hit', () => {
40
40
  expect(nodesIntersecting(scene, boundsFromPoints({ x: 0, y: 0 }, { x: 600, y: 600 })).length).toBe(3);
41
41
  });
42
42
 
43
- test('the default extent is centred on the origin and lands on the major grid', ({ expect }) => {
44
- // A scene is laid out around (0, 0); an extent anchored at the origin instead would put its centre
45
- // — and so the initial fit — well below and right of the content.
46
- expect(DEFAULT_EXTENT.x + DEFAULT_EXTENT.width / 2).toBe(0);
47
- expect(DEFAULT_EXTENT.y + DEFAULT_EXTENT.height / 2).toBe(0);
48
- expect(Math.abs(DEFAULT_EXTENT.x % MAJOR_GRID)).toBe(0);
49
- expect(Math.abs(DEFAULT_EXTENT.y % MAJOR_GRID)).toBe(0);
50
- });
51
-
52
- test('sceneBounds grows the default extent by the padded union of the nodes', ({ expect }) => {
53
- // Content inside the default extent leaves it as it is, whatever the padding or grid.
54
- const near: Scene = { id: 'near', nodes: { a: nodes.a }, links: {} };
55
- expect(sceneBounds(near, 10, 1)).toEqual(DEFAULT_EXTENT);
56
- expect(sceneBounds(near, 10)).toEqual(DEFAULT_EXTENT);
57
- expect(sceneBounds({ id: 'empty', nodes: {}, links: {} })).toEqual(DEFAULT_EXTENT);
58
- // A node beyond it grows the frame that way, and the default still holds the other three sides.
59
- const far: BuiltinNode = {
60
- type: 'rect',
61
- id: 'far',
62
- z: 'Z',
63
- center: { x: 2000, y: -400 },
64
- size: { width: 100, height: 100 },
65
- };
66
- expect(sceneBounds({ ...near, nodes: { ...near.nodes, far } })).toEqual({
67
- x: -832,
68
- y: -576,
69
- width: 3008,
70
- height: 1088,
71
- });
72
- });
73
-
74
- test('contentBounds frames the content alone, and the floor only when there is none', ({ expect }) => {
75
- // The same scene seen two ways: on its own it gets the editing surface, in a portal its own content.
43
+ test('contentBounds frames the content alone, and nothing for an empty scene', ({ expect }) => {
76
44
  const near: Scene = { id: 'near', nodes: { a: nodes.a }, links: {} };
77
- expect(sceneBounds(near)).toEqual(DEFAULT_EXTENT);
78
45
  // The node's 50..150 box, padded by a major cell and grown to the grid.
79
46
  expect(contentBounds(near)).toEqual({ x: -64, y: -64, width: 320, height: 320 });
80
- // Nothing to frame, so the floor is the frame rather than a degenerate box.
81
- expect(contentBounds({ id: 'empty', nodes: {}, links: {} })).toEqual(DEFAULT_EXTENT);
47
+ expect(contentBounds({ id: 'empty', nodes: {}, links: {} })).toBeUndefined();
82
48
  });
83
49
  });