@likec4/diagram 1.17.0 → 1.17.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@likec4/diagram",
3
- "version": "1.17.0",
3
+ "version": "1.17.1",
4
4
  "license": "MIT",
5
5
  "bugs": "https://github.com/likec4/likec4/issues",
6
6
  "homepage": "https://likec4.dev",
@@ -23,44 +23,47 @@
23
23
  "exports": {
24
24
  ".": "./src/index.ts"
25
25
  },
26
- "packageManager": "yarn@4.5.1",
26
+ "packageManager": "yarn@4.5.3",
27
27
  "scripts": {
28
28
  "typecheck": "tsc --noEmit",
29
29
  "lint": "run -T eslint src/ --fix",
30
- "clean": "rm -r -f -g 'dist/*' .tsbuildinfo"
30
+ "clean": "rm -r -f -g 'dist/*' .tsbuildinfo",
31
+ "test": "vitest run --no-isolate"
31
32
  },
32
33
  "dependencies": {
33
34
  "@dagrejs/dagre": "^1.1.4",
34
- "@likec4/core": "1.17.0",
35
- "@mantine/vanilla-extract": "7.13.4",
35
+ "@likec4/core": "1.17.1",
36
+ "@mantine/vanilla-extract": "7.14.1",
36
37
  "@react-hookz/web": "^24.0.4",
37
38
  "@tabler/icons-react": "3.17.0",
38
- "@vanilla-extract/css": "^1.16.0",
39
+ "@vanilla-extract/css": "^1.16.1",
39
40
  "@vanilla-extract/css-utils": "^0.1.4",
40
41
  "@vanilla-extract/dynamic": "^2.1.2",
41
- "@xyflow/react": "12.3.4",
42
- "@xyflow/system": "0.0.45",
42
+ "@xyflow/react": "12.3.5",
43
+ "@xyflow/system": "0.0.46",
43
44
  "bezier-js": "^6.1.4",
44
45
  "clsx": "^2.1.1",
45
46
  "d3-shape": "^3.2.0",
46
47
  "esm-env": "^1.1.4",
47
48
  "fast-equals": "^5.0.1",
48
- "framer-motion": "^11.11.11",
49
+ "framer-motion": "^11.11.17",
49
50
  "khroma": "^2.1.0",
50
- "remeda": "^2.16.0",
51
- "type-fest": "4.26.1",
51
+ "remeda": "^2.17.3",
52
+ "type-fest": "4.27.1",
52
53
  "zustand": "^4.5.5"
53
54
  },
54
55
  "peerDependencies": {
55
- "@mantine/core": "^7.13.4",
56
- "@mantine/hooks": "^7.13.4",
56
+ "@mantine/core": "^7.14.1",
57
+ "@mantine/hooks": "^7.14.1",
58
+ "@mantine/spotlight": "^7.14.1",
57
59
  "react": "^18.3.1",
58
60
  "react-dom": "^18.3.1"
59
61
  },
60
62
  "devDependencies": {
61
- "@likec4/tsconfig": "1.17.0",
62
- "@mantine/core": "7.13.4",
63
- "@mantine/hooks": "7.13.4",
63
+ "@likec4/tsconfig": "1.17.1",
64
+ "@mantine/core": "7.14.1",
65
+ "@mantine/hooks": "7.14.1",
66
+ "@mantine/spotlight": "7.14.1",
64
67
  "@types/bezier-js": "^4.1.3",
65
68
  "@types/d3-shape": "^3.1.6",
66
69
  "@types/react": "^18.3.11",
@@ -68,6 +71,7 @@
68
71
  "execa": "^9.3.1",
69
72
  "react": "^18.3.1",
70
73
  "react-dom": "^18.3.1",
71
- "typescript": "^5.6.3"
74
+ "typescript": "^5.7.2",
75
+ "vitest": "^2.1.5"
72
76
  }
73
77
  }
