@bavuchoko/edge-node 0.0.0-stage → 0.1.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/LICENSE +21 -0
- package/README.md +437 -2
- package/dist/components/ConnectionLine.d.ts +9 -0
- package/dist/components/EdgeEditor.d.ts +16 -0
- package/dist/components/GraphCanvas.d.ts +42 -0
- package/dist/components/GraphEdge.d.ts +22 -0
- package/dist/components/GraphNode.d.ts +59 -0
- package/dist/dependencies.d.ts +32 -0
- package/dist/geometry.d.ts +26 -0
- package/dist/hooks/useColorMode.d.ts +21 -0
- package/dist/hooks/useConnectionDrag.d.ts +12 -0
- package/dist/hooks/useGraphPersistence.d.ts +42 -0
- package/dist/hooks/useViewport.d.ts +15 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +1639 -0
- package/dist/layout.d.ts +20 -0
- package/dist/store.d.ts +46 -0
- package/dist/style.css +628 -0
- package/dist/theme.d.ts +108 -0
- package/dist/types.d.ts +86 -0
- package/package.json +68 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bavuchoko
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,438 @@
|
|
|
1
|
-
#
|
|
1
|
+
# edge-node
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
외부 그래프 라이브러리 없이 만든 React **노드 연결 에디터**입니다.
|
|
4
|
+
HTML 카드 노드와 SVG 연결선으로 왼쪽 → 오른쪽 파이프라인 형태의 의존 관계(DAG)를 보여주고 편집합니다.
|
|
5
|
+
|
|
6
|
+

