@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.
- package/README.md +1 -1
- package/dist/lib/index.mjs +9044 -1135
- package/dist/lib/index.mjs.map +1 -1
- package/dist/types/src/components/About/About.d.ts +13 -0
- package/dist/types/src/components/About/About.d.ts.map +1 -0
- package/dist/types/src/components/About/About.stories.d.ts +7 -0
- package/dist/types/src/components/About/About.stories.d.ts.map +1 -0
- package/dist/types/src/components/About/index.d.ts +2 -0
- package/dist/types/src/components/About/index.d.ts.map +1 -0
- package/dist/types/src/components/Breadcrumbs/Breadcrumbs.d.ts.map +1 -1
- package/dist/types/src/components/ControlFrame/ControlFrame.d.ts +8 -2
- package/dist/types/src/components/ControlFrame/ControlFrame.d.ts.map +1 -1
- package/dist/types/src/components/Layers/Layers.d.ts +30 -0
- package/dist/types/src/components/Layers/Layers.d.ts.map +1 -0
- package/dist/types/src/components/Layers/Layers.stories.d.ts +11 -0
- package/dist/types/src/components/Layers/Layers.stories.d.ts.map +1 -0
- package/dist/types/src/components/Layers/index.d.ts +2 -0
- package/dist/types/src/components/Layers/index.d.ts.map +1 -0
- package/dist/types/src/components/Palette/Palette.d.ts +20 -4
- package/dist/types/src/components/Palette/Palette.d.ts.map +1 -1
- package/dist/types/src/components/Palette/Palette.stories.d.ts +22 -0
- package/dist/types/src/components/Palette/Palette.stories.d.ts.map +1 -0
- package/dist/types/src/components/PartEditor/PartEditor.d.ts.map +1 -1
- package/dist/types/src/components/Properties/AlignField.d.ts +23 -0
- package/dist/types/src/components/Properties/AlignField.d.ts.map +1 -0
- package/dist/types/src/components/Properties/ClassField.d.ts +23 -0
- package/dist/types/src/components/Properties/ClassField.d.ts.map +1 -0
- package/dist/types/src/components/Properties/FontField.d.ts +7 -0
- package/dist/types/src/components/Properties/FontField.d.ts.map +1 -0
- package/dist/types/src/components/Properties/LayerField.d.ts +17 -0
- package/dist/types/src/components/Properties/LayerField.d.ts.map +1 -0
- package/dist/types/src/components/Properties/Properties.d.ts +27 -3
- package/dist/types/src/components/Properties/Properties.d.ts.map +1 -1
- package/dist/types/src/components/Properties/Properties.stories.d.ts +20 -0
- package/dist/types/src/components/Properties/Properties.stories.d.ts.map +1 -0
- package/dist/types/src/components/Properties/SceneField.d.ts +8 -0
- package/dist/types/src/components/Properties/SceneField.d.ts.map +1 -0
- package/dist/types/src/components/Properties/StyleGrid.d.ts +15 -5
- package/dist/types/src/components/Properties/StyleGrid.d.ts.map +1 -1
- package/dist/types/src/components/Properties/StyleGrid.stories.d.ts +7 -0
- package/dist/types/src/components/Properties/StyleGrid.stories.d.ts.map +1 -0
- package/dist/types/src/components/SceneLayer/SceneLayer.d.ts +31 -7
- package/dist/types/src/components/SceneLayer/SceneLayer.d.ts.map +1 -1
- package/dist/types/src/components/SceneView/Dock.d.ts +22 -0
- package/dist/types/src/components/SceneView/Dock.d.ts.map +1 -0
- package/dist/types/src/components/SceneView/Dock.stories.d.ts +15 -0
- package/dist/types/src/components/SceneView/Dock.stories.d.ts.map +1 -0
- package/dist/types/src/components/SceneView/SceneView.d.ts +33 -7
- package/dist/types/src/components/SceneView/SceneView.d.ts.map +1 -1
- package/dist/types/src/components/SceneView/SceneView.stories.d.ts +16 -6
- package/dist/types/src/components/SceneView/SceneView.stories.d.ts.map +1 -1
- package/dist/types/src/components/SceneView/SceneViewContext.d.ts +36 -4
- package/dist/types/src/components/SceneView/SceneViewContext.d.ts.map +1 -1
- package/dist/types/src/components/SceneView/Scored.stories.d.ts +4 -4
- package/dist/types/src/components/SceneView/Scored.stories.d.ts.map +1 -1
- package/dist/types/src/components/SceneView/usePointerMachine.d.ts +3 -1
- package/dist/types/src/components/SceneView/usePointerMachine.d.ts.map +1 -1
- package/dist/types/src/components/SceneView/useSceneCamera.d.ts +2 -2
- package/dist/types/src/components/SceneView/useSceneCamera.d.ts.map +1 -1
- package/dist/types/src/components/SceneView/useSceneClipboard.d.ts.map +1 -1
- package/dist/types/src/components/SceneView/useSceneKeys.d.ts.map +1 -1
- package/dist/types/src/components/SceneView/useSceneNavigation.d.ts +7 -4
- package/dist/types/src/components/SceneView/useSceneNavigation.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Toolbar.d.ts +2 -0
- package/dist/types/src/components/Toolbar/Toolbar.d.ts.map +1 -1
- package/dist/types/src/components/Toolbar/Toolbar.stories.d.ts.map +1 -1
- package/dist/types/src/components/index.d.ts +2 -0
- package/dist/types/src/components/index.d.ts.map +1 -1
- package/dist/types/src/hooks/useWheel.d.ts +5 -0
- package/dist/types/src/hooks/useWheel.d.ts.map +1 -1
- package/dist/types/src/index.d.ts +4 -1
- package/dist/types/src/index.d.ts.map +1 -1
- package/dist/types/src/model/atoms.d.ts +21 -3
- package/dist/types/src/model/atoms.d.ts.map +1 -1
- package/dist/types/src/model/projection.d.ts.map +1 -1
- package/dist/types/src/model/projections/constrained.d.ts +2 -1
- package/dist/types/src/model/projections/constrained.d.ts.map +1 -1
- package/dist/types/src/model/projections/dynamic.d.ts.map +1 -1
- package/dist/types/src/model/projections/lattice.d.ts.map +1 -1
- package/dist/types/src/model/registry.d.ts +21 -3
- package/dist/types/src/model/registry.d.ts.map +1 -1
- package/dist/types/src/model/store.d.ts +10 -2
- package/dist/types/src/model/store.d.ts.map +1 -1
- package/dist/types/src/model/types.d.ts +699 -338
- package/dist/types/src/model/types.d.ts.map +1 -1
- package/dist/types/src/playwright/SceneManager.d.ts +1 -1
- package/dist/types/src/playwright/SceneManager.d.ts.map +1 -1
- package/dist/types/src/utils/builder.d.ts +22 -41
- package/dist/types/src/utils/builder.d.ts.map +1 -1
- package/dist/types/src/utils/camera.d.ts +19 -10
- package/dist/types/src/utils/camera.d.ts.map +1 -1
- package/dist/types/src/utils/clipboard.d.ts +4 -2
- package/dist/types/src/utils/clipboard.d.ts.map +1 -1
- package/dist/types/src/utils/diagram.d.ts.map +1 -1
- package/dist/types/src/utils/group.d.ts +14 -0
- package/dist/types/src/utils/group.d.ts.map +1 -0
- package/dist/types/src/utils/group.test.d.ts +2 -0
- package/dist/types/src/utils/group.test.d.ts.map +1 -0
- package/dist/types/src/utils/gutter-route.d.ts +7 -0
- package/dist/types/src/utils/gutter-route.d.ts.map +1 -1
- package/dist/types/src/utils/hit.d.ts +3 -20
- package/dist/types/src/utils/hit.d.ts.map +1 -1
- package/dist/types/src/utils/index.d.ts +3 -0
- package/dist/types/src/utils/index.d.ts.map +1 -1
- package/dist/types/src/utils/lattice.d.ts +5 -0
- package/dist/types/src/utils/lattice.d.ts.map +1 -1
- package/dist/types/src/utils/layers.d.ts +35 -0
- package/dist/types/src/utils/layers.d.ts.map +1 -0
- package/dist/types/src/utils/layers.test.d.ts +2 -0
- package/dist/types/src/utils/layers.test.d.ts.map +1 -0
- package/dist/types/src/utils/parts.d.ts +25 -9
- package/dist/types/src/utils/parts.d.ts.map +1 -1
- package/dist/types/src/utils/ports.d.ts +2 -0
- package/dist/types/src/utils/ports.d.ts.map +1 -1
- package/dist/types/src/utils/properties.d.ts +4 -3
- package/dist/types/src/utils/properties.d.ts.map +1 -1
- package/dist/types/src/utils/route.d.ts +5 -0
- package/dist/types/src/utils/route.d.ts.map +1 -1
- package/dist/types/src/utils/scenes.d.ts +14 -0
- package/dist/types/src/utils/scenes.d.ts.map +1 -0
- package/dist/types/src/utils/scenes.test.d.ts +2 -0
- package/dist/types/src/utils/scenes.test.d.ts.map +1 -0
- package/dist/types/src/utils/shapes.d.ts +23 -9
- package/dist/types/src/utils/shapes.d.ts.map +1 -1
- package/dist/types/src/utils/style.d.ts +29 -6
- package/dist/types/src/utils/style.d.ts.map +1 -1
- package/dist/types/src/utils/style.test.d.ts +2 -0
- package/dist/types/src/utils/style.test.d.ts.map +1 -0
- package/dist/types/src/utils/testing.d.ts +2 -2
- package/dist/types/src/utils/testing.d.ts.map +1 -1
- package/dist/types/src/utils/undo.d.ts +15 -4
- package/dist/types/src/utils/undo.d.ts.map +1 -1
- package/dist/types/tsconfig.tsbuildinfo +1 -1
- package/package.json +20 -26
- package/src/components/About/About.stories.tsx +32 -0
- package/src/components/About/About.tsx +38 -0
- package/src/components/About/index.ts +5 -0
- package/src/components/Breadcrumbs/Breadcrumbs.tsx +17 -3
- package/src/components/ControlFrame/ControlFrame.tsx +138 -82
- package/src/components/Grid/Grid.stories.tsx +1 -1
- package/src/components/Layers/Layers.stories.tsx +131 -0
- package/src/components/Layers/Layers.tsx +216 -0
- package/src/components/Layers/index.ts +5 -0
- package/src/components/Palette/Palette.stories.tsx +135 -0
- package/src/components/Palette/Palette.tsx +230 -39
- package/src/components/PartEditor/PartEditor.tsx +4 -3
- package/src/components/Properties/AlignField.tsx +117 -0
- package/src/components/Properties/ClassField.tsx +44 -0
- package/src/components/Properties/FontField.tsx +73 -0
- package/src/components/Properties/LayerField.tsx +61 -0
- package/src/components/Properties/Properties.stories.tsx +113 -0
- package/src/components/Properties/Properties.tsx +240 -60
- package/src/components/Properties/SceneField.tsx +22 -0
- package/src/components/Properties/StyleGrid.stories.tsx +50 -0
- package/src/components/Properties/StyleGrid.tsx +54 -26
- package/src/components/SceneLayer/SceneLayer.tsx +497 -188
- package/src/components/SceneView/Constrained.stories.tsx +1 -1
- package/src/components/SceneView/Dock.stories.tsx +95 -0
- package/src/components/SceneView/Dock.tsx +146 -0
- package/src/components/SceneView/Dynamic.stories.tsx +1 -1
- package/src/components/SceneView/SceneView.stories.tsx +170 -18
- package/src/components/SceneView/SceneView.tsx +565 -151
- package/src/components/SceneView/SceneViewContext.ts +23 -2
- package/src/components/SceneView/Scored.stories.tsx +11 -11
- package/src/components/SceneView/usePointerMachine.ts +80 -20
- package/src/components/SceneView/useSceneCamera.ts +15 -8
- package/src/components/SceneView/useSceneClipboard.ts +4 -0
- package/src/components/SceneView/useSceneKeys.ts +6 -4
- package/src/components/SceneView/useSceneNavigation.ts +44 -33
- package/src/components/Toolbar/Toolbar.stories.tsx +14 -15
- package/src/components/Toolbar/Toolbar.tsx +15 -4
- package/src/components/index.ts +2 -0
- package/src/hooks/useWheel.ts +35 -0
- package/src/index.ts +4 -3
- package/src/model/atoms.ts +27 -3
- package/src/model/projection.ts +5 -0
- package/src/model/projections/constrained.test.ts +18 -10
- package/src/model/projections/constrained.ts +19 -8
- package/src/model/projections/dynamic.ts +5 -2
- package/src/model/projections/lattice.ts +15 -3
- package/src/model/registry.test.ts +14 -9
- package/src/model/registry.ts +39 -34
- package/src/model/store.ts +12 -2
- package/src/model/types.ts +124 -36
- package/src/playwright/SceneManager.ts +2 -2
- package/src/playwright/create.spec.ts +5 -3
- package/src/playwright/demo.spec.ts +2 -2
- package/src/playwright/lattice.spec.ts +4 -5
- package/src/playwright/scene.spec.ts +27 -25
- package/src/utils/builder.test.ts +46 -9
- package/src/utils/builder.ts +42 -18
- package/src/utils/camera.test.ts +22 -51
- package/src/utils/camera.ts +39 -32
- package/src/utils/clipboard.test.ts +23 -2
- package/src/utils/clipboard.ts +8 -4
- package/src/utils/diagram.test.ts +3 -1
- package/src/utils/diagram.ts +3 -25
- package/src/utils/group.test.ts +69 -0
- package/src/utils/group.ts +94 -0
- package/src/utils/gutter-route.test.ts +49 -0
- package/src/utils/gutter-route.ts +142 -16
- package/src/utils/hit.test.ts +4 -38
- package/src/utils/hit.ts +4 -26
- package/src/utils/index.ts +3 -0
- package/src/utils/lattice.ts +7 -1
- package/src/utils/layers.test.ts +139 -0
- package/src/utils/layers.ts +175 -0
- package/src/utils/parts.test.ts +66 -32
- package/src/utils/parts.ts +63 -41
- package/src/utils/ports.ts +7 -4
- package/src/utils/properties.test.ts +23 -6
- package/src/utils/properties.ts +66 -16
- package/src/utils/route.test.ts +51 -2
- package/src/utils/route.ts +133 -23
- package/src/utils/scenes.test.ts +38 -0
- package/src/utils/scenes.ts +54 -0
- package/src/utils/shapes.test.ts +33 -8
- package/src/utils/shapes.ts +51 -35
- package/src/utils/style.test.ts +54 -0
- package/src/utils/style.ts +153 -40
- package/src/utils/testing.test.ts +3 -3
- package/src/utils/testing.ts +21 -30
- package/src/utils/undo.test.ts +25 -2
- package/src/utils/undo.ts +50 -13
- package/dist/lib/chunk-Grid.mjs +0 -99
- package/dist/lib/chunk-Grid.mjs.map +0 -1
- package/dist/lib/scene.mjs +0 -6835
- package/dist/lib/scene.mjs.map +0 -1
- package/dist/lib/types.mjs +0 -16
- package/dist/lib/types.mjs.map +0 -1
- package/dist/types/src/archive/components/Canvas/Canvas.d.ts +0 -15
- package/dist/types/src/archive/components/Canvas/Canvas.d.ts.map +0 -1
- package/dist/types/src/archive/components/Canvas/Canvas.stories.d.ts +0 -18
- package/dist/types/src/archive/components/Canvas/Canvas.stories.d.ts.map +0 -1
- package/dist/types/src/archive/components/Canvas/index.d.ts +0 -2
- package/dist/types/src/archive/components/Canvas/index.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/CellGrid.d.ts +0 -21
- package/dist/types/src/archive/components/CellGrid/CellGrid.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/CellGrid.stories.d.ts +0 -21
- package/dist/types/src/archive/components/CellGrid/CellGrid.stories.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/headers/Ruler.d.ts +0 -15
- package/dist/types/src/archive/components/CellGrid/headers/Ruler.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/headers/TrackHeader.d.ts +0 -19
- package/dist/types/src/archive/components/CellGrid/headers/TrackHeader.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/headers/index.d.ts +0 -3
- package/dist/types/src/archive/components/CellGrid/headers/index.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/index.d.ts +0 -6
- package/dist/types/src/archive/components/CellGrid/index.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/input/index.d.ts +0 -3
- package/dist/types/src/archive/components/CellGrid/input/index.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/input/pointer.d.ts +0 -33
- package/dist/types/src/archive/components/CellGrid/input/pointer.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/input/wheel.d.ts +0 -14
- package/dist/types/src/archive/components/CellGrid/input/wheel.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/render/index.d.ts +0 -3
- package/dist/types/src/archive/components/CellGrid/render/index.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/render/overlay-layer.d.ts +0 -21
- package/dist/types/src/archive/components/CellGrid/render/overlay-layer.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/render/static-layer.d.ts +0 -36
- package/dist/types/src/archive/components/CellGrid/render/static-layer.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/state/atoms.d.ts +0 -23
- package/dist/types/src/archive/components/CellGrid/state/atoms.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/state/index.d.ts +0 -4
- package/dist/types/src/archive/components/CellGrid/state/index.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/state/types.d.ts +0 -39
- package/dist/types/src/archive/components/CellGrid/state/types.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/state/viewport.d.ts +0 -52
- package/dist/types/src/archive/components/CellGrid/state/viewport.d.ts.map +0 -1
- package/dist/types/src/archive/components/CellGrid/state/viewport.test.d.ts +0 -2
- package/dist/types/src/archive/components/CellGrid/state/viewport.test.d.ts.map +0 -1
- package/dist/types/src/archive/components/FPS.d.ts +0 -9
- package/dist/types/src/archive/components/FPS.d.ts.map +0 -1
- package/dist/types/src/archive/components/Grid/Grid.d.ts +0 -6
- package/dist/types/src/archive/components/Grid/Grid.d.ts.map +0 -1
- package/dist/types/src/archive/components/Grid/index.d.ts +0 -2
- package/dist/types/src/archive/components/Grid/index.d.ts.map +0 -1
- package/dist/types/src/archive/components/index.d.ts +0 -5
- package/dist/types/src/archive/components/index.d.ts.map +0 -1
- package/dist/types/src/archive/hooks/index.d.ts +0 -5
- package/dist/types/src/archive/hooks/index.d.ts.map +0 -1
- package/dist/types/src/archive/hooks/projection.d.ts +0 -60
- package/dist/types/src/archive/hooks/projection.d.ts.map +0 -1
- package/dist/types/src/archive/hooks/useCanvasContext.d.ts +0 -13
- package/dist/types/src/archive/hooks/useCanvasContext.d.ts.map +0 -1
- package/dist/types/src/archive/hooks/useDrag.d.ts +0 -6
- package/dist/types/src/archive/hooks/useDrag.d.ts.map +0 -1
- package/dist/types/src/archive/hooks/useWheel.d.ts +0 -8
- package/dist/types/src/archive/hooks/useWheel.d.ts.map +0 -1
- package/dist/types/src/archive/index.d.ts +0 -5
- package/dist/types/src/archive/index.d.ts.map +0 -1
- package/dist/types/src/archive/types.d.ts +0 -19
- package/dist/types/src/archive/types.d.ts.map +0 -1
- package/dist/types/src/archive/util/index.d.ts +0 -4
- package/dist/types/src/archive/util/index.d.ts.map +0 -1
- package/dist/types/src/archive/util/svg-path.d.ts +0 -4
- package/dist/types/src/archive/util/svg-path.d.ts.map +0 -1
- package/dist/types/src/archive/util/svg.d.ts +0 -32
- package/dist/types/src/archive/util/svg.d.ts.map +0 -1
- package/dist/types/src/archive/util/svg.stories.d.ts +0 -15
- package/dist/types/src/archive/util/svg.stories.d.ts.map +0 -1
- package/dist/types/src/archive/util/util.d.ts +0 -17
- package/dist/types/src/archive/util/util.d.ts.map +0 -1
- package/dist/types/src/scene.d.ts +0 -5
- package/dist/types/src/scene.d.ts.map +0 -1
- package/src/archive/components/Canvas/Canvas.stories.tsx +0 -111
- package/src/archive/components/Canvas/Canvas.tsx +0 -87
- package/src/archive/components/Canvas/index.ts +0 -5
- package/src/archive/components/CellGrid/CellGrid.stories.tsx +0 -239
- package/src/archive/components/CellGrid/CellGrid.tsx +0 -270
- package/src/archive/components/CellGrid/headers/Ruler.tsx +0 -71
- package/src/archive/components/CellGrid/headers/TrackHeader.tsx +0 -58
- package/src/archive/components/CellGrid/headers/index.ts +0 -6
- package/src/archive/components/CellGrid/index.ts +0 -9
- package/src/archive/components/CellGrid/input/index.ts +0 -6
- package/src/archive/components/CellGrid/input/pointer.ts +0 -236
- package/src/archive/components/CellGrid/input/wheel.ts +0 -68
- package/src/archive/components/CellGrid/render/index.ts +0 -6
- package/src/archive/components/CellGrid/render/overlay-layer.ts +0 -66
- package/src/archive/components/CellGrid/render/static-layer.ts +0 -112
- package/src/archive/components/CellGrid/state/atoms.ts +0 -43
- package/src/archive/components/CellGrid/state/index.ts +0 -7
- package/src/archive/components/CellGrid/state/types.ts +0 -40
- package/src/archive/components/CellGrid/state/viewport.test.ts +0 -50
- package/src/archive/components/CellGrid/state/viewport.ts +0 -94
- package/src/archive/components/FPS.tsx +0 -98
- package/src/archive/components/Grid/Grid.tsx +0 -17
- package/src/archive/components/Grid/index.ts +0 -5
- package/src/archive/components/index.ts +0 -8
- package/src/archive/hooks/index.ts +0 -8
- package/src/archive/hooks/projection.tsx +0 -156
- package/src/archive/hooks/useCanvasContext.ts +0 -32
- package/src/archive/hooks/useDrag.tsx +0 -96
- package/src/archive/hooks/useWheel.tsx +0 -80
- package/src/archive/index.ts +0 -8
- package/src/archive/types.ts +0 -14
- package/src/archive/util/index.ts +0 -7
- package/src/archive/util/svg-path.ts +0 -13
- package/src/archive/util/svg.stories.tsx +0 -46
- package/src/archive/util/svg.tsx +0 -128
- package/src/archive/util/util.ts +0 -50
- 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', '
|
|
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('
|
|
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 = {
|
package/src/utils/clipboard.ts
CHANGED
|
@@ -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')
|
|
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);
|
package/src/utils/diagram.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
|
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
|
|
71
|
-
|
|
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)
|
|
84
|
-
//
|
|
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
|
|
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.
|
|
96
|
-
|
|
97
|
-
|
|
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(
|
package/src/utils/hit.test.ts
CHANGED
|
@@ -4,8 +4,8 @@
|
|
|
4
4
|
|
|
5
5
|
import { describe, test } from 'vitest';
|
|
6
6
|
|
|
7
|
-
import { type BuiltinNode,
|
|
8
|
-
import {
|
|
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('
|
|
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
|
-
|
|
81
|
-
expect(contentBounds({ id: 'empty', nodes: {}, links: {} })).toEqual(DEFAULT_EXTENT);
|
|
47
|
+
expect(contentBounds({ id: 'empty', nodes: {}, links: {} })).toBeUndefined();
|
|
82
48
|
});
|
|
83
49
|
});
|