@@ -148,6 +148,12 @@ export interface LikeC4DiagramProperties {
148
148
  */
149
149
  enableFocusMode?: boolean | undefined
150
150
 
151
+ /**
152
+ * Enable search popup for elements and views
153
+ * @default true
154
+ */
155
+ enableSearch?: boolean | undefined
156
+
151
157
  /**
152
158
  * Enable modal with element details
153
159
  * @default false
@@ -1,6 +1,6 @@
1
1
  import { ReactFlowProvider as XYFlowProvider } from '@xyflow/react'
2
2
  import clsx from 'clsx'
3
- import { deepEqual, shallowEqual } from 'fast-equals'
3
+ import { deepEqual } from 'fast-equals'
4
4
  import { memo, useEffect, useRef } from 'react'
5
5
  import { isEmpty } from 'remeda'
6
6
  import { rootClassName } from './globals.css'
@@ -9,6 +9,7 @@ import { LikeC4CustomColors } from './LikeC4CustomColors'
9
9
  import * as css from './LikeC4Diagram.css'
10
10
  import { type LikeC4DiagramEventHandlers, type LikeC4DiagramProperties } from './LikeC4Diagram.props'
11
11
  import { useLikeC4Model } from './likec4model'
12
+ import { LikeC4Search } from './LikeC4Search'
12
13
  import { Overlays } from './overlays'
13
14
  import { DiagramContextProvider } from './state/DiagramContext'
14
15
  import { EnsureMantine } from './ui/EnsureMantine'
@@ -39,6 +40,7 @@ export function LikeC4Diagram({
39
40
  enableElementDetails = false,
40
41
  enableRelationshipDetails = enableElementDetails,
41
42
  enableRelationshipBrowser = enableRelationshipDetails,
43
+ enableSearch = true,
42
44
  initialWidth,
43
45
  initialHeight,
44
46
  keepAspectRatio = false,
@@ -111,6 +113,7 @@ export function LikeC4Diagram({
111
113
  showNotations={showNotations}
112
114
  enableFocusMode={enableFocusMode}
113
115
  enableRelationshipDetails={enableRelationshipDetails && hasLikec4model}
116
+ enableSearch={hasLikec4model && enableSearch}
114
117
  enableElementDetails={enableElementDetails && hasLikec4model}
115
118
  enableDynamicViewWalkthrough={enableDynamicViewWalkthrough}
116
119
  enableRelationshipBrowser={enableRelationshipBrowser && hasLikec4model}
@@ -134,6 +137,7 @@ export function LikeC4Diagram({
134
137
  <LikeC4DiagramInnerMemo
135
138
  background={background}
136
139
  showDiagramTitle={showDiagramTitle}
140
+ enableSearch={hasLikec4model && enableSearch}
137
141
  />
138
142
  </DiagramContextProvider>
139
143
  </XYFlowProvider>
@@ -146,10 +150,12 @@ LikeC4Diagram.displayName = 'LikeC4Diagram'
146
150
  type LikeC4DiagramInnerProps = {
147
151
  background: NonNullable<LikeC4DiagramProperties['background']>
148
152
  showDiagramTitle: boolean
153
+ enableSearch: boolean
149
154
  }
150
155
  const LikeC4DiagramInnerMemo = /* @__PURE__ */ memo<LikeC4DiagramInnerProps>(function LikeC4DiagramInner({
151
156
  background,
152
- showDiagramTitle
157
+ showDiagramTitle,
158
+ enableSearch
153
159
  }) {
154
160
  const {
155
161
  isInitialized,
@@ -183,6 +189,7 @@ const LikeC4DiagramInnerMemo = /* @__PURE__ */ memo<LikeC4DiagramInnerProps>(fun
183
189
  />
184
190
  </XYFlow>
185
191
  {enableOverlays && <Overlays />}
192
+ {enableSearch && <LikeC4Search />}
186
193
  {isInitialized && (
187
194
  <>
188
195
  {fitView && <FitViewOnDiagramChange />}
@@ -0,0 +1,146 @@
1
+ import type { DiagramView } from '@likec4/core'
2
+ import { Spotlight, SpotlightActionsGroup, type SpotlightActionData } from '@mantine/spotlight'
3
+ import { IconRectangularPrism, IconSitemap, IconSearch } from '@tabler/icons-react'
4
+ import { memo, useMemo, useState } from 'react'
5
+ import { filter, map, pipe } from 'remeda'
6
+ import { useDiagramState, useDiagramStoreApi, useMantinePortalProps } from './hooks'
7
+ import { useLikeC4Model } from './likec4model'
8
+ import { Center, Group, Highlight, Text } from '@mantine/core'
9
+
10
+ interface QueryMatch {
11
+ data: SpotlightActionData,
12
+ keyword: string,
13
+ match: string
14
+ }
15
+
16
+ function filterAction(actionData: SpotlightActionData, query: string) : QueryMatch | undefined {
17
+ const queryLower = query.toLowerCase();
18
+ const keywords = Array.isArray(actionData.keywords) ? actionData.keywords : [];
19
+ const keywordMatch = keywords
20
+ .map(keyword => {
21
+ const index = keyword.toLowerCase().indexOf(queryLower);
22
+ return index !== -1 ? {
23
+ keyword: keyword,
24
+ match: keyword.substring(index, index + query.length)
25
+ } : null;
26
+ })
27
+ .find(match => !!match);
28
+ return !!keywordMatch ? {
29
+ data: actionData,
30
+ keyword: keywordMatch.keyword,
31
+ match: keywordMatch.match
32
+ } : undefined;
33
+ }
34
+
35
+ function buildFilteredActions(actionsData: SpotlightActionData[], query: string): JSX.Element[] {
36
+ return pipe(
37
+ actionsData,
38
+ map(a => filterAction(a, query)),
39
+ filter(qm => !!qm),
40
+ map(qm => buildSpotlightAction(qm))
41
+ );
42
+ }
43
+
44
+ function highlightMatch(match: QueryMatch): JSX.Element {
45
+ return <Highlight highlight={match.match}>{match.keyword}</Highlight>
46
+ }
47
+
48
+ function buildSpotlightAction(matchedAction: QueryMatch): JSX.Element {
49
+ const isMatchInLabel = matchedAction.keyword == matchedAction.data.label;
50
+ return <Spotlight.Action onClick={matchedAction.data.onClick}>
51
+ <Center style={{ marginRight: '8px '}}>
52
+ {matchedAction.data.leftSection}
53
+ </Center>
54
+ <Group wrap="nowrap" w="100%">
55
+ <div style={{ flex: 1 }}>
56
+ <Text>
57
+ {isMatchInLabel ? highlightMatch(matchedAction) : matchedAction.data.label}
58
+ </Text>
59
+ {!isMatchInLabel && <Text opacity={0.6} size="xs">{highlightMatch(matchedAction)}</Text>}
60
+ </div>
61
+ </Group>
62
+ </Spotlight.Action>
63
+ }
64
+
65
+ export const LikeC4Search = memo(() => {
66
+ const view = useDiagramState(s => s.view)
67
+ const model = useLikeC4Model(true)
68
+ const store = useDiagramStoreApi()
69
+ const [ query, setQuery ] = useState('')
70
+
71
+ const getNodeActionsData = (): SpotlightActionData[] => {
72
+ const { focusOnNode } = store.getState()
73
+
74
+ return pipe(
75
+ view.nodes,
76
+ filter(n => !!n.title),
77
+ map(n => ({
78
+ id: n.id,
79
+ label: n.title,
80
+ keywords: [
81
+ n.title,
82
+ ...(n.tags ?? []).map(t => `#${t}`),
83
+ ...(n.description ? [n.description] : [])
84
+ ].filter(k => k.toLowerCase()),
85
+ onClick: () => focusOnNode(n.id),
86
+ leftSection: <IconRectangularPrism />
87
+ }))
88
+ );
89
+ }
90
+
91
+ const getViewActionsData = (): SpotlightActionData[] => {
92
+ const { onNavigateTo } = store.getState()
93
+ const views = model.views()
94
+
95
+ return map(views, v => ({
96
+ id: v.id,
97
+ label: v.title ?? v.id,
98
+ keywords: [
99
+ v.title ?? v.id,
100
+ ...(v.tags ?? []),
101
+ ...(v.view.description ? [v.view.description] : [])
102
+ ],
103
+ onClick: () => {
104
+ store.setState({
105
+ hoveredNodeId: null,
106
+ lastOnNavigate: {
107
+ fromView: view.id,
108
+ toView: v.id,
109
+ fromNode: null
110
+ }
111
+ })
112
+ onNavigateTo?.(v.id)
113
+ },
114
+ leftSection: <IconSitemap />
115
+ }));
116
+ }
117
+
118
+ const { nodeActions, viewActions } = useMemo(() => ({
119
+ nodeActions: buildFilteredActions(getNodeActionsData(), query),
120
+ viewActions: buildFilteredActions(getViewActionsData(), query)
121
+ }),
122
+ [model, store, view, query]
123
+ );
124
+
125
+ const portalProps = useMantinePortalProps();
126
+ return (
127
+ <Spotlight.Root
128
+ {...portalProps}
129
+ shortcut={['mod + f', 'mod + k']}
130
+ query={query}
131
+ onQueryChange={setQuery}
132
+ scrollable
133
+ maxHeight={'calc(100vh - 100px)'}
134
+ >
135
+ <Spotlight.Search
136
+ placeholder="Search elements in current view and other views..."
137
+ leftSection={<IconSearch stroke={1.5} />}
138
+ />
139
+ <Spotlight.ActionsList>
140
+ {nodeActions.length > 0 && <SpotlightActionsGroup label="Elements">{nodeActions}</SpotlightActionsGroup> }
141
+ {viewActions.length > 0 && <SpotlightActionsGroup label="Views">{viewActions}</SpotlightActionsGroup> }
142
+ {nodeActions.length == 0 && viewActions.length == 0 && <Spotlight.Empty>Nothing found...</Spotlight.Empty> }
143
+ </Spotlight.ActionsList>
144
+ </Spotlight.Root>
145
+ );
146
+ });
@@ -48,6 +48,7 @@ export function StaticLikeC4Diagram({
48
48
  enableFocusMode={false}
49
49
  nodesSelectable={false}
50
50
  nodesDraggable={false}
51
+ enableSearch={false}
51
52
  {...rest}
52
53
  />
53
54
  )
@@ -278,8 +278,8 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
278
278
  <Box
279
279
  className={css.cardHeader}
280
280
  onPointerDown={e => controls.start(e)}>
281
- <Group align="start" justify="space-between" gap={'sm'} mb={'sm'}>
282
- <Group align="start" gap={'sm'} style={{ cursor: 'default' }}>
281
+ <Group align="start" justify="space-between" gap={'sm'} mb={'sm'} wrap="nowrap">
282
+ <Group align="start" gap={'sm'} style={{ cursor: 'default' }} wrap="nowrap">
283
283
  {elementIcon}
284
284
  <Box>
285
285
  <Text
@@ -304,7 +304,7 @@ export function ElementDetailsCard({ fqn }: ElementDetailsCardProps) {
304
304
  }}
305
305
  />
306
306
  </Group>
307
- <Group align="baseline" gap={'sm'}>
307
+ <Group align="baseline" gap={'sm'} wrap="nowrap">
308
308
  <Box>
309
309
  <SmallLabel>kind</SmallLabel>
310
310
  <Badge radius={'sm'} size="sm" fw={600} color="gray">{element.kind}</Badge>
@@ -0,0 +1,473 @@
1
+ import { invariant, type NonEmptyArray } from '@likec4/core'
2
+ import type { Rect, XYPosition } from '@xyflow/react'
3
+ import { map, pick, pipe, reduce, sortBy } from 'remeda'
4
+
5
+ export type GridAlignmentMode = 'Column' | 'Row'
6
+ export type LinearAlignmentMode = 'Left' | 'Center' | 'Right' | 'Top' | 'Middle' | 'Bottom'
7
+
8
+ export abstract class Aligner {
9
+ abstract computeLayout(nodes: NodeRect[]): void
10
+ abstract applyPosition(node: NodeRect): Partial<XYPosition>
11
+ }
12
+
13
+ export class LinearAligner extends Aligner {
14
+ private alignTo: number | undefined
15
+ constructor(
16
+ private getEdgePosition: (nodes: NodeRect[]) => number,
17
+ private computePosition: (alignTo: number, node: NodeRect) => number,
18
+ private propertyToEdit: keyof XYPosition
19
+ ) {
20
+ super()
21
+ }
22
+
23
+ override computeLayout(nodes: NodeRect[]) {
24
+ this.alignTo = this.getEdgePosition(nodes)
25
+ }
26
+
27
+ override applyPosition(node: NodeRect): Partial<XYPosition> {
28
+ return {
29
+ [this.propertyToEdit]: this.computePosition(this.alignTo!, node)
30
+ }
31
+ }
32
+ }
33
+
34
+ export type NodeRect = Rect & { id: string }
35
+ type Layer = {
36
+ primaryAxisSize: number
37
+ nodes: NodeRect[]
38
+ occupiedSpace: number
39
+ layout: Layout | null
40
+ }
41
+ type Layout = {
42
+ nodePositions: Map<string, XYPosition>
43
+ refLayer: Layer | null
44
+ }
45
+
46
+ export class GridAligner extends Aligner {
47
+ private layout: Map<string, XYPosition> = new Map()
48
+ private axisPreset: {
49
+ primaryAxisDimension: 'height' | 'width'
50
+ secondaryAxisDimension: 'height' | 'width'
51
+ primaryAxisCoord: 'x' | 'y'
52
+ secondaryAxisCoord: 'x' | 'y'
53
+ }
54
+
55
+ private get primaryAxisCoord() {
56
+ return this.axisPreset.primaryAxisCoord
57
+ }
58
+ private get secondaryAxisCoord() {
59
+ return this.axisPreset.secondaryAxisCoord
60
+ }
61
+ private get primaryAxisDimension() {
62
+ return this.axisPreset.primaryAxisDimension
63
+ }
64
+ private get secondaryAxisDimension() {
65
+ return this.axisPreset.secondaryAxisDimension
66
+ }
67
+
68
+ constructor(alignmentMode: GridAlignmentMode) {
69
+ super()
70
+
71
+ this.axisPreset = alignmentMode === 'Column'
72
+ ? {
73
+ primaryAxisDimension: 'width',
74
+ secondaryAxisDimension: 'height',
75
+ primaryAxisCoord: 'x',
76
+ secondaryAxisCoord: 'y'
77
+ }
78
+ : {
79
+ primaryAxisDimension: 'height',
80
+ secondaryAxisDimension: 'width',
81
+ primaryAxisCoord: 'y',
82
+ secondaryAxisCoord: 'x'
83
+ }
84
+ }
85
+
86
+ override applyPosition(node: NodeRect): Partial<XYPosition> {
87
+ return this.layout?.get(node.id) ?? {}
88
+ }
89
+
90
+ override computeLayout(nodes: NodeRect[]) {
91
+ // Sort by primary axis
92
+ const sortedNodeRects = pipe(
93
+ nodes,
94
+ sortBy(r => r[this.primaryAxisCoord])
95
+ )
96
+
97
+ const layoutRect = this.getLayoutRect(sortedNodeRects)
98
+
99
+ const layers = this.getLayers(sortedNodeRects)
100
+
101
+ this.layout = this.buildLayout(layers, layoutRect, sortedNodeRects)
102
+ }
103
+
104
+ private getLayoutRect(nodeRects: NodeRect[]): Rect {
105
+ const x = Math.min(...nodeRects.map(n => n.x))
106
+ const y = Math.min(...nodeRects.map(n => n.y))
107
+ const right = Math.max(...nodeRects.map(n => n.x + n.width))
108
+ const bottom = Math.max(...nodeRects.map(n => n.y + n.height))
109
+
110
+ return {
111
+ x,
112
+ y,
113
+ width: right - x,
114
+ height: bottom - y
115
+ }
116
+ }
117
+
118
+ private getLayers(sortedNodeRects: NodeRect[]): NonEmptyArray<Layer> {
119
+ const layers: Layer[] = []
120
+ let layerEnd = 0
121
+ let layer = null
122
+
123
+ for (let node of sortedNodeRects) {
124
+ if (!!layer && node[this.primaryAxisCoord] < layerEnd) {
125
+ layer.nodes.push(node)
126
+ layer.primaryAxisSize = Math.max(layer.primaryAxisSize, node[this.primaryAxisDimension])
127
+ layer.occupiedSpace += node[this.secondaryAxisDimension]
128
+ layerEnd = Math.max(
129
+ node[this.primaryAxisCoord] + node[this.primaryAxisDimension],
130
+ layerEnd
131
+ )
132
+ } else {
133
+ layer = {
134
+ primaryAxisSize: node[this.primaryAxisDimension],
135
+ nodes: [node],
136
+ occupiedSpace: node[this.secondaryAxisDimension],
137
+ layout: null
138
+ }
139
+ layers.push(layer)
140
+ layerEnd = node[this.primaryAxisCoord] + node[this.primaryAxisDimension]
141
+ continue
142
+ }
143
+ }
144
+
145
+ layers.forEach(l =>
146
+ l.nodes.sort((a, b) => a[this.secondaryAxisCoord] - b[this.secondaryAxisCoord])
147
+ )
148
+
149
+ return layers as NonEmptyArray<Layer>
150
+ }
151
+
152
+ private buildLayout(layers: NonEmptyArray<Layer>, layoutRect: Rect, nodeRects: NodeRect[]): Map<string, XYPosition> {
153
+ const nodeMap = new Map(nodeRects.map(n => [n.id, n]))
154
+ const layout: [string, XYPosition][] = []
155
+ const occupiedSpace = layers.reduce((a, b) => a + b.primaryAxisSize, 0)
156
+ const rowMargin = layers.length > 1
157
+ ? (layoutRect[this.primaryAxisDimension] - occupiedSpace) / (layers.length - 1)
158
+ : 0
159
+
160
+ // Find the widest layer and layout diagram from there
161
+ const baseLayerIndex = layers.reduce(
162
+ (widestLayerIndex, layer, i) =>
163
+ layers[widestLayerIndex]!.occupiedSpace < layer.occupiedSpace ? i : widestLayerIndex,
164
+ 0
165
+ )
166
+ const baseLayer = layers[baseLayerIndex]!
167
+ const baseLayerPosition = layers.slice(0, baseLayerIndex).reduce(
168
+ (a, layer) => a + layer.primaryAxisSize + rowMargin,
169
+ layoutRect[this.primaryAxisCoord]
170
+ )
171
+ const baseLayerLayout = this.buildLayerLayout(
172
+ baseLayer,
173
+ layoutRect,
174
+ baseLayerPosition,
175
+ nodeMap,
176
+ null
177
+ )
178
+ baseLayer.layout = baseLayerLayout
179
+ layout.push(...baseLayerLayout.nodePositions)
180
+
181
+ // Layout layers after the base layer
182
+ let placeNextLayerAt = baseLayerPosition + baseLayer.primaryAxisSize + rowMargin
183
+ let refLayer = baseLayer
184
+ for (let i = baseLayerIndex + 1; i < layers.length; i++) {
185
+ const layer = layers[i]!
186
+ layer.layout = this.buildLayerLayout(layer, layoutRect, placeNextLayerAt, nodeMap, refLayer)
187
+
188
+ layout.push(...layer.layout.nodePositions)
189
+ refLayer = layer.layout.refLayer ?? layer
190
+ placeNextLayerAt += layer.primaryAxisSize + rowMargin
191
+ }
192
+
193
+ // Layout layers before the base layer
194
+ placeNextLayerAt = baseLayerPosition
195
+ refLayer = baseLayer
196
+ for (let i = baseLayerIndex - 1; i >= 0; i--) {
197
+ const layer = layers[i]!
198
+ placeNextLayerAt -= layer.primaryAxisSize + rowMargin
199
+
200
+ layer.layout = this.buildLayerLayout(layer, layoutRect, placeNextLayerAt, nodeMap, refLayer)
201
+
202
+ layout.push(...layer.layout.nodePositions)
203
+ refLayer = layer.layout.refLayer ?? layer
204
+ }
205
+
206
+ return new Map(layout)
207
+ }
208
+
209
+ private buildLayerLayout(
210
+ layer: Layer,
211
+ layoutRect: Rect,
212
+ placeNextLayerAt: number,
213
+ nodeMap: Map<string, NodeRect>,
214
+ refLayer: Layer | null
215
+ ): Layout {
216
+ let bestLayerLayout = this.scoreLayout(
217
+ this.spaceAround(layer, layoutRect, placeNextLayerAt),
218
+ nodeMap
219
+ )
220
+
221
+ if (layer.nodes.length != 1) {
222
+ const currentlayerLayout = this.scoreLayout(
223
+ this.spaceBetween(layer, layoutRect, placeNextLayerAt),
224
+ nodeMap
225
+ )
226
+ bestLayerLayout = currentlayerLayout[0] < bestLayerLayout[0] ? currentlayerLayout : bestLayerLayout
227
+ }
228
+
229
+ if (refLayer && refLayer.nodes.length - 1 >= layer.nodes.length) {
230
+ const currentlayerLayout = this.scoreLayout(
231
+ this.placeInGaps(layer, placeNextLayerAt, refLayer),
232
+ nodeMap
233
+ )
234
+ bestLayerLayout = currentlayerLayout[0] < bestLayerLayout[0] ? currentlayerLayout : bestLayerLayout
235
+ }
236
+
237
+ if (refLayer && refLayer.nodes.length >= layer.nodes.length) {
238
+ const currentlayerLayout = this.scoreLayout(
239
+ this.placeInCells(layer, placeNextLayerAt, refLayer),
240
+ nodeMap
241
+ )
242
+ bestLayerLayout = currentlayerLayout[0] < bestLayerLayout[0] ? currentlayerLayout : bestLayerLayout
243
+ }
244
+
245
+ return bestLayerLayout[1]
246
+ }
247
+
248
+ private spaceBetween(
249
+ layer: Layer,
250
+ layoutRect: Rect,
251
+ placeNextLayerAt: number
252
+ ): Layout {
253
+ const freeSpace = layoutRect[this.secondaryAxisDimension] - layer.occupiedSpace
254
+ const margin = freeSpace / (layer.nodes.length - 1)
255
+
256
+ let placeNextNodeAt = layoutRect[this.secondaryAxisCoord]
257
+ const result = new Map<string, XYPosition>()
258
+
259
+ let i = 0
260
+ for (let node of layer.nodes) {
261
+ result.set(node.id, {
262
+ [this.secondaryAxisCoord]: placeNextNodeAt,
263
+ [this.primaryAxisCoord]: placeNextLayerAt
264
+ } as XYPosition)
265
+ placeNextNodeAt += node[this.secondaryAxisDimension] + margin
266
+ i++
267
+ }
268
+
269
+ return { nodePositions: result, refLayer: null }
270
+ }
271
+
272
+ private spaceAround(
273
+ layer: Layer,
274
+ layoutRect: Rect,
275
+ placeNextLayerAt: number
276
+ ): Layout {
277
+ const freeSpace = layoutRect[this.secondaryAxisDimension] - layer.occupiedSpace
278
+ const margin = freeSpace / (layer.nodes.length + 1)
279
+
280
+ let placeNextNodeAt = layoutRect[this.secondaryAxisCoord] + margin
281
+ const result = new Map<string, XYPosition>()
282
+
283
+ for (let node of sortBy(layer.nodes, n => n[this.secondaryAxisCoord])) {
284
+ result.set(node.id, {
285
+ [this.secondaryAxisCoord]: placeNextNodeAt,
286
+ [this.primaryAxisCoord]: placeNextLayerAt
287
+ } as XYPosition)
288
+ placeNextNodeAt += node[this.secondaryAxisDimension] + margin
289
+ }
290
+
291
+ return { nodePositions: result, refLayer: null }
292
+ }
293
+
294
+ private placeInGaps(
295
+ layer: Layer,
296
+ placeNextLayerAt: number,
297
+ refLayer: Layer
298
+ ): Layout {
299
+ const result = new Map<string, XYPosition>()
300
+
301
+ const nodes = layer.nodes
302
+ const placementOptions = this.getGapsPositions(refLayer)
303
+
304
+ let optionIndex = 0
305
+ for (let i = 0, node = nodes[i]!; i < nodes.length; i++, node = nodes[i]!) {
306
+ const nodeCenter = node[this.secondaryAxisCoord] + node[this.secondaryAxisDimension] / 2
307
+
308
+ let bestOffset = Infinity
309
+ while (optionIndex - i <= placementOptions.length - nodes.length) {
310
+ let position = placementOptions[optionIndex]!
311
+ const offset = position - nodeCenter
312
+
313
+ if (Math.abs(offset) < Math.abs(bestOffset)) {
314
+ bestOffset = offset
315
+
316
+ optionIndex++
317
+ }
318
+ else {
319
+ break
320
+ }
321
+ }
322
+
323
+ result.set(node.id, {
324
+ [this.secondaryAxisCoord]: node[this.secondaryAxisCoord] + bestOffset,
325
+ [this.primaryAxisCoord]: placeNextLayerAt
326
+ } as XYPosition)
327
+ }
328
+
329
+ return { nodePositions: result, refLayer }
330
+ }
331
+
332
+ private placeInCells(
333
+ layer: Layer,
334
+ placeNextLayerAt: number,
335
+ refLayer: Layer
336
+ ): Layout {
337
+ const result = new Map<string, XYPosition>()
338
+
339
+ const nodes = layer.nodes
340
+ const placementOptions = this.getNodePositions(refLayer)
341
+
342
+ let optionIndex = 0
343
+ for (let i = 0, node = nodes[i]!; i < nodes.length; i++, node = nodes[i]!) {
344
+ const nodeCenter = node[this.secondaryAxisCoord] + node[this.secondaryAxisDimension] / 2
345
+
346
+ let bestOffset = Infinity
347
+
348
+ while (optionIndex - i <= placementOptions.length - nodes.length) {
349
+ let position = placementOptions[optionIndex]!
350
+ const offset = position - nodeCenter
351
+
352
+ if (Math.abs(offset) < Math.abs(bestOffset)) {
353
+ bestOffset = offset
354
+
355
+ optionIndex++
356
+ }
357
+ else {
358
+ break
359
+ }
360
+ }
361
+
362
+ result.set(node.id, {
363
+ [this.secondaryAxisCoord]: node[this.secondaryAxisCoord] + bestOffset,
364
+ [this.primaryAxisCoord]: placeNextLayerAt
365
+ } as XYPosition)
366
+ }
367
+
368
+ return { nodePositions: result, refLayer }
369
+ }
370
+
371
+ private scoreLayout(
372
+ layout: Layout,
373
+ originalRects: Map<string, NodeRect>
374
+ ): [number, Layout] {
375
+ return [
376
+ pipe(
377
+ Array.from(layout.nodePositions),
378
+ map(([id, position]) => {
379
+ const originalRect = originalRects.get(id)
380
+ invariant(originalRect, `Could not find original rect for node ${id}`)
381
+ return [pick(originalRect, ['x', 'y']), position]
382
+ }),
383
+ map(([original, suggested]) =>
384
+ Math.abs(original![this.secondaryAxisCoord] - suggested![this.secondaryAxisCoord])
385
+ ),
386
+ reduce((a, b) => a + b, 0)
387
+ ),
388
+ layout
389
+ ]
390
+ }
391
+
392
+ private getGapsPositions(layer: Layer): number[] {
393
+ const result = []
394
+ const { layout, nodes } = layer
395
+
396
+ invariant(layout, 'Layout of the layer must be computed before calling getGapsPositions')
397
+
398
+ for (let i = 1; i < nodes.length; i++) {
399
+ const previousNode = nodes[i - 1]!
400
+ const currentNode = nodes[i]!
401
+ const previousNodePosition = layout.nodePositions.get(previousNode.id)!
402
+ const currentNodePosition = layout.nodePositions.get(currentNode.id)!
403
+
404
+ result.push(
405
+ (currentNodePosition[this.secondaryAxisCoord]
406
+ + previousNodePosition[this.secondaryAxisCoord]
407
+ + previousNode[this.secondaryAxisDimension]) / 2
408
+ )
409
+ }
410
+
411
+ return result
412
+ }
413
+
414
+ private getNodePositions(layer: Layer): number[] {
415
+ const result = []
416
+ const { layout, nodes } = layer
417
+
418
+ invariant(layout, 'Layout of the layer must be computed before calling getGapsPositions')
419
+
420
+ for (let i = 0; i < nodes.length; i++) {
421
+ const node = nodes[i]!
422
+ const nodePosition = layout.nodePositions.get(node.id)!
423
+
424
+ result.push(
425
+ nodePosition[this.secondaryAxisCoord]
426
+ + node[this.secondaryAxisDimension] / 2
427
+ )
428
+ }
429
+
430
+ return result
431
+ }
432
+ }
433
+
434
+ export function getLinearAligner(mode: LinearAlignmentMode): Aligner {
435
+ switch (mode) {
436
+ case 'Left':
437
+ return new LinearAligner(
438
+ nodes => Math.min(...nodes.map(n => n.x)),
439
+ (alignTo, _) => Math.floor(alignTo),
440
+ 'x'
441
+ )
442
+ case 'Top':
443
+ return new LinearAligner(
444
+ nodes => Math.min(...nodes.map(n => n.y)),
445
+ (alignTo, _) => Math.floor(alignTo),
446
+ 'y'
447
+ )
448
+ case 'Right':
449
+ return new LinearAligner(
450
+ nodes => Math.max(...nodes.map(n => n.x + n.width)),
451
+ (alignTo, node) => Math.floor(alignTo - node.width!),
452
+ 'x'
453
+ )
454
+ case 'Bottom':
455
+ return new LinearAligner(
456
+ nodes => Math.max(...nodes.map(n => n.y + n.height)),
457
+ (alignTo, node) => Math.floor(alignTo - node.height!),
458
+ 'y'
459
+ )
460
+ case 'Center':
461
+ return new LinearAligner(
462
+ nodes => Math.min(...nodes.map(n => n.x + n.width / 2)),
463
+ (alignTo, node) => Math.floor(alignTo - node.width / 2),
464
+ 'x'
465
+ )
466
+ case 'Middle':
467
+ return new LinearAligner(
468
+ nodes => Math.min(...nodes.map(n => n.y + n.height / 2)),
469
+ (alignTo, node) => Math.floor(alignTo - node.height / 2),
470
+ 'y'
471
+ )
472
+ }
473
+ }
@@ -0,0 +1,67 @@
1
+ import { nonexhaustive } from '@likec4/core'
2
+ import type { InternalNode } from '@xyflow/react'
3
+ import { getNodeDimensions } from '@xyflow/system'
4
+ import { hasAtLeast } from 'remeda'
5
+ import type { DiagramState } from '../hooks'
6
+ import type { XYFlowNode } from '../xyflow/types'
7
+ import { createLayoutConstraints } from '../xyflow/useLayoutConstraints'
8
+ import { type Aligner, getLinearAligner, GridAligner, type GridAlignmentMode, type LinearAlignmentMode, type NodeRect } from './aligners'
9
+
10
+ export type AlignmentMode = LinearAlignmentMode | GridAlignmentMode
11
+
12
+ export function align(get: () => DiagramState) {
13
+ return (mode: AlignmentMode) => {
14
+ const { scheduleSaveManualLayout, xystore } = get()
15
+ const { nodeLookup, parentLookup } = xystore.getState()
16
+
17
+ const selectedNodes = new Set(nodeLookup.values().filter(n => n.selected).map(n => n.id))
18
+ const nodesToAlign = [...selectedNodes.difference(new Set(parentLookup.keys()))]
19
+
20
+ if (!hasAtLeast(nodesToAlign, 2)) {
21
+ console.warn('At least 2 nodes must be selected to align')
22
+ return
23
+ }
24
+ const constraints = createLayoutConstraints(xystore, nodesToAlign)
25
+
26
+ const aligner = getAligner(mode)
27
+
28
+ constraints.onMove(nodes => {
29
+ aligner.computeLayout(nodes.map(({ node }) => toNodeRect(node)))
30
+
31
+ nodes.forEach(({ rect, node }) => {
32
+ rect.positionAbsolute = {
33
+ ...rect.positionAbsolute,
34
+ ...aligner.applyPosition(toNodeRect(node))
35
+ }
36
+ })
37
+ })
38
+
39
+ scheduleSaveManualLayout()
40
+ }
41
+ }
42
+
43
+ function toNodeRect(node: InternalNode<XYFlowNode>): NodeRect {
44
+ return {
45
+ ...node.internals.positionAbsolute,
46
+ id: node.id,
47
+ width: getNodeDimensions(node).width,
48
+ height: getNodeDimensions(node).height
49
+ }
50
+ }
51
+
52
+ function getAligner(mode: AlignmentMode): Aligner {
53
+ switch (mode) {
54
+ case 'Left':
55
+ case 'Right':
56
+ case 'Top':
57
+ case 'Bottom':
58
+ case 'Center':
59
+ case 'Middle':
60
+ return getLinearAligner(mode)
61
+ case 'Column':
62
+ case 'Row':
63
+ return new GridAligner(mode)
64
+ default:
65
+ nonexhaustive(mode)
66
+ }
67
+ }
@@ -49,6 +49,7 @@ import type { XYFlowEdge, XYFlowInstance, XYFlowNode } from '../xyflow/types'
49
49
  import { createLayoutConstraints } from '../xyflow/useLayoutConstraints'
50
50
  import { bezierControlPoints, isInside, isSamePoint, toDomPrecision } from '../xyflow/utils'
51
51
  import { diagramViewToXYFlowData } from './diagram-to-xyflow'
52
+ import { align, type AlignmentMode } from './diagramStore.layout'
52
53
 
53
54
  type RequiredOrNull<T> = {
54
55
  [P in keyof T]-?: NonNullable<T[P]> | null
@@ -73,6 +74,7 @@ export type DiagramInitialState = {
73
74
  enableElementDetails: boolean
74
75
  enableRelationshipBrowser: boolean
75
76
  enableRelationshipDetails: boolean
77
+ enableSearch: boolean
76
78
  renderIcon: ElementIconRenderer | null
77
79
  whereFilter: WhereOperator<string, string> | null
78
80
  // If Dynamic View
@@ -85,8 +87,6 @@ export type DiagramInitialState = {
85
87
  getContainer: () => HTMLDivElement | null
86
88
  } & RequiredOrNull<LikeC4DiagramEventHandlers>
87
89
 
88
- type AlignmentMode = 'Left' | 'Center' | 'Right' | 'Top' | 'Middle' | 'Bottom'
89
-
90
90
  const StringSet = Set<string>
91
91
 
92
92
  export type DiagramState = Simplify<
@@ -134,7 +134,7 @@ export type DiagramState = Simplify<
134
134
  lastOnNavigate: null | {
135
135
  fromView: ViewID
136
136
  toView: ViewID
137
- fromNode: NodeId
137
+ fromNode: NodeId | null
138
138
  }
139
139
  navigationHistory: Array<{
140
140
  viewId: ViewID
@@ -1068,70 +1068,7 @@ export function createDiagramStore(props: DiagramInitialState) {
1068
1068
  return vector(v).mul(scale).add(nodeCenter)
1069
1069
  }
1070
1070
  },
1071
- align: (mode: AlignmentMode) => {
1072
- const { scheduleSaveManualLayout, xystore } = get()
1073
- const { nodeLookup, parentLookup } = xystore.getState()
1074
-
1075
- const selectedNodes = new Set(nodeLookup.values().filter(n => n.selected).map(n => n.id))
1076
- const nodesToAlign = [...selectedNodes.difference(new Set(parentLookup.keys()))]
1077
-
1078
- if (!hasAtLeast(nodesToAlign, 2)) {
1079
- console.warn('At least 2 nodes must be selected to align')
1080
- return
1081
- }
1082
- const constraints = createLayoutConstraints(xystore, nodesToAlign)
1083
-
1084
- let getEdgePosition: (nodes: InternalNode<XYFlowNode>[]) => number
1085
- let getPosition: (alignTo: number, node: InternalNode<XYFlowNode>) => number
1086
- let propertyToEdit: keyof XYPosition
1087
-
1088
- switch (mode) {
1089
- case 'Left':
1090
- getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.x))
1091
- propertyToEdit = 'x'
1092
- getPosition = (alignTo, _) => Math.floor(alignTo)
1093
- break
1094
- case 'Top':
1095
- getEdgePosition = nodes => Math.min(...nodes.map(n => n.internals.positionAbsolute.y))
1096
- propertyToEdit = 'y'
1097
- getPosition = (alignTo, _) => Math.floor(alignTo)
1098
- break
1099
- case 'Right':
1100
- getEdgePosition = nodes =>
1101
- Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width))
1102
- propertyToEdit = 'x'
1103
- getPosition = (alignTo, node) => Math.floor(alignTo - node.width!)
1104
- break
1105
- case 'Bottom':
1106
- getEdgePosition = nodes =>
1107
- Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height))
1108
- propertyToEdit = 'y'
1109
- getPosition = (alignTo, node) => Math.floor(alignTo - node.height!)
1110
- break
1111
- case 'Center':
1112
- getEdgePosition = nodes =>
1113
- Math.max(...nodes.map(n => n.internals.positionAbsolute.x + getNodeDimensions(n).width / 2))
1114
- propertyToEdit = 'x'
1115
- getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).width / 2)
1116
- break
1117
- case 'Middle':
1118
- getEdgePosition = nodes =>
1119
- Math.max(...nodes.map(n => n.internals.positionAbsolute.y + getNodeDimensions(n).height / 2))
1120
- propertyToEdit = 'y'
1121
- getPosition = (alignTo, node) => Math.floor(alignTo - getNodeDimensions(node).height / 2)
1122
- break
1123
- }
1124
-
1125
- constraints.onMove((nodes) => {
1126
- const alignTo = getEdgePosition(nodes.map(({ node }) => node))
1127
-
1128
- nodes.forEach(({ rect, node }) => {
1129
- rect.positionAbsolute = { ...rect.positionAbsolute, [propertyToEdit]: getPosition(alignTo, node) }
1130
- })
1131
- })
1132
-
1133
- scheduleSaveManualLayout()
1134
- }
1071
+ align: align(get)
1135
1072
  } satisfies DiagramState),