|
|
7
|
+
|
|
8
|
+
- 연결 방향(화살표), 선 모양(실선/점선), 라벨, **양방향 관계** 표시
|
|
9
|
+
- 노드에 마우스를 올리면 의존 사슬 전체를 강조하고 각 노드의 관계를 칩으로 표시
|
|
10
|
+
- 편집 모드(이동·연결·수정·삭제) / 보기 모드(세부정보 펼치기) 전환
|
|
11
|
+
- 변경 시 콜백(`onConnect` 등)으로 API 저장, 실패하면 자동 되돌리기
|
|
12
|
+
- 라이트/다크 모드, 테마, 카드 전체 커스텀(`renderNode`)
|
|
13
|
+
|
|
14
|
+
---
|
|
15
|
+
|
|
16
|
+
## 목차
|
|
17
|
+
|
|
18
|
+
1. [설치](#설치)
|
|
19
|
+
2. [빠른 시작](#빠른-시작)
|
|
20
|
+
3. [데이터 구조: 노드와 연결선](#데이터-구조-노드와-연결선)
|
|
21
|
+
4. [편집 모드와 보기 모드](#편집-모드와-보기-모드)
|
|
22
|
+
5. [연결 만들기: 선 모양 · 라벨 · 관계](#연결-만들기-선-모양--라벨--관계)
|
|
23
|
+
6. [호버로 관계 보기](#호버로-관계-보기)
|
|
24
|
+
7. [노드 세부정보](#노드-세부정보)
|
|
25
|
+
8. [변경 저장 (API 연동)](#변경-저장-api-연동)
|
|
26
|
+
9. [카드 직접 그리기 (`renderNode`)](#카드-직접-그리기-rendernode)
|
|
27
|
+
10. [라이트/다크 모드와 테마](#라이트다크-모드와-테마)
|
|
28
|
+
11. [API 요약](#api-요약)
|
|
29
|
+
12. [개발](#개발)
|
|
30
|
+
|
|
31
|
+
---
|
|
32
|
+
|
|
33
|
+
## 설치
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
npm install @bavuchoko/edge-node
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
React 18 이상이 필요합니다(`react`, `react-dom`은 peer dependency). 스타일 파일을 한 번 import 해야 카드 모양이 나옵니다.
|
|
40
|
+
|
|
41
|
+
```ts
|
|
42
|
+
import '@bavuchoko/edge-node/style.css'
|
|
43
|
+
```
|
|
44
|
+
|
|
45
|
+
## 빠른 시작
|
|
46
|
+
|
|
47
|
+
```tsx
|
|
48
|
+
import { createInitialState, GraphCanvas, layoutColumns } from '@bavuchoko/edge-node'
|
|
49
|
+
import '@bavuchoko/edge-node/style.css'
|
|
50
|
+
|
|
51
|
+
const nodes = layoutColumns([
|
|
52
|
+
{ id: 'a', column: 0, row: 0, data: { title: 'Build', status: 'success' } },
|
|
53
|
+
{ id: 'b', column: 1, row: 0, data: { title: 'Deploy', status: 'idle' } },
|
|
54
|
+
])
|
|
55
|
+
|
|
56
|
+
const initialState = createInitialState(nodes, [
|
|
57
|
+
{ source: 'a', target: 'b', kind: 'required', label: 'App', relation: 'run On', inverseRelation: 'hosts' },
|
|
58
|
+
])
|
|
59
|
+
|
|
60
|
+
export function Pipeline() {
|
|
61
|
+
return (
|
|
62
|
+
<div style={{ height: 600 }}>
|
|
63
|
+
<GraphCanvas initialState={initialState} editable colorMode="system" />
|
|
64
|
+
</div>
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
- 캔버스는 부모 크기를 꽉 채웁니다. **감싸는 요소에 높이**를 꼭 주세요.
|
|
70
|
+
- `initialState`는 처음 한 번만 읽습니다. 이후 변경은 콜백으로 받습니다([변경 저장](#변경-저장-api-연동)).
|
|
71
|
+
|
|
72
|
+
## 데이터 구조: 노드와 연결선
|
|
73
|
+
|
|
74
|
+
노드와 연결선은 **따로** 넘깁니다. 노드 데이터 안에는 연결 정보가 없고,
|
|
75
|
+
연결선 목록이 `createInitialState(nodes, edges)`의 두 번째 인자입니다.
|
|
76
|
+
|
|
77
|
+
### 노드
|
|
78
|
+
|
|
79
|
+
```ts
|
|
80
|
+
layoutColumns([
|
|
81
|
+
{
|
|
82
|
+
id: 'betaflight',
|
|
83
|
+
column: 0, // 몇 번째 열 (왼쪽부터)
|
|
84
|
+
row: 0, // 열 안에서 몇 번째 (위부터)
|
|
85
|
+
// position: { x: 120, y: 80 }, // 저장된 위치가 있으면 column/row 대신 사용
|
|
86
|
+
// width: 300, // 기본 300
|
|
87
|
+
data: {
|
|
88
|
+
title: 'Betaflight Configurator', // 필수. 굵은 제목
|
|
89
|
+
status: 'success', // 필수. 'success' | 'error' | 'idle' → 왼쪽 위 아이콘
|
|
90
|
+
stats: '#12: 121 statements, 473 branches uncovered', // 아이콘 아래 한 줄 (status 색)
|
|
91
|
+
subtitle: '... / TeamCity Plugins', // 제목 아래 회색 글씨
|
|
92
|
+
badge: 'master', // 파란 칩
|
|
93
|
+
details: [ // 보기 모드에서 펼치면 보이는 목록
|
|
94
|
+
{ label: 'Build', value: '#12 · 4m 12s' },
|
|
95
|
+
{ label: 'Commit', value: 'a3f9c21 — Fix serial port reconnect' },
|
|
96
|
+
],
|
|
97
|
+
},
|
|
98
|
+
},
|
|
99
|
+
])
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
| 필드 | 카드에서의 위치 |
|
|
103
|
+
|------|----------------|
|
|
104
|
+
| `status` | 왼쪽 위 아이콘 (초록 체크 / 빨간 느낌표 / 회색 원) |
|
|
105
|
+
| `stats` | 아이콘 아래 고정폭 글씨, 색은 `status`를 따름 |
|
|
106
|
+
| `title` | 굵은 제목 |
|
|
107
|
+
| `subtitle` | 제목 아래 회색 글씨 |
|
|
108
|
+
| `badge` | 파란 칩 |
|
|
109
|
+
| `details` | 펼쳤을 때 아래쪽 라벨/값 목록. 없으면 펼치기 버튼이 안 보임 |
|
|
110
|
+
| (오른쪽 위) | 호버 시 관계 칩, 보기 모드의 펼치기 버튼 ⌄ |
|
|
111
|
+
|
|
112
|
+
빈 필드는 화면에서 자리 자체가 사라집니다. 카드 높이는 내용에 맞게 자동으로 정해집니다.
|
|
113
|
+
|
|
114
|
+
### 연결선
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
type GraphEdgeInput = {
|
|
118
|
+
source: string // 출발 노드 id (카드 오른쪽 핸들)
|
|
119
|
+
target: string // 도착 노드 id (카드 왼쪽 핸들). target이 source에 의존
|
|
120
|
+
kind?: 'required' | 'optional' // 선 모양: 실선(기본) / 점선
|
|
121
|
+
label?: string // 선 가운데 글자
|
|
122
|
+
relation?: string // target의 관계: "target <relation> source"
|
|
123
|
+
inverseRelation?: string // source의 관계: "source <inverseRelation> target"
|
|
124
|
+
}
|
|
125
|
+
```
|
|
126
|
+
|
|
127
|
+
연결선 `id`는 자동으로 `` `${source}->${target}` ``가 됩니다. 같은 방향의 중복 연결과
|
|
128
|
+
**순환(A → B → A)은 만들 수 없습니다**(필요하면 `allowCycles`).
|
|
129
|
+
|
|
130
|
+
## 편집 모드와 보기 모드
|
|
131
|
+
|
|
132
|
+
```tsx
|
|
133
|
+
<GraphCanvas initialState={initialState} editable /> // 편집 모드
|
|
134
|
+
<GraphCanvas initialState={initialState} editable={false} /> // 보기 모드 (기본값)
|
|
135
|
+
```
|
|
136
|
+
|
|
137
|
+
| 동작 | 편집 모드 (`editable`) | 보기 모드 (기본값) |
|
|
138
|
+
|------|-----------------------|-------------------|
|
|
139
|
+
| 노드 클릭 | 선택 | 세부정보 펼치기 / 접기 |
|
|
140
|
+
| 노드 드래그 | 위치 이동 | — |
|
|
141
|
+
| 연결 만들기 | 오른쪽 핸들을 끌어 다른 노드의 왼쪽 핸들에 놓기 | — |
|
|
142
|
+
| 연결선 클릭 | 편집 창 열기 (선 모양·라벨·관계·삭제) | — |
|
|
143
|
+
| 삭제 | 선택 후 `Delete` / `Backspace`, 또는 편집 창 휴지통 | — |
|
|
144
|
+
| 선택 해제 | 빈 곳 클릭 또는 `Esc` | — |
|
|
145
|
+
| 마우스 올리기 | 의존 사슬 강조 + 관계 칩 | 같음 |
|
|
146
|
+
| 화면 이동 / 확대 | 빈 곳 드래그 / 스크롤 | 같음 |
|
|
147
|
+
|
|
148
|
+
- 편집 → 보기로 바꾸면 열려 있던 연결선 편집 창이 닫히면서 그때까지의 변경이 확정됩니다.
|
|
149
|
+
- 보기 → 편집으로 바꾸면 펼쳐져 있던 세부정보가 접힙니다.
|
|
150
|
+
|
|
151
|
+
## 연결 만들기: 선 모양 · 라벨 · 관계
|
|
152
|
+
|
|
153
|
+
편집 모드에서 a의 오른쪽 핸들을 b의 왼쪽 핸들로 끌어 놓으면 편집 창이 뜹니다.
|
|
154
|
+
선 모양을 고르고, 라벨과 **양쪽 관계**를 입력한 뒤 `Enter`(또는 `Esc`, 다른 곳 클릭)로 닫으면 저장됩니다.
|
|
155
|
+
|
|
156
|
+

|
|
157
|
+
|
|
158
|
+
| 입력 | 저장되는 필드 | 화면 |
|
|
159
|
+
|------|--------------|------|
|
|
160
|
+
| Required / Optional | `kind` | 실선 / 점선 |
|
|
161
|
+
| Label | `label` | 선 가운데 글자 (위 그림의 `App`) |
|
|
162
|
+
| Relation 왼쪽 칸 (출발 노드 이름 아래) | `inverseRelation` | b에 호버하면 **a 카드**에 표시 |
|
|
163
|
+
| Relation 오른쪽 칸 (도착 노드 이름 아래) | `relation` | a에 호버하면 **b 카드**에 표시 |
|
|
164
|
+
|
|
165
|
+
관계 칸은 그래프와 같은 방향으로 놓입니다. 왼쪽이 출발 노드, 오른쪽이 도착 노드이고,
|
|
166
|
+
각 칸 위에 적힌 노드의 카드에 그 관계가 표시됩니다. 위 그림을 데이터로 쓰면 다음과 같습니다.
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
{ source: 'absa-root', target: 'publish-docs', kind: 'required', label: 'App', inverseRelation: 'hosts', relation: 'run On' }
|
|
170
|
+
```
|
|
171
|
+
|
|
172
|
+
이미 있는 선은 클릭하면 같은 편집 창으로 고칠 수 있습니다.
|
|
173
|
+
|
|
174
|
+
## 호버로 관계 보기
|
|
175
|
+
|
|
176
|
+
노드에 마우스를 올리면 그 노드와 이어진 **의존 사슬 전체**가 강조되고 나머지는 흐려집니다.
|
|
177
|
+
|
|
178
|
+
- 보라색: 호버한 노드가 의존하는 쪽(왼쪽, upstream)
|
|
179
|
+
- 주황색: 호버한 노드에 의존하는 쪽(오른쪽, downstream)
|
|
180
|
+
- 왼쪽 아래 범례에 양쪽 개수가 표시됩니다.
|
|
181
|
+
|
|
182
|
+
각 카드 오른쪽 위 칩에는 **그 노드가 사슬에서 바로 앞 노드에 대해 갖는 관계**가 나옵니다.
|
|
183
|
+
`a → b → c`에서 a에 호버하면 b에는 a와의 관계, c에는 b와의 관계가 나옵니다.
|
|
184
|
+
|
|
185
|
+

|
|
186
|
+
|
|
187
|
+
위 그림은 Betaflight(a)에 호버한 모습입니다.
|
|
188
|
+
|
|
189
|
+
- Auto Release(b): `Builds from` (Betaflight와의 관계)
|
|
190
|
+
- Publish NPM / Publish Docs(c): `Runs on` (Auto Release와의 관계)
|
|
191
|
+
|
|
192
|
+
여러 경로로 이어져 관계가 서로 다르면 쉼표로 이어서 표시합니다(같은 관계는 한 번만).
|
|
193
|
+
관계를 입력하지 않은 연결은 칩 없이 색만 바뀝니다.
|
|
194
|
+
|
|
195
|
+
가운데 노드에 호버하면 양쪽이 함께 보입니다. 왼쪽 노드에는 `inverseRelation`, 오른쪽 노드에는 `relation`이 표시됩니다.
|
|
196
|
+
|
|
197
|
+

|
|
198
|
+
|
|
199
|
+
## 노드 세부정보
|
|
200
|
+
|
|
201
|
+
보기 모드에서 노드의 내용 부분을 클릭하거나 오른쪽 위 ⌄ 버튼을 누르면 `data.details`가 펼쳐지고, 다시 누르면 접힙니다.
|
|
202
|
+
드래그는 클릭으로 치지 않습니다.
|
|
203
|
+
|
|
204
|
+
<img src="docs/images/node-details.png" alt="세부정보 펼치기" width="333" />
|
|
205
|
+
|
|
206
|
+
API에서 조회해야 하면 `renderNodeDetails`로 직접 그립니다. 펼칠 때만 렌더링되므로,
|
|
207
|
+
반환한 컴포넌트가 마운트될 때 데이터를 불러오면 됩니다. 이 prop을 주면 모든 노드를 펼칠 수 있습니다.
|
|
208
|
+
|
|
209
|
+
```tsx
|
|
210
|
+
function NodeDetails({ id }: { id: string }) {
|
|
211
|
+
const [detail, setDetail] = useState<Record<string, string> | null>(null)
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
fetch(`/api/nodes/${id}`).then((r) => r.json()).then(setDetail)
|
|
214
|
+
}, [id])
|
|
215
|
+
if (!detail) return <span>Loading…</span>
|
|
216
|
+
return <pre>{JSON.stringify(detail, null, 2)}</pre>
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
<GraphCanvas initialState={initialState} renderNodeDetails={(node) => <NodeDetails id={node.id} />} />
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
## 변경 저장 (API 연동)
|
|
223
|
+
|
|
224
|
+
화면에서 바뀐 내용은 콜백으로 받아 저장합니다. 변경은 화면에 먼저 반영되고,
|
|
225
|
+
콜백이 `false`를 반환하거나 Promise가 reject되면 **자동으로 되돌립니다**
|
|
226
|
+
(연결 → 제거, 수정 → 이전 값, 삭제 → 복원, 이동 → 원래 위치). 처리 중인 연결선은 반투명으로 보입니다.
|
|
227
|
+
|
|
228
|
+
| 콜백 | 호출 시점 |
|
|
229
|
+
|------|-----------|
|
|
230
|
+
| `onConnect(edge)` | 새 연결의 편집 창이 닫힐 때 한 번. 입력한 선 모양·라벨·관계가 담겨 있음. 닫기 전에 지우면 호출 안 됨 |
|
|
231
|
+
| `onEdgeUpdate(edge, previous)` | 기존 연결의 편집 창이 닫힐 때, 실제로 바뀐 게 있을 때만 한 번 |
|
|
232
|
+
| `onEdgeDelete(edge)` | 확정된 연결이 지워질 때. 노드를 지우면 붙어 있던 연결마다 한 번씩 |
|
|
233
|
+
| `onNodeMove(node, previous)` | 노드를 드래그해서 놓을 때 한 번(드래그 중에는 안 불림). `node.position`이 새 위치 |
|
|
234
|
+
|
|
235
|
+
```tsx
|
|
236
|
+
const json = (method: string, body?: unknown) => ({
|
|
237
|
+
method,
|
|
238
|
+
headers: { 'Content-Type': 'application/json' },
|
|
239
|
+
body: body === undefined ? undefined : JSON.stringify(body),
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
<GraphCanvas
|
|
243
|
+
initialState={initialState}
|
|
244
|
+
editable
|
|
245
|
+
onConnect={async (edge) => (await fetch('/api/edges', json('POST', edge))).ok}
|
|
246
|
+
onEdgeUpdate={async (edge) =>
|
|
247
|
+
(await fetch(`/api/edges/${encodeURIComponent(edge.id)}`, json('PATCH', edge))).ok}
|
|
248
|
+
onEdgeDelete={async (edge) =>
|
|
249
|
+
(await fetch(`/api/edges/${encodeURIComponent(edge.id)}`, json('DELETE'))).ok}
|
|
250
|
+
onNodeMove={async (node) =>
|
|
251
|
+
(await fetch(`/api/nodes/${node.id}`, json('PATCH', { position: node.position }))).ok}
|
|
252
|
+
/>
|
|
253
|
+
```
|
|
254
|
+
|
|
255
|
+
### 저장한 위치로 불러오기
|
|
256
|
+
|
|
257
|
+
`layoutColumns`에 `position`을 넘기면 열/행 자동 배치 대신 그 위치를 씁니다. 저장된 위치가 없는 노드만 자동 배치됩니다.
|
|
258
|
+
|
|
259
|
+
```ts
|
|
260
|
+
const saved = await fetch('/api/nodes').then((r) => r.json()) // [{ id, position: { x, y } }, ...]
|
|
261
|
+
const positions = new Map(saved.map((n) => [n.id, n.position]))
|
|
262
|
+
|
|
263
|
+
const nodes = layoutColumns(inputs.map((input) => ({ ...input, position: positions.get(input.id) })))
|
|
264
|
+
```
|
|
265
|
+
|
|
266
|
+
## 카드 직접 그리기 (`renderNode`)
|
|
267
|
+
|
|
268
|
+
`renderNode`를 주면 카드 안쪽을 전부 직접 그릴 수 있고, 노드 데이터 모양도 자유롭게 정할 수 있습니다.
|
|
269
|
+
라이브러리는 카드 테두리(위치, 배경, 선택·호버·흐림 상태), 연결 핸들, 드래그, 클릭으로 펼치기만 맡습니다.
|
|
270
|
+
아래 그림의 오른쪽 Slack 카드가 `renderNode`로 그린 카드입니다.
|
|
271
|
+
|
|
272
|
+

|
|
273
|
+
|
|
274
|
+
```tsx
|
|
275
|
+
import { createInitialState, GraphCanvas, layoutColumns, type RenderNode } from '@bavuchoko/edge-node'
|
|
276
|
+
|
|
277
|
+
type ServiceData = { name: string; owner: string; logs: string[] }
|
|
278
|
+
|
|
279
|
+
const nodes = layoutColumns<ServiceData>([
|
|
280
|
+
{ id: 'api', column: 0, row: 0, data: { name: 'API', owner: 'core', logs: ['deployed v2'] } },
|
|
281
|
+
])
|
|
282
|
+
|
|
283
|
+
const renderNode: RenderNode<ServiceData> = (node, ctx) => (
|
|
284
|
+
<div style={{ padding: 16 }}>
|
|
285
|
+
<strong>{node.data.name}</strong> · {node.data.owner}
|
|
286
|
+
{ctx.relation ? <span className="chip">{ctx.relation}</span> : null}
|
|
287
|
+
{ctx.expanded ? <ul>{node.data.logs.map((l) => <li key={l}>{l}</li>)}</ul> : null}
|
|
288
|
+
{!ctx.editable ? (
|
|
289
|
+
<button onClick={ctx.toggleExpanded}>{ctx.expanded ? '접기' : '로그 보기'}</button>
|
|
290
|
+
) : null}
|
|
291
|
+
</div>
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
<GraphCanvas initialState={createInitialState(nodes, [])} renderNode={renderNode} />
|
|
295
|
+
```
|
|
296
|
+
|
|
297
|
+
`ctx`(`NodeRenderContext`)로 넘어오는 값:
|
|
298
|
+
|
|
299
|
+
| 값 | 의미 |
|
|
300
|
+
|----|------|
|
|
301
|
+
| `editable` | 편집 모드 여부 |
|
|
302
|
+
| `selected` | 선택됨 (편집 모드) |
|
|
303
|
+
| `role` | 호버 중인 노드 기준 위치: `'focus'` / `'upstream'` / `'downstream'` / `undefined` |
|
|
304
|
+
| `relation` | 호버 시 이 카드에 표시할 관계 문구 ([호버로 관계 보기](#호버로-관계-보기)와 같은 규칙) |
|
|
305
|
+
| `dimmed` | 강조 범위 밖이라 흐리게 표시되는 중 |
|
|
306
|
+
| `expanded` / `toggleExpanded()` | 펼침 상태와 전환 함수 (보기 모드) |
|
|
307
|
+
|
|
308
|
+
- 노드마다 다르게 그리려면 기본 카드를 쓸 노드에서 `undefined`를 반환하세요.
|
|
309
|
+
- 커스텀 카드는 안쪽 여백이 0이라 여백을 직접 줘야 합니다.
|
|
310
|
+
- 카드 안의 `button`, `a`, `input` 등이나 `data-no-toggle` 속성이 있는 요소를 클릭해도 펼치기가 바뀌지 않습니다.
|
|
311
|
+
- 데이터가 기본 `NodeData` 모양이 아니면 `layoutColumns<MyData>(...)`처럼 타입을 지정하세요.
|
|
312
|
+
|
|
313
|
+
## 라이트/다크 모드와 테마
|
|
314
|
+
|
|
315
|
+

|
|
316
|
+
|
|
317
|
+
```tsx
|
|
318
|
+
<GraphCanvas initialState={initialState} colorMode="dark" /> // 'dark'(기본) | 'light' | 'system'
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
사용자가 고른 모드를 기억하려면 `useColorMode`를 씁니다.
|
|
322
|
+
|
|
323
|
+
```tsx
|
|
324
|
+
const { resolvedMode, toggle } = useColorMode({ storageKey: 'my-app:theme' }) // 기본은 OS 설정
|
|
325
|
+
<button onClick={toggle}>{resolvedMode === 'dark' ? 'Light' : 'Dark'}</button>
|
|
326
|
+
<GraphCanvas initialState={initialState} colorMode={resolvedMode} />
|
|
327
|
+
```
|
|
328
|
+
|
|
329
|
+
색을 바꾸려면 `theme`에 바꿀 값만 넘기면 됩니다. 나머지는 `colorMode` 프리셋 값을 유지합니다.
|
|
330
|
+
|
|
331
|
+
```tsx
|
|
332
|
+
<GraphCanvas
|
|
333
|
+
initialState={initialState}
|
|
334
|
+
theme={{
|
|
335
|
+
background: '#0b1220',
|
|
336
|
+
gridColor: 'transparent',
|
|
337
|
+
node: { background: '#111827', border: '#374151', radius: 10 },
|
|
338
|
+
edge: { color: '#64748b', selectedColor: '#38bdf8', arrowSize: 10 },
|
|
339
|
+
dependency: { upstreamColor: '#a78bfa', downstreamColor: '#f59e0b', dimOpacity: 0.2 },
|
|
340
|
+
}}
|
|
341
|
+
/>
|
|
342
|
+
```
|
|
343
|
+
|
|
344
|
+
<details>
|
|
345
|
+
<summary><code>GraphTheme</code> 전체 필드</summary>
|
|
346
|
+
|
|
347
|
+
```ts
|
|
348
|
+
type GraphTheme = {
|
|
349
|
+
background?: string
|
|
350
|
+
gridColor?: string // 'transparent'면 점 그리드 숨김
|
|
351
|
+
gridSize?: number
|
|
352
|
+
node?: {
|
|
353
|
+
background?: string; border?: string; borderHover?: string; borderSelected?: string
|
|
354
|
+
title?: string; subtitle?: string; shadow?: string; radius?: number
|
|
355
|
+
handle?: string; handleActive?: string; badgeBackground?: string; badgeText?: string
|
|
356
|
+
statusSuccess?: string; statusError?: string; statusIdle?: string
|
|
357
|
+
}
|
|
358
|
+
edge?: {
|
|
359
|
+
color?: string; hoverColor?: string; selectedColor?: string
|
|
360
|
+
width?: number; hoverWidth?: number; selectedWidth?: number
|
|
361
|
+
hitWidth?: number // 클릭 인식 폭 (화면 px)
|
|
362
|
+
draftColor?: string; draftWidth?: number; draftDash?: string // 연결 드래그 중 임시선
|
|
363
|
+
arrowSize?: number // 화살표 길이 (화면 px), 0이면 숨김
|
|
364
|
+
optionalDash?: string // 점선(optional) 패턴
|
|
365
|
+
labelColor?: string
|
|
366
|
+
}
|
|
367
|
+
hud?: { color?: string } // 왼쪽 아래 안내 문구
|
|
368
|
+
dependency?: {
|
|
369
|
+
upstreamColor?: string // 의존하는 쪽 강조색
|
|
370
|
+
downstreamColor?: string // 의존받는 쪽 강조색
|
|
371
|
+
dimOpacity?: number // 강조 범위 밖 투명도
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
```
|
|
375
|
+
|
|
376
|
+
</details>
|
|
377
|
+
|
|
378
|
+
## API 요약
|
|
379
|
+
|
|
380
|
+
### `<GraphCanvas>` props
|
|
381
|
+
|
|
382
|
+
| prop | 타입 | 설명 |
|
|
383
|
+
|------|------|------|
|
|
384
|
+
| `initialState` | `GraphState` | `createInitialState(nodes, edges)` 결과. 필수 |
|
|
385
|
+
| `editable` | `boolean` | 편집 모드. 기본 `false`(보기 모드) |
|
|
386
|
+
| `colorMode` | `'dark' \| 'light' \| 'system'` | 색 프리셋. 기본 `'dark'` |
|
|
387
|
+
| `theme` | `GraphTheme` | 프리셋 위에 덮어쓸 색·크기 |
|
|
388
|
+
| `allowCycles` | `boolean` | 순환 연결 허용. 기본 `false` |
|
|
389
|
+
| `onConnect` / `onEdgeUpdate` / `onEdgeDelete` / `onNodeMove` | 콜백 | [변경 저장](#변경-저장-api-연동) |
|
|
390
|
+
| `renderNodeDetails` | `(node) => ReactNode` | 펼친 영역 내용 |
|
|
391
|
+
| `renderNode` | `(node, ctx) => ReactNode` | 카드 안쪽 전체 |
|
|
392
|
+
|
|
393
|
+
### 함수 · 훅
|
|
394
|
+
|
|
395
|
+
| 이름 | 설명 |
|
|
396
|
+
|------|------|
|
|
397
|
+
| `createInitialState(nodes, edges, viewport?)` | 초기 상태 생성 |
|
|
398
|
+
| `layoutColumns(inputs)` | 열/행으로 노드 위치 자동 배치 (`position`이 있으면 그 위치 사용) |
|
|
399
|
+
| `useColorMode(options?)` | `{ mode, resolvedMode, setMode, toggle }`, `storageKey`를 주면 저장 |
|
|
400
|
+
| `useSystemColorMode()` | OS 라이트/다크 설정 |
|
|
401
|
+
| `getUpstream` / `getDownstream(edges, id)` | 의존하는 / 의존받는 노드 id 집합 (간접 포함) |
|
|
402
|
+
| `wouldCreateCycle(edges, source, target)` | 연결 시 순환 여부 |
|
|
403
|
+
| `darkGraphTheme` / `lightGraphTheme` / `resolveGraphTheme` | 테마 프리셋과 병합 함수 |
|
|
404
|
+
|
|
405
|
+
## 개발
|
|
406
|
+
|
|
407
|
+
```bash
|
|
408
|
+
npm install
|
|
409
|
+
npm run dev
|
|
410
|
+
```
|
|
411
|
+
|
|
412
|
+
| 명령 | 설명 |
|
|
413
|
+
|------|------|
|
|
414
|
+
| `npm run dev` | 데모 개발 서버 |
|
|
415
|
+
| `npm run build` | 데모 빌드 (`dist-demo/`) |
|
|
416
|
+
| `npm run build:lib` | 라이브러리 빌드 (`dist/`: JS, 타입, `style.css`) |
|
|
417
|
+
| `npm run lint` | Oxlint |
|
|
418
|
+
| `npm run preview` | 데모 빌드 미리보기 |
|
|
419
|
+
|
|
420
|
+
```
|
|
421
|
+
src/
|
|
422
|
+
graph/ ← 라이브러리 (npm 배포 대상)
|
|
423
|
+
components/ GraphCanvas, GraphNode, GraphEdge, EdgeEditor, ConnectionLine
|
|
424
|
+
hooks/ useViewport, useConnectionDrag, useColorMode, useGraphPersistence
|
|
425
|
+
dependencies.ts 의존 사슬 계산, 순환 검사, 관계 칩
|
|
426
|
+
types.ts / store.ts 데이터 모델, reducer
|
|
427
|
+
geometry.ts 핸들 좌표, 베지어 곡선, 화살표
|
|
428
|
+
layout.ts 열 배치
|
|
429
|
+
theme.ts 테마 프리셋 · CSS 변수
|
|
430
|
+
graph.css 스타일
|
|
431
|
+
App.tsx, demoGraph.ts 데모 앱과 샘플 데이터
|
|
432
|
+
IntegrationCard.tsx renderNode 데모 카드
|
|
433
|
+
docs/images/ README 스크린샷
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### 아직 없는 것
|
|
437
|
+
|
|
438
|
+
미니맵, undo/redo, 그리드 스냅, 다중 선택, 위·아래 방향 연결, 노드 추가/삭제 콜백, 화면 안 문구 변경(현재 영어 고정)
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Point } from '../types';
|
|
2
|
+
type ConnectionLineProps = {
|
|
3
|
+
source: Point;
|
|
4
|
+
target: Point;
|
|
5
|
+
/** Arrowhead length in world units. */
|
|
6
|
+
arrowLength: number;
|
|
7
|
+
};
|
|
8
|
+
export declare function ConnectionLine({ source, target, arrowLength }: ConnectionLineProps): import("react").JSX.Element;
|
|
9
|
+
export {};
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { EdgePatch, GraphEdge, Point } from '../types';
|
|
2
|
+
type EdgeEditorProps = {
|
|
3
|
+
edge: GraphEdge;
|
|
4
|
+
/** Display names of the connected nodes, used to caption the relation fields. */
|
|
5
|
+
sourceName: string;
|
|
6
|
+
targetName: string;
|
|
7
|
+
/** Anchor in canvas-relative screen px (edge midpoint). */
|
|
8
|
+
position: Point;
|
|
9
|
+
/** Focus the label field on mount — used right after the edge is drawn. */
|
|
10
|
+
autoFocus?: boolean;
|
|
11
|
+
onChange: (patch: EdgePatch) => void;
|
|
12
|
+
onDelete: () => void;
|
|
13
|
+
onClose: () => void;
|
|
14
|
+
};
|
|
15
|
+
export declare function EdgeEditor({ edge, sourceName, targetName, position, autoFocus, onChange, onDelete, onClose, }: EdgeEditorProps): import("react").JSX.Element;
|
|
16
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import { type ColorModePreference } from '../hooks/useColorMode';
|
|
3
|
+
import { type GraphPersistenceCallbacks } from '../hooks/useGraphPersistence';
|
|
4
|
+
import { type GraphTheme } from '../theme';
|
|
5
|
+
import type { GraphNode as GraphNodeModel, GraphState, NodeData } from '../types';
|
|
6
|
+
import { type RenderNode } from './GraphNode';
|
|
7
|
+
/**
|
|
8
|
+
* Change callbacks (`onConnect`, `onEdgeUpdate`, `onEdgeDelete`, `onNodeMove`)
|
|
9
|
+
* apply the change locally first; return `false` or reject to roll it back.
|
|
10
|
+
* While an edge callback's promise is pending the edge is drawn faded.
|
|
11
|
+
*/
|
|
12
|
+
export type GraphCanvasProps<TData = NodeData> = GraphPersistenceCallbacks<TData> & {
|
|
13
|
+
initialState: GraphState<TData>;
|
|
14
|
+
/**
|
|
15
|
+
* `true`: edit mode — move nodes; add, edit and delete connections.
|
|
16
|
+
* `false` (default): view mode — click a node to expand/collapse its details.
|
|
17
|
+
* Pan, zoom and hover highlighting work in both.
|
|
18
|
+
*/
|
|
19
|
+
editable?: boolean;
|
|
20
|
+
/** Overrides applied on top of the `colorMode` preset. */
|
|
21
|
+
theme?: GraphTheme;
|
|
22
|
+
/** Base color preset. `'system'` follows `prefers-color-scheme`. Defaults to `'dark'`. */
|
|
23
|
+
colorMode?: ColorModePreference;
|
|
24
|
+
/**
|
|
25
|
+
* Allow connections that close a dependency cycle (A → B → A).
|
|
26
|
+
* Defaults to `false`: such connections are rejected.
|
|
27
|
+
*/
|
|
28
|
+
allowCycles?: boolean;
|
|
29
|
+
/**
|
|
30
|
+
* Body of a node's expanded panel (opened by clicking the node in view mode). Defaults to
|
|
31
|
+
* `node.data.details` as label/value rows. Rendered only while expanded, so a
|
|
32
|
+
* component returned here can fetch its data on mount.
|
|
33
|
+
*/
|
|
34
|
+
renderNodeDetails?: (node: GraphNodeModel<TData>) => ReactNode;
|
|
35
|
+
/**
|
|
36
|
+
* Draws the inside of every node card (the frame, connection handles,
|
|
37
|
+
* dragging and click-to-expand stay built in). Return `undefined` to use the
|
|
38
|
+
* built-in card for a node. Required in practice when `TData` isn't `NodeData`.
|
|
39
|
+
*/
|
|
40
|
+
renderNode?: RenderNode<TData>;
|
|
41
|
+
};
|
|
42
|
+
export declare function GraphCanvas<TData = NodeData>({ initialState, editable, theme, colorMode, allowCycles, onConnect, onEdgeUpdate, onEdgeDelete, onNodeMove, renderNodeDetails, renderNode, }: GraphCanvasProps<TData>): import("react").JSX.Element;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { EdgeKind, Point } from '../types';
|
|
2
|
+
type GraphEdgeProps = {
|
|
3
|
+
id: string;
|
|
4
|
+
source: Point;
|
|
5
|
+
target: Point;
|
|
6
|
+
kind?: EdgeKind;
|
|
7
|
+
label?: string;
|
|
8
|
+
selected: boolean;
|
|
9
|
+
/** When false, the edge can't be selected (and so can't be edited or deleted). */
|
|
10
|
+
editable?: boolean;
|
|
11
|
+
/** Set when the edge lies on the hovered node's dependency chain. */
|
|
12
|
+
role?: 'upstream' | 'downstream';
|
|
13
|
+
dimmed?: boolean;
|
|
14
|
+
/** Waiting on `onConnect` to confirm. */
|
|
15
|
+
pending?: boolean;
|
|
16
|
+
zoom: number;
|
|
17
|
+
hitWidthPx?: number;
|
|
18
|
+
arrowSizePx?: number;
|
|
19
|
+
onSelect: (id: string) => void;
|
|
20
|
+
};
|
|
21
|
+
export declare function GraphEdge({ id, source, target, kind, label, selected, editable, role, dimmed, pending, zoom, hitWidthPx, arrowSizePx, onSelect, }: GraphEdgeProps): import("react").JSX.Element;
|
|
22
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { type ReactNode } from 'react';
|
|
2
|
+
import type { DependencyRole } from '../dependencies';
|
|
3
|
+
import type { GraphNode as GraphNodeModel } from '../types';
|
|
4
|
+
/** State handed to `renderNode` so a custom card can reflect it. */
|
|
5
|
+
export type NodeRenderContext = {
|
|
6
|
+
/** Edit mode (`true`) or view mode (`false`). */
|
|
7
|
+
editable: boolean;
|
|
8
|
+
/** Selected in edit mode. */
|
|
9
|
+
selected: boolean;
|
|
10
|
+
/** Position on the hovered node's dependency chain, if one is being shown. */
|
|
11
|
+
role?: DependencyRole;
|
|
12
|
+
/**
|
|
13
|
+
* This node's relation to the previous node on the hovered node's chain
|
|
14
|
+
* (for a direct neighbor, the hovered node itself). Comes from the edge's
|
|
15
|
+
* `relation` (downstream side) / `inverseRelation` (upstream side).
|
|
16
|
+
*/
|
|
17
|
+
relation?: string;
|
|
18
|
+
/** Outside the chain currently highlighted (or an invalid target while connecting). */
|
|
19
|
+
dimmed: boolean;
|
|
20
|
+
/** Details panel open (view mode). */
|
|
21
|
+
expanded: boolean;
|
|
22
|
+
toggleExpanded: () => void;
|
|
23
|
+
};
|
|
24
|
+
/**
|
|
25
|
+
* Draws the inside of a node card. Return `undefined` to use the built-in card
|
|
26
|
+
* for that node. Interactive elements you render (button, a, input, …) don't
|
|
27
|
+
* toggle the details panel when clicked.
|
|
28
|
+
*/
|
|
29
|
+
export type RenderNode<TData> = (node: GraphNodeModel<TData>, ctx: NodeRenderContext) => ReactNode;
|
|
30
|
+
type GraphNodeProps<TData> = {
|
|
31
|
+
node: GraphNodeModel<TData>;
|
|
32
|
+
selected: boolean;
|
|
33
|
+
/**
|
|
34
|
+
* `true`: the node can be selected, dragged and connected.
|
|
35
|
+
* `false`: view mode — clicking expands/collapses its details instead.
|
|
36
|
+
*/
|
|
37
|
+
editable: boolean;
|
|
38
|
+
/** Position relative to the hovered node's dependency chain. */
|
|
39
|
+
role?: DependencyRole;
|
|
40
|
+
/** Relation chip text on the hovered node's chain (see `NodeRenderContext.relation`). */
|
|
41
|
+
relation?: string;
|
|
42
|
+
dimmed?: boolean;
|
|
43
|
+
onSelect: (id: string) => void;
|
|
44
|
+
onHoverChange: (id: string, hovered: boolean) => void;
|
|
45
|
+
onDragStart: (id: string, event: React.PointerEvent) => void;
|
|
46
|
+
onSourceHandleDown: (id: string, event: React.PointerEvent) => void;
|
|
47
|
+
onSizeChange: (id: string, size: {
|
|
48
|
+
width: number;
|
|
49
|
+
height: number;
|
|
50
|
+
}) => void;
|
|
51
|
+
expanded: boolean;
|
|
52
|
+
onToggleExpanded: (id: string) => void;
|
|
53
|
+
/** Custom card body; see `RenderNode`. */
|
|
54
|
+
renderNode?: RenderNode<TData>;
|
|
55
|
+
/** Custom detail body; defaults to `data.details` as label/value rows. */
|
|
56
|
+
renderDetails?: (node: GraphNodeModel<TData>) => ReactNode;
|
|
57
|
+
};
|
|
58
|
+
export declare function GraphNode<TData>({ node, selected, editable, role, relation, dimmed, onSelect, onHoverChange, onDragStart, onSourceHandleDown, onSizeChange, expanded, onToggleExpanded, renderNode, renderDetails, }: GraphNodeProps<TData>): import("react").JSX.Element;
|
|
59
|
+
export {};
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { EdgeId, GraphEdge, NodeId } from './types';
|
|
2
|
+
/**
|
|
3
|
+
* Edge direction is `source → target`: the target depends on the source.
|
|
4
|
+
* - upstream of X: nodes X (transitively) depends on
|
|
5
|
+
* - downstream of X: nodes that (transitively) depend on X
|
|
6
|
+
*/
|
|
7
|
+
export type DependencyRole = 'focus' | 'upstream' | 'downstream';
|
|
8
|
+
/** A chain node's relation to its neighbor on the chain, read as "<node> <relation> <otherId>". */
|
|
9
|
+
export type ChainRelation = {
|
|
10
|
+
relation: string;
|
|
11
|
+
/** The adjacent chain node the relation refers to (the focus node for direct neighbors). */
|
|
12
|
+
otherId: NodeId;
|
|
13
|
+
};
|
|
14
|
+
export type DependencyHighlight = {
|
|
15
|
+
focusId: NodeId;
|
|
16
|
+
nodes: Map<NodeId, DependencyRole>;
|
|
17
|
+
edges: Map<EdgeId, 'upstream' | 'downstream'>;
|
|
18
|
+
/**
|
|
19
|
+
* Relations of chain nodes, taken from the chain edge(s) that reach them:
|
|
20
|
+
* downstream nodes get the edge's `relation` (toward its source), upstream
|
|
21
|
+
* nodes the edge's `inverseRelation` (toward its target). Only set when present.
|
|
22
|
+
*/
|
|
23
|
+
relations: Map<NodeId, ChainRelation[]>;
|
|
24
|
+
};
|
|
25
|
+
/** Nodes that `nodeId` transitively depends on. */
|
|
26
|
+
export declare function getUpstream(edges: Iterable<GraphEdge>, nodeId: NodeId): Set<NodeId>;
|
|
27
|
+
/** Nodes that transitively depend on `nodeId`. */
|
|
28
|
+
export declare function getDownstream(edges: Iterable<GraphEdge>, nodeId: NodeId): Set<NodeId>;
|
|
29
|
+
/** True when adding `source → target` would close a dependency cycle. */
|
|
30
|
+
export declare function wouldCreateCycle(edges: Iterable<GraphEdge>, source: NodeId, target: NodeId): boolean;
|
|
31
|
+
/** Roles of every node/edge on a dependency chain through `focusId`. */
|
|
32
|
+
export declare function getDependencyHighlight(edges: GraphEdge[], focusId: NodeId): DependencyHighlight;
|