@exellix/diagrams-toolkit 0.2.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 +95 -0
- package/package.json +41 -0
- package/src/ConceptSummaryStrip.jsx +75 -0
- package/src/DefaultGraphEdge.jsx +57 -0
- package/src/EmptyState.jsx +16 -0
- package/src/ErrorState.jsx +20 -0
- package/src/FlowCanvas.jsx +103 -0
- package/src/GraphCanvas.jsx +455 -0
- package/src/GraphCanvasZoomControl.jsx +59 -0
- package/src/InspectorShell.jsx +64 -0
- package/src/JsonDocumentPanel.jsx +95 -0
- package/src/ResourceDataTable.jsx +180 -0
- package/src/StatusBadge.jsx +41 -0
- package/src/SyncStateBanner.jsx +58 -0
- package/src/ViewTabStrip.jsx +39 -0
- package/src/ZoomControls.jsx +80 -0
- package/src/index.js +39 -0
- package/src/lib/computeEdgeBezierPath.js +32 -0
- package/src/lib/graphCanvasLayout.js +99 -0
- package/src/lib/graphCanvasZoom.js +33 -0
- package/src/lib/graphViewportResizePan.js +99 -0
- package/src/styles.css +4 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
import React, { useMemo, useState } from 'react';
|
|
2
|
+
import { Search, ChevronDown, ChevronUp, MoreHorizontal } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @typedef {{
|
|
6
|
+
* key: string,
|
|
7
|
+
* label: string,
|
|
8
|
+
* sortable?: boolean,
|
|
9
|
+
* render?: (row: Record<string, unknown>) => React.ReactNode,
|
|
10
|
+
* className?: string,
|
|
11
|
+
* }} DataColumn
|
|
12
|
+
*
|
|
13
|
+
* @typedef {{
|
|
14
|
+
* id: string,
|
|
15
|
+
* label: string,
|
|
16
|
+
* options: Array<{ value: string, label: string }>,
|
|
17
|
+
* }} DataFilter
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* @param {{
|
|
22
|
+
* columns: DataColumn[],
|
|
23
|
+
* rows: Array<Record<string, unknown>>,
|
|
24
|
+
* searchPlaceholder?: string,
|
|
25
|
+
* searchKeys?: string[],
|
|
26
|
+
* filters?: DataFilter[],
|
|
27
|
+
* filterValues?: Record<string, string>,
|
|
28
|
+
* onFilterChange?: (key: string, value: string) => void,
|
|
29
|
+
* sortKey?: string,
|
|
30
|
+
* sortDir?: 'asc' | 'desc',
|
|
31
|
+
* onSort?: (key: string) => void,
|
|
32
|
+
* rowActions?: (row: Record<string, unknown>) => React.ReactNode,
|
|
33
|
+
* onRowClick?: (row: Record<string, unknown>) => void,
|
|
34
|
+
* emptyMessage?: string,
|
|
35
|
+
* toolbar?: React.ReactNode,
|
|
36
|
+
* className?: string,
|
|
37
|
+
* }} props
|
|
38
|
+
*/
|
|
39
|
+
export function ResourceDataTable({
|
|
40
|
+
columns,
|
|
41
|
+
rows,
|
|
42
|
+
searchPlaceholder = 'Search…',
|
|
43
|
+
searchKeys = [],
|
|
44
|
+
filters = [],
|
|
45
|
+
filterValues = {},
|
|
46
|
+
onFilterChange,
|
|
47
|
+
sortKey,
|
|
48
|
+
sortDir = 'desc',
|
|
49
|
+
onSort,
|
|
50
|
+
rowActions,
|
|
51
|
+
onRowClick,
|
|
52
|
+
emptyMessage = 'No items found.',
|
|
53
|
+
toolbar,
|
|
54
|
+
className = '',
|
|
55
|
+
}) {
|
|
56
|
+
const [search, setSearch] = useState('');
|
|
57
|
+
|
|
58
|
+
const filtered = useMemo(() => {
|
|
59
|
+
let list = [...rows];
|
|
60
|
+
const q = search.trim().toLowerCase();
|
|
61
|
+
if (q) {
|
|
62
|
+
list = list.filter((row) => {
|
|
63
|
+
const keys = searchKeys.length ? searchKeys : columns.map((c) => c.key);
|
|
64
|
+
return keys.some((k) => String(row[k] ?? '').toLowerCase().includes(q));
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
for (const f of filters) {
|
|
68
|
+
const val = filterValues[f.id];
|
|
69
|
+
if (val && val !== 'all') {
|
|
70
|
+
list = list.filter((row) => String(row[f.id] ?? '') === val);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
if (sortKey && onSort) {
|
|
74
|
+
list.sort((a, b) => {
|
|
75
|
+
const av = a[sortKey];
|
|
76
|
+
const bv = b[sortKey];
|
|
77
|
+
const cmp =
|
|
78
|
+
typeof av === 'number' && typeof bv === 'number'
|
|
79
|
+
? av - bv
|
|
80
|
+
: String(av ?? '').localeCompare(String(bv ?? ''));
|
|
81
|
+
return sortDir === 'asc' ? cmp : -cmp;
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
return list;
|
|
85
|
+
}, [rows, search, searchKeys, columns, filters, filterValues, sortKey, sortDir, onSort]);
|
|
86
|
+
|
|
87
|
+
return (
|
|
88
|
+
<div className={`flex flex-col min-h-0 ${className}`}>
|
|
89
|
+
<div className="flex flex-wrap items-center gap-3 px-4 py-3 bg-white border-b border-slate-200 shrink-0">
|
|
90
|
+
<div className="relative flex-1 min-w-[12rem] max-w-md">
|
|
91
|
+
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
|
92
|
+
<input
|
|
93
|
+
type="search"
|
|
94
|
+
value={search}
|
|
95
|
+
onChange={(e) => setSearch(e.target.value)}
|
|
96
|
+
placeholder={searchPlaceholder}
|
|
97
|
+
className="w-full pl-9 pr-3 py-2 text-sm border border-slate-200 rounded-lg focus:outline-none focus:ring-2 focus:ring-indigo-500/30"
|
|
98
|
+
/>
|
|
99
|
+
</div>
|
|
100
|
+
{filters.map((f) => (
|
|
101
|
+
<select
|
|
102
|
+
key={f.id}
|
|
103
|
+
value={filterValues[f.id] ?? 'all'}
|
|
104
|
+
onChange={(e) => onFilterChange?.(f.id, e.target.value)}
|
|
105
|
+
className="text-sm border border-slate-200 rounded-lg px-3 py-2 bg-white"
|
|
106
|
+
>
|
|
107
|
+
<option value="all">All {f.label}</option>
|
|
108
|
+
{f.options.map((o) => (
|
|
109
|
+
<option key={o.value} value={o.value}>
|
|
110
|
+
{o.label}
|
|
111
|
+
</option>
|
|
112
|
+
))}
|
|
113
|
+
</select>
|
|
114
|
+
))}
|
|
115
|
+
{toolbar}
|
|
116
|
+
</div>
|
|
117
|
+
<div className="flex-1 min-h-0 overflow-auto">
|
|
118
|
+
<table className="w-full text-sm text-left">
|
|
119
|
+
<thead className="sticky top-0 bg-slate-50 border-b border-slate-200 text-xs uppercase text-slate-500">
|
|
120
|
+
<tr>
|
|
121
|
+
{columns.map((col) => (
|
|
122
|
+
<th key={col.key} className={`px-4 py-3 font-semibold ${col.className ?? ''}`}>
|
|
123
|
+
{col.sortable && onSort ? (
|
|
124
|
+
<button
|
|
125
|
+
type="button"
|
|
126
|
+
onClick={() => onSort(col.key)}
|
|
127
|
+
className="inline-flex items-center gap-1 hover:text-slate-800"
|
|
128
|
+
>
|
|
129
|
+
{col.label}
|
|
130
|
+
{sortKey === col.key ? (
|
|
131
|
+
sortDir === 'asc' ? (
|
|
132
|
+
<ChevronUp size={14} />
|
|
133
|
+
) : (
|
|
134
|
+
<ChevronDown size={14} />
|
|
135
|
+
)
|
|
136
|
+
) : null}
|
|
137
|
+
</button>
|
|
138
|
+
) : (
|
|
139
|
+
col.label
|
|
140
|
+
)}
|
|
141
|
+
</th>
|
|
142
|
+
))}
|
|
143
|
+
{rowActions ? <th className="px-4 py-3 w-12" /> : null}
|
|
144
|
+
</tr>
|
|
145
|
+
</thead>
|
|
146
|
+
<tbody className="divide-y divide-slate-100 bg-white">
|
|
147
|
+
{filtered.length === 0 ? (
|
|
148
|
+
<tr>
|
|
149
|
+
<td colSpan={columns.length + (rowActions ? 1 : 0)} className="px-4 py-12 text-center text-slate-500">
|
|
150
|
+
{emptyMessage}
|
|
151
|
+
</td>
|
|
152
|
+
</tr>
|
|
153
|
+
) : (
|
|
154
|
+
filtered.map((row) => (
|
|
155
|
+
<tr
|
|
156
|
+
key={String(row.id ?? row.graphId ?? row.itemId)}
|
|
157
|
+
className={`hover:bg-slate-50 ${onRowClick ? 'cursor-pointer' : ''}`}
|
|
158
|
+
onClick={() => onRowClick?.(row)}
|
|
159
|
+
>
|
|
160
|
+
{columns.map((col) => (
|
|
161
|
+
<td key={col.key} className={`px-4 py-3 ${col.className ?? ''}`}>
|
|
162
|
+
{col.render ? col.render(row) : String(row[col.key] ?? '—')}
|
|
163
|
+
</td>
|
|
164
|
+
))}
|
|
165
|
+
{rowActions ? (
|
|
166
|
+
<td className="px-4 py-3" onClick={(e) => e.stopPropagation()}>
|
|
167
|
+
{rowActions(row)}
|
|
168
|
+
</td>
|
|
169
|
+
) : null}
|
|
170
|
+
</tr>
|
|
171
|
+
))
|
|
172
|
+
)}
|
|
173
|
+
</tbody>
|
|
174
|
+
</table>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export { MoreHorizontal };
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/** @type {Record<string, { className: string, label?: string }>} */
|
|
4
|
+
export const DEFAULT_STATUS_PALETTE = {
|
|
5
|
+
draft: { className: 'bg-gray-100 text-gray-600 border-gray-200', label: 'Draft' },
|
|
6
|
+
published: { className: 'bg-green-100 text-green-700 border-green-200', label: 'Published' },
|
|
7
|
+
deleted: { className: 'bg-red-50 text-red-700 border-red-200', label: 'Deleted' },
|
|
8
|
+
planned: { className: 'bg-purple-100 text-purple-700 border-purple-200', label: 'Planned' },
|
|
9
|
+
'partially-defined': {
|
|
10
|
+
className: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
|
11
|
+
label: 'Needs Def',
|
|
12
|
+
},
|
|
13
|
+
'ready-for-config': {
|
|
14
|
+
className: 'bg-blue-100 text-blue-700 border-blue-200',
|
|
15
|
+
label: 'Ready for Config',
|
|
16
|
+
},
|
|
17
|
+
'implementation-ready': {
|
|
18
|
+
className: 'bg-green-100 text-green-700 border-green-200',
|
|
19
|
+
label: 'Impl Ready',
|
|
20
|
+
},
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* @param {{
|
|
25
|
+
* status: string,
|
|
26
|
+
* palette?: Record<string, { className: string, label?: string }>,
|
|
27
|
+
* className?: string,
|
|
28
|
+
* }} props
|
|
29
|
+
*/
|
|
30
|
+
export function StatusBadge({ status, palette = DEFAULT_STATUS_PALETTE, className = '' }) {
|
|
31
|
+
const key = typeof status === 'string' ? status.trim() : '';
|
|
32
|
+
const entry = palette[key] || palette.draft || { className: 'bg-gray-100 text-gray-600 border-gray-200' };
|
|
33
|
+
const label = entry.label || key || 'Unknown';
|
|
34
|
+
return (
|
|
35
|
+
<span
|
|
36
|
+
className={`inline-flex px-2 py-0.5 text-xs font-medium rounded-full border ${entry.className} ${className}`}
|
|
37
|
+
>
|
|
38
|
+
{label}
|
|
39
|
+
</span>
|
|
40
|
+
);
|
|
41
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { AlertTriangle } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
const SYNC_LABELS = {
|
|
5
|
+
clean: { text: 'Layers in sync', className: 'bg-emerald-50 text-emerald-800 border-emerald-200' },
|
|
6
|
+
'concept-ahead': {
|
|
7
|
+
text: 'Concept layer has unpublished changes',
|
|
8
|
+
className: 'bg-amber-50 text-amber-900 border-amber-200',
|
|
9
|
+
},
|
|
10
|
+
'graph-ahead': {
|
|
11
|
+
text: 'Graph changed — layers may be stale',
|
|
12
|
+
className: 'bg-amber-50 text-amber-900 border-amber-200',
|
|
13
|
+
},
|
|
14
|
+
'layer-ahead': {
|
|
15
|
+
text: 'Data-flow layer has unpublished changes',
|
|
16
|
+
className: 'bg-amber-50 text-amber-900 border-amber-200',
|
|
17
|
+
},
|
|
18
|
+
conflict: {
|
|
19
|
+
text: 'Layer conflict — resolve before continuing',
|
|
20
|
+
className: 'bg-red-50 text-red-800 border-red-200',
|
|
21
|
+
},
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{
|
|
26
|
+
* syncState: string,
|
|
27
|
+
* onResolve?: () => void,
|
|
28
|
+
* resolveLabel?: string,
|
|
29
|
+
* className?: string,
|
|
30
|
+
* }} props
|
|
31
|
+
*/
|
|
32
|
+
export function SyncStateBanner({
|
|
33
|
+
syncState,
|
|
34
|
+
onResolve,
|
|
35
|
+
resolveLabel = 'Resolve',
|
|
36
|
+
className = '',
|
|
37
|
+
}) {
|
|
38
|
+
if (!syncState || syncState === 'clean') return null;
|
|
39
|
+
const entry = SYNC_LABELS[syncState] || SYNC_LABELS.conflict;
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<div
|
|
43
|
+
className={`flex items-center gap-3 px-4 py-2 border-b text-sm ${entry.className} ${className}`}
|
|
44
|
+
>
|
|
45
|
+
<AlertTriangle size={16} className="shrink-0" aria-hidden />
|
|
46
|
+
<span className="flex-1">{entry.text}</span>
|
|
47
|
+
{onResolve ? (
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={onResolve}
|
|
51
|
+
className="shrink-0 px-2.5 py-1 text-xs font-semibold rounded-md border border-current hover:bg-white/40"
|
|
52
|
+
>
|
|
53
|
+
{resolveLabel}
|
|
54
|
+
</button>
|
|
55
|
+
) : null}
|
|
56
|
+
</div>
|
|
57
|
+
);
|
|
58
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* @param {{
|
|
5
|
+
* tabs: Array<{ id: string, label: string, disabled?: boolean }>,
|
|
6
|
+
* active: string,
|
|
7
|
+
* onChange: (id: string) => void,
|
|
8
|
+
* className?: string,
|
|
9
|
+
* }} props
|
|
10
|
+
*/
|
|
11
|
+
export function ViewTabStrip({ tabs, active, onChange, className = '' }) {
|
|
12
|
+
return (
|
|
13
|
+
<div
|
|
14
|
+
className={`inline-flex items-center gap-0.5 p-0.5 bg-slate-100 border border-slate-200 rounded-lg ${className}`}
|
|
15
|
+
role="tablist"
|
|
16
|
+
>
|
|
17
|
+
{tabs.map((tab) => {
|
|
18
|
+
const isActive = tab.id === active;
|
|
19
|
+
return (
|
|
20
|
+
<button
|
|
21
|
+
key={tab.id}
|
|
22
|
+
type="button"
|
|
23
|
+
role="tab"
|
|
24
|
+
aria-selected={isActive}
|
|
25
|
+
disabled={tab.disabled}
|
|
26
|
+
onClick={() => onChange(tab.id)}
|
|
27
|
+
className={`px-3 py-1.5 text-sm font-medium rounded-md transition-colors ${
|
|
28
|
+
isActive
|
|
29
|
+
? 'bg-white text-indigo-700 shadow-sm border border-slate-200'
|
|
30
|
+
: 'text-slate-600 hover:text-slate-900 hover:bg-slate-50'
|
|
31
|
+
} disabled:opacity-40 disabled:cursor-not-allowed`}
|
|
32
|
+
>
|
|
33
|
+
{tab.label}
|
|
34
|
+
</button>
|
|
35
|
+
);
|
|
36
|
+
})}
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { Crosshair, ZoomIn, ZoomOut } from 'lucide-react';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* @param {{
|
|
6
|
+
* zoom?: number,
|
|
7
|
+
* minZoom?: number,
|
|
8
|
+
* maxZoom?: number,
|
|
9
|
+
* onZoomChange?: (zoom: number) => void,
|
|
10
|
+
* onZoomIn?: () => void,
|
|
11
|
+
* onZoomOut?: () => void,
|
|
12
|
+
* onFitView?: () => void,
|
|
13
|
+
* showSlider?: boolean,
|
|
14
|
+
* className?: string,
|
|
15
|
+
* }} props
|
|
16
|
+
*/
|
|
17
|
+
export function ZoomControls({
|
|
18
|
+
zoom = 100,
|
|
19
|
+
minZoom = 25,
|
|
20
|
+
maxZoom = 200,
|
|
21
|
+
onZoomChange,
|
|
22
|
+
onZoomIn,
|
|
23
|
+
onZoomOut,
|
|
24
|
+
onFitView,
|
|
25
|
+
showSlider = true,
|
|
26
|
+
className = '',
|
|
27
|
+
}) {
|
|
28
|
+
const pct = Math.min(maxZoom, Math.max(minZoom, Math.round(zoom)));
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<div
|
|
32
|
+
className={`flex items-center gap-2 bg-white border border-slate-200 rounded-lg shadow-md px-2.5 py-2 ${className}`}
|
|
33
|
+
onPointerDown={(e) => e.stopPropagation()}
|
|
34
|
+
>
|
|
35
|
+
{onZoomOut ? (
|
|
36
|
+
<button
|
|
37
|
+
type="button"
|
|
38
|
+
className="p-1.5 rounded-md hover:bg-slate-50 text-slate-600 border border-slate-200"
|
|
39
|
+
onClick={onZoomOut}
|
|
40
|
+
aria-label="Zoom out"
|
|
41
|
+
>
|
|
42
|
+
<ZoomOut size={16} />
|
|
43
|
+
</button>
|
|
44
|
+
) : null}
|
|
45
|
+
{showSlider && onZoomChange ? (
|
|
46
|
+
<input
|
|
47
|
+
type="range"
|
|
48
|
+
min={minZoom}
|
|
49
|
+
max={maxZoom}
|
|
50
|
+
step={5}
|
|
51
|
+
value={pct}
|
|
52
|
+
onChange={(e) => onZoomChange(Number(e.target.value))}
|
|
53
|
+
className="flex-1 min-w-[6rem] h-1.5 cursor-pointer accent-indigo-600"
|
|
54
|
+
aria-label="Canvas zoom"
|
|
55
|
+
/>
|
|
56
|
+
) : null}
|
|
57
|
+
{onZoomIn ? (
|
|
58
|
+
<button
|
|
59
|
+
type="button"
|
|
60
|
+
className="p-1.5 rounded-md hover:bg-slate-50 text-slate-600 border border-slate-200"
|
|
61
|
+
onClick={onZoomIn}
|
|
62
|
+
aria-label="Zoom in"
|
|
63
|
+
>
|
|
64
|
+
<ZoomIn size={16} />
|
|
65
|
+
</button>
|
|
66
|
+
) : null}
|
|
67
|
+
{onFitView ? (
|
|
68
|
+
<button
|
|
69
|
+
type="button"
|
|
70
|
+
className="p-1.5 rounded-md hover:bg-slate-50 text-slate-600 border border-slate-200"
|
|
71
|
+
onClick={onFitView}
|
|
72
|
+
title="Fit in view"
|
|
73
|
+
aria-label="Fit in view"
|
|
74
|
+
>
|
|
75
|
+
<Crosshair size={16} />
|
|
76
|
+
</button>
|
|
77
|
+
) : null}
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
export { ViewTabStrip } from './ViewTabStrip.jsx';
|
|
2
|
+
export { JsonDocumentPanel } from './JsonDocumentPanel.jsx';
|
|
3
|
+
export { FlowCanvas } from './FlowCanvas.jsx';
|
|
4
|
+
export { GraphCanvas } from './GraphCanvas.jsx';
|
|
5
|
+
export { GraphCanvasZoomControl } from './GraphCanvasZoomControl.jsx';
|
|
6
|
+
export { DefaultGraphEdge } from './DefaultGraphEdge.jsx';
|
|
7
|
+
export { ConceptSummaryStrip } from './ConceptSummaryStrip.jsx';
|
|
8
|
+
export { ResourceDataTable } from './ResourceDataTable.jsx';
|
|
9
|
+
export { StatusBadge, DEFAULT_STATUS_PALETTE } from './StatusBadge.jsx';
|
|
10
|
+
export { InspectorShell } from './InspectorShell.jsx';
|
|
11
|
+
export { ZoomControls } from './ZoomControls.jsx';
|
|
12
|
+
export { EmptyState } from './EmptyState.jsx';
|
|
13
|
+
export { ErrorState } from './ErrorState.jsx';
|
|
14
|
+
export { SyncStateBanner } from './SyncStateBanner.jsx';
|
|
15
|
+
export {
|
|
16
|
+
clampGraphCanvasScale,
|
|
17
|
+
graphCanvasScaleToPercent,
|
|
18
|
+
graphCanvasPercentToScale,
|
|
19
|
+
GRAPH_CANVAS_MIN_SCALE,
|
|
20
|
+
GRAPH_CANVAS_MAX_SCALE,
|
|
21
|
+
GRAPH_CANVAS_MIN_PERCENT,
|
|
22
|
+
GRAPH_CANVAS_MAX_PERCENT,
|
|
23
|
+
} from './lib/graphCanvasZoom.js';
|
|
24
|
+
export {
|
|
25
|
+
layoutGraphNodesFromTopology,
|
|
26
|
+
calculateAutoLayout,
|
|
27
|
+
withEdgeCounts,
|
|
28
|
+
DEFAULT_NODE_CARD_WIDTH,
|
|
29
|
+
DEFAULT_CANVAS_LEFT_INSET,
|
|
30
|
+
} from './lib/graphCanvasLayout.js';
|
|
31
|
+
export { computeEdgeBezierPath } from './lib/computeEdgeBezierPath.js';
|
|
32
|
+
export {
|
|
33
|
+
computeGraphViewportResizePan,
|
|
34
|
+
canvasNodesForViewportBounds,
|
|
35
|
+
graphContentScreenBounds,
|
|
36
|
+
GRAPH_VIEWPORT_RESIZE_EDGE_PAD,
|
|
37
|
+
GRAPH_NODE_CARD_WIDTH,
|
|
38
|
+
GRAPH_NODE_CARD_APPROX_HEIGHT,
|
|
39
|
+
} from './lib/graphViewportResizePan.js';
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { DEFAULT_NODE_CARD_WIDTH } from './graphCanvasLayout.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Cubic-bezier path from source node right-center to target node left-center.
|
|
5
|
+
* @param {{ x: number, y: number }} sourceNode
|
|
6
|
+
* @param {{ x: number, y: number }} targetNode
|
|
7
|
+
* @param {{ nodeWidth?: number, anchorY?: number, minControlOffset?: number }} [opts]
|
|
8
|
+
* @returns {{ path: string, startX: number, startY: number, endX: number, endY: number, midX: number, midY: number }}
|
|
9
|
+
*/
|
|
10
|
+
export function computeEdgeBezierPath(sourceNode, targetNode, opts = {}) {
|
|
11
|
+
const nodeWidth = opts.nodeWidth ?? DEFAULT_NODE_CARD_WIDTH;
|
|
12
|
+
const anchorY = opts.anchorY ?? 60;
|
|
13
|
+
const minControlOffset = opts.minControlOffset ?? 50;
|
|
14
|
+
|
|
15
|
+
const startX = sourceNode.x + nodeWidth;
|
|
16
|
+
const startY = sourceNode.y + anchorY;
|
|
17
|
+
const endX = targetNode.x;
|
|
18
|
+
const endY = targetNode.y + anchorY;
|
|
19
|
+
|
|
20
|
+
const controlPointOffset = Math.max(Math.abs(endX - startX) / 2, minControlOffset);
|
|
21
|
+
const path = `M ${startX} ${startY} C ${startX + controlPointOffset} ${startY}, ${endX - controlPointOffset} ${endY}, ${endX} ${endY}`;
|
|
22
|
+
|
|
23
|
+
return {
|
|
24
|
+
path,
|
|
25
|
+
startX,
|
|
26
|
+
startY,
|
|
27
|
+
endX,
|
|
28
|
+
endY,
|
|
29
|
+
midX: (startX + endX) / 2,
|
|
30
|
+
midY: (startY + endY) / 2,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/** Default layout constants matching graphs-studio node cards. */
|
|
2
|
+
export const DEFAULT_NODE_CARD_WIDTH = 320;
|
|
3
|
+
export const GRAPH_NODE_CARD_APPROX_HEIGHT = 240;
|
|
4
|
+
export const DEFAULT_VIRTUAL_ANCHOR_GAP = 100;
|
|
5
|
+
export const DEFAULT_CANVAS_LEFT_INSET =
|
|
6
|
+
DEFAULT_NODE_CARD_WIDTH + DEFAULT_VIRTUAL_ANCHOR_GAP + 60;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {object[]} nodes
|
|
10
|
+
* @param {object[]} edges
|
|
11
|
+
* @param {{ columnGap?: number, rowGap?: number, leftInset?: number, baseX?: number, baseY?: number }} [opts]
|
|
12
|
+
*/
|
|
13
|
+
export function calculateAutoLayout(nodes, edges, opts = {}) {
|
|
14
|
+
const columnGap = opts.columnGap ?? 380;
|
|
15
|
+
const rowGap = opts.rowGap ?? 340;
|
|
16
|
+
const leftInset = opts.leftInset ?? DEFAULT_CANVAS_LEFT_INSET;
|
|
17
|
+
const baseX = opts.baseX ?? 100;
|
|
18
|
+
const baseY = opts.baseY ?? 100;
|
|
19
|
+
|
|
20
|
+
const levels = {};
|
|
21
|
+
const inDegree = {};
|
|
22
|
+
const outDegree = {};
|
|
23
|
+
|
|
24
|
+
nodes.forEach((n) => {
|
|
25
|
+
inDegree[n.id] = 0;
|
|
26
|
+
outDegree[n.id] = [];
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
edges.forEach((e) => {
|
|
30
|
+
if (inDegree[e.to] !== undefined) inDegree[e.to]++;
|
|
31
|
+
if (outDegree[e.from]) {
|
|
32
|
+
if (!outDegree[e.from].includes(e.to)) outDegree[e.from].push(e.to);
|
|
33
|
+
} else {
|
|
34
|
+
outDegree[e.from] = [e.to];
|
|
35
|
+
}
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
let queue = nodes.filter((n) => inDegree[n.id] === 0).map((n) => n.id);
|
|
39
|
+
queue.forEach((id) => {
|
|
40
|
+
levels[id] = 0;
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
while (queue.length > 0) {
|
|
44
|
+
const u = queue.shift();
|
|
45
|
+
(outDegree[u] || []).forEach((v) => {
|
|
46
|
+
if (levels[v] === undefined || levels[v] < levels[u] + 1) {
|
|
47
|
+
levels[v] = levels[u] + 1;
|
|
48
|
+
queue.push(v);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
nodes.forEach((n) => {
|
|
54
|
+
if (levels[n.id] === undefined) levels[n.id] = 0;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const levelCounts = {};
|
|
58
|
+
return nodes.map((n) => {
|
|
59
|
+
const l = levels[n.id];
|
|
60
|
+
if (levelCounts[l] === undefined) levelCounts[l] = 0;
|
|
61
|
+
const x = l * columnGap + baseX + leftInset;
|
|
62
|
+
const y = levelCounts[l] * rowGap + baseY;
|
|
63
|
+
levelCounts[l]++;
|
|
64
|
+
return { ...n, x, y };
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* @param {object[]} nodes
|
|
70
|
+
* @param {object[]} edges
|
|
71
|
+
*/
|
|
72
|
+
export function withEdgeCounts(nodes, edges) {
|
|
73
|
+
const counts = (Array.isArray(edges) ? edges : []).reduce(
|
|
74
|
+
(acc, e) => {
|
|
75
|
+
if (!acc.in[e.to]) acc.in[e.to] = 0;
|
|
76
|
+
if (!acc.out[e.from]) acc.out[e.from] = 0;
|
|
77
|
+
acc.in[e.to]++;
|
|
78
|
+
acc.out[e.from]++;
|
|
79
|
+
return acc;
|
|
80
|
+
},
|
|
81
|
+
{ in: {}, out: {} },
|
|
82
|
+
);
|
|
83
|
+
return (Array.isArray(nodes) ? nodes : []).map((n) => ({
|
|
84
|
+
...n,
|
|
85
|
+
_inCount: counts.in[n.id] || 0,
|
|
86
|
+
_outCount: counts.out[n.id] || 0,
|
|
87
|
+
}));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Auto-layout canvas nodes + edge in/out counts from canonical node/edge lists.
|
|
92
|
+
* @param {object[]} nList
|
|
93
|
+
* @param {object[]} eList
|
|
94
|
+
* @param {{ columnGap?: number, rowGap?: number, leftInset?: number }} [layoutOpts]
|
|
95
|
+
*/
|
|
96
|
+
export function layoutGraphNodesFromTopology(nList, eList, layoutOpts) {
|
|
97
|
+
const layoutedNodes = calculateAutoLayout(nList, eList, layoutOpts);
|
|
98
|
+
return withEdgeCounts(layoutedNodes, eList);
|
|
99
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/** Canvas zoom limits (match graph-editor wheel + toolbar). */
|
|
2
|
+
export const GRAPH_CANVAS_MIN_SCALE = 0.1;
|
|
3
|
+
export const GRAPH_CANVAS_MAX_SCALE = 3;
|
|
4
|
+
|
|
5
|
+
export const GRAPH_CANVAS_MIN_PERCENT = Math.round(GRAPH_CANVAS_MIN_SCALE * 100);
|
|
6
|
+
export const GRAPH_CANVAS_MAX_PERCENT = Math.round(GRAPH_CANVAS_MAX_SCALE * 100);
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* @param {number} scale
|
|
10
|
+
* @returns {number}
|
|
11
|
+
*/
|
|
12
|
+
export function clampGraphCanvasScale(scale) {
|
|
13
|
+
if (!Number.isFinite(scale)) return 1;
|
|
14
|
+
return Math.min(GRAPH_CANVAS_MAX_SCALE, Math.max(GRAPH_CANVAS_MIN_SCALE, scale));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {number} scale
|
|
19
|
+
* @returns {number} Integer percent for UI (10–300).
|
|
20
|
+
*/
|
|
21
|
+
export function graphCanvasScaleToPercent(scale) {
|
|
22
|
+
return Math.round(clampGraphCanvasScale(scale) * 100);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* @param {number} percent
|
|
27
|
+
* @returns {number}
|
|
28
|
+
*/
|
|
29
|
+
export function graphCanvasPercentToScale(percent) {
|
|
30
|
+
const n = Number(percent);
|
|
31
|
+
if (!Number.isFinite(n)) return 1;
|
|
32
|
+
return clampGraphCanvasScale(n / 100);
|
|
33
|
+
}
|