1136
1073
  {
1137
1074
  name: `${storeDevId} - ${props.view.id}`,
@@ -6,6 +6,7 @@ import {
6
6
  IconLayoutAlignMiddle,
7
7
  IconLayoutAlignRight,
8
8
  IconLayoutAlignTop,
9
+ IconLayoutBoardSplit,
9
10
  IconLayoutCollage,
10
11
  IconRouteOff
11
12
  } from '@tabler/icons-react'
@@ -53,6 +54,13 @@ export const ManualLayoutToolsButton = (props: PopoverProps) => {
53
54
  <PopoverDropdown p={0}>
54
55
  <Group gap={'xs'}>
55
56
  <ActionIconGroup pos={'relative'}>
57
+ <Action
58
+ label="Align in columns"
59
+ icon={<IconLayoutBoardSplit />}
60
+ onClick={e => {
61
+ e.stopPropagation()
62
+ store.getState().align('Column')
63
+ }} />
56
64
  <Action
57
65
  label="Align left"
58
66
  icon={<IconLayoutAlignLeft />}
@@ -74,6 +82,13 @@ export const ManualLayoutToolsButton = (props: PopoverProps) => {
74
82
  e.stopPropagation()
75
83
  store.getState().align('Right')
76
84
  }} />
85
+ <Action
86
+ label="Align in rows"
87
+ icon={<IconLayoutBoardSplit style={{ transform: 'rotate(90deg)' }} />}
88
+ onClick={e => {
89
+ e.stopPropagation()
90
+ store.getState().align('Row')
91
+ }} />
77
92
  <Action
78
93
  label="Align top"
79
94
  icon={<IconLayoutAlignTop />}
@@ -18,7 +18,8 @@ import {
18
18
  IconChevronRight,
19
19
  IconFileSymlink,
20
20
  IconFocusCentered,
21
- IconMenu2
21
+ IconMenu2,
22
+ IconSearch
22
23
  } from '@tabler/icons-react'
23
24
  import clsx from 'clsx'
24
25
  import { AnimatePresence, LayoutGroup, m } from 'framer-motion'
@@ -30,6 +31,7 @@ import { ActionIcon, Tooltip } from './_shared'
30
31
  import { ChangeAutoLayoutButton } from './ChangeAutoLayoutButton'
31
32
  import { ManualLayoutToolsButton } from './ManualLayoutToolsButton'
32
33
  import * as css from './styles.css'
34
+ import { openSpotlight } from '@mantine/spotlight'
33
35
 
34
36
  const historySelector = (s: DiagramState) => ({
35
37
  showBurgerMenu: !!s.onBurgerMenuClick,
@@ -138,6 +140,7 @@ const LayoutDriftNotification = (props: PopoverProps) => (
138
140
  export const TopLeftPanel = () => {
139
141
  const store = useDiagramStoreApi()
140
142
  const {
143
+ enableSearch,
141
144
  showNavigationButtons,
142
145
  showFitDiagram,
143
146
  showLayoutDriftWarning,
@@ -150,6 +153,7 @@ export const TopLeftPanel = () => {
150
153
  const isNotFocused = isNullish(s.focusedNodeId)
151
154
  const isNotActive = isNotWalkthrough && isNotFocused
152
155
  return ({
156
+ enableSearch: s.enableSearch,
153
157
  showNavigationButtons: !!s.onBurgerMenuClick || (s.showNavigationButtons && !!s.onNavigateTo),
154
158
  showFitDiagram: s.controls && s.fitViewEnabled && s.zoomable && isNotWalkthrough,
155
159
  showLayoutDriftWarning: s.controls && !s.readonly && s.view.hasLayoutDrift === true && isNotActive,
@@ -174,6 +178,15 @@ export const TopLeftPanel = () => {
174
178
  gap={'xs'}>
175
179
  {showNavigationButtons && <BackwardForwardButtons />}
176
180
  <ActionIconGroup className={css.actionIconGroup} orientation="vertical">
181
+ {enableSearch && (
182
+ <Tooltip label="Search">
183
+ <ActionIcon
184
+ onClick={() => openSpotlight()}
185
+ >
186
+ <IconSearch />
187
+ </ActionIcon>
188
+ </Tooltip>
189
+ )}
177
190
  {showGoToSource && (
178
191
  <Tooltip label="Open source" {...portalProps}>
179
192
  <ActionIcon
@@ -1,7 +1,7 @@
1
1
  import { type ThemeColor } from '@likec4/core'
2
- import { ActionIcon, Box, Text as MantineText, Tooltip } from '@mantine/core'
2
+ import { ActionIcon, Box, Group, Text as MantineText, Tooltip } from '@mantine/core'
3
3
  import { useDebouncedValue } from '@mantine/hooks'
4
- import { IconId, IconZoomScan } from '@tabler/icons-react'
4
+ import { IconId, IconTransform, IconZoomScan } from '@tabler/icons-react'
5
5
  import { Handle, type NodeProps, Position } from '@xyflow/react'
6
6
  import clsx from 'clsx'
7
7
  import { deepEqual as eq } from 'fast-equals'
@@ -54,21 +54,24 @@ const VariantsRoot = {
54
54
  }
55
55
  } satisfies Variants
56
56
 
57
- const VariantsNavigate = {
57
+ const VariantsNavigateBtn = {
58
58
  idle: {
59
59
  '--ai-bg': 'var(--ai-bg-idle)',
60
60
  scale: 1,
61
61
  opacity: 0.5,
62
- translateX: '-50%',
63
- originY: 0.2
62
+ originY: 0.5
64
63
  },
65
64
  selected: {},
66
65
  hovered: {
67
66
  '--ai-bg': 'var(--ai-bg-hover)',
68
- translateX: '-50%',
69
67
  scale: 1.35,
70
68
  opacity: 1
71
- },
69
+ }
70
+ } satisfies Variants
71
+ VariantsNavigateBtn['selected'] = VariantsNavigateBtn['hovered']
72
+
73
+ const VariantsNavigate = {
74
+ ...VariantsNavigateBtn,
72
75
  'hovered:navigate': {
73
76
  scale: 1.42
74
77
  },
@@ -76,7 +79,16 @@ const VariantsNavigate = {
76
79
  scale: 1.15
77
80
  }
78
81
  } satisfies Variants
79
- VariantsNavigate['selected'] = VariantsNavigate['hovered']
82
+
83
+ const VariantRealationships = {
84
+ ...VariantsNavigateBtn,
85
+ 'hovered:relationships': {
86
+ scale: 1.42
87
+ },
88
+ 'tap:relationships': {
89
+ scale: 1.15
90
+ }
91
+ } satisfies Variants
80
92
 
81
93
  const VariantsDetailsBtn = {
82
94
  idle: {
@@ -127,6 +139,7 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
127
139
  isNavigable,
128
140
  isInteractive,
129
141
  enableElementDetails,
142
+ enableRelationshipBrowser,
130
143
  triggerOnNavigateTo,
131
144
  openOverlay,
132
145
  isInActiveOverlay,
@@ -136,10 +149,11 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
136
149
  isEditable: s.readonly !== true,
137
150
  isHovered: s.hoveredNodeId === id,
138
151
  isDimmed: s.dimmed.has(id),
139
- isInteractive: s.nodesDraggable || s.nodesSelectable || s.enableElementDetails
152
+ isInteractive: s.nodesDraggable || s.nodesSelectable || s.enableElementDetails || s.enableRelationshipBrowser
140
153
  || (!!s.onNavigateTo && !!element.navigateTo),
141
154
  isNavigable: (!!s.onNavigateTo && !!element.navigateTo),
142
155
  enableElementDetails: s.enableElementDetails,
156
+ enableRelationshipBrowser: s.enableRelationshipBrowser,
143
157
  triggerOnNavigateTo: s.triggerOnNavigateTo,
144
158
  openOverlay: s.openOverlay,
145
159
  isInActiveOverlay: (s.activeOverlay?.elementDetails ?? s.activeOverlay?.relationshipsOf) === id,
@@ -195,6 +209,11 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
195
209
  openOverlay({ elementDetails: element.id })
196
210
  }, [openOverlay, element.id])
197
211
 
212
+ const onOpenRelationships = useCallback((e: React.MouseEvent) => {
213
+ e.stopPropagation()
214
+ openOverlay({ relationshipsOf: element.id })
215
+ }, [openOverlay, element.id])
216
+
198
217
  return (
199
218
  <>
200
219
  {isToolbarVisible && (
@@ -266,21 +285,40 @@ export const ElementNodeMemo = memo<ElementNodeProps>(function ElementNode({
266
285
  </Box>
267
286
  </Box>
268
287
  {/* {isHovercards && element.links && <ElementLink element={element} />} */}
269
- {isNavigable && (
270
- <ActionIcon
271
- component={m.div}
272
- variants={VariantsNavigate}
273
- data-animate-target="navigate"
274
- className={clsx('nodrag nopan', css.navigateBtn)}
275
- radius="md"
276
- style={{ zIndex: 100 }}
277
- role="button"
278
- onClick={onNavigateTo}
279
- onDoubleClick={stopPropagation}
280
- {...isInteractive && animateHandlers}
281
- >
282
- <IconZoomScan style={{ width: '75%' }} />
283
- </ActionIcon>
288
+
289
+ {(enableRelationshipBrowser || isNavigable) && (
290
+ <Group className={css.navigateBtnBox} align="center">
291
+ {enableRelationshipBrowser && (
292
+ <ActionIcon
293
+ component={m.div}
294
+ variants={VariantRealationships}
295
+ data-animate-target="relationships"
296
+ className={clsx('nodrag nopan', css.navigateBtn)}
297
+ radius="md"
298
+ role="button"
299
+ onClick={onOpenRelationships}
300
+ onDoubleClick={stopPropagation}
301
+ {...isInteractive && animateHandlers}
302
+ >
303
+ <IconTransform style={{ width: '75%' }} />
304
+ </ActionIcon>
305
+ )}
306
+ {isNavigable && (
307
+ <ActionIcon
308
+ component={m.div}
309
+ variants={VariantsNavigate}
310
+ data-animate-target="navigate"
311
+ className={clsx('nodrag nopan', css.navigateBtn)}
312
+ radius="md"
313
+ role="button"
314
+ onClick={onNavigateTo}
315
+ onDoubleClick={stopPropagation}
316
+ {...isInteractive && animateHandlers}
317
+ >
318
+ <IconZoomScan style={{ width: '75%' }} />
319
+ </ActionIcon>
320
+ )}
321
+ </Group>
284
322
  )}
285
323
  {enableElementDetails && (
286
324
  <Tooltip
@@ -354,16 +354,25 @@ const btn = style({
354
354
  }
355
355
  })
356
356
 
357
- export const navigateBtn = style([btn, {
357
+ export const navigateBtnBox = style({
358
+ zIndex: 100,
358
359
  position: 'absolute',
359
360
  left: '50%',
360
- bottom: 0,
361
+ bottom: 2,
362
+ transform: 'translate(-50%, 0%)',
363
+ gap: 0,
361
364
  selectors: {
362
365
  [`:where([data-likec4-shape='browser']) &`]: {
363
366
  bottom: 4
364
367
  }
365
368
  }
366
- }])
369
+ })
370
+ globalStyle(`:where(.react-flow__node:hover) ${navigateBtnBox}`, {
371
+ transitionDelay: '20ms',
372
+ gap: 16
373
+ })
374
+
375
+ export const navigateBtn = style([btn, {}])
367
376
 
368
377
  export const detailsBtn = style([btn, {
369
378
  position: 'absolute',