sqlseed-web 0.2.4__py3-none-any.whl
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.
- sqlseed_web/AGENTS.md +106 -0
- sqlseed_web/__init__.py +24 -0
- sqlseed_web/__main__.py +8 -0
- sqlseed_web/_application.py +205 -0
- sqlseed_web/ai_settings.py +290 -0
- sqlseed_web/api.py +1102 -0
- sqlseed_web/app.py +26 -0
- sqlseed_web/managed_worker.py +184 -0
- sqlseed_web/operation_errors.py +42 -0
- sqlseed_web/plugin_environment.py +231 -0
- sqlseed_web/plugin_management.py +322 -0
- sqlseed_web/plugin_process.py +131 -0
- sqlseed_web/runtime_lifecycle.py +130 -0
- sqlseed_web/runtime_session.py +97 -0
- sqlseed_web/settings_environment.py +368 -0
- sqlseed_web/sqlite_target.py +86 -0
- sqlseed_web/state.py +348 -0
- sqlseed_web/static/AGENTS.md +180 -0
- sqlseed_web/static/ai.css +57 -0
- sqlseed_web/static/configs.css +50 -0
- sqlseed_web/static/date-picker.css +230 -0
- sqlseed_web/static/disclosure.css +149 -0
- sqlseed_web/static/graph-clarity.css +103 -0
- sqlseed_web/static/index.html +29 -0
- sqlseed_web/static/js/api.js +228 -0
- sqlseed_web/static/js/app.js +97 -0
- sqlseed_web/static/js/dropdown.js +432 -0
- sqlseed_web/static/js/filepicker.js +203 -0
- sqlseed_web/static/js/genform.js +1066 -0
- sqlseed_web/static/js/labels.js +195 -0
- sqlseed_web/static/js/pages/browse.js +209 -0
- sqlseed_web/static/js/pages/configs.js +424 -0
- sqlseed_web/static/js/pages/connect.js +332 -0
- sqlseed_web/static/js/pages/heal.js +395 -0
- sqlseed_web/static/js/pages/meta.js +110 -0
- sqlseed_web/static/js/pages/runs.js +293 -0
- sqlseed_web/static/js/pages/settings.js +942 -0
- sqlseed_web/static/js/pages/wizard.js +751 -0
- sqlseed_web/static/js/pages/workbench.js +3123 -0
- sqlseed_web/static/js/tree.js +126 -0
- sqlseed_web/static/js/workbench/ai-eligibility.js +33 -0
- sqlseed_web/static/js/workbench/ai-handoff.js +31 -0
- sqlseed_web/static/js/workbench/ai-stream.js +116 -0
- sqlseed_web/static/js/workbench/ai.js +888 -0
- sqlseed_web/static/js/workbench/connection.js +508 -0
- sqlseed_web/static/js/workbench/date-picker.js +445 -0
- sqlseed_web/static/js/workbench/dependency-view.js +119 -0
- sqlseed_web/static/js/workbench/editor.js +1236 -0
- sqlseed_web/static/js/workbench/focus.js +11 -0
- sqlseed_web/static/js/workbench/graph-layout.js +332 -0
- sqlseed_web/static/js/workbench/graph.js +970 -0
- sqlseed_web/static/js/workbench/guidance.js +29 -0
- sqlseed_web/static/js/workbench/model.js +124 -0
- sqlseed_web/static/js/workbench/plugin-management.js +512 -0
- sqlseed_web/static/js/workbench/preview-scroll-layout.js +94 -0
- sqlseed_web/static/js/workbench/preview.js +572 -0
- sqlseed_web/static/js/workbench/provider-guide.js +33 -0
- sqlseed_web/static/js/workbench/recovery.js +28 -0
- sqlseed_web/static/js/workbench/scroll-lock.js +26 -0
- sqlseed_web/static/js/workbench/session.js +174 -0
- sqlseed_web/static/js/workbench/table-data.js +186 -0
- sqlseed_web/static/js/workbench/ui.js +262 -0
- sqlseed_web/static/navigation.css +92 -0
- sqlseed_web/static/preview.css +29 -0
- sqlseed_web/static/runs.css +53 -0
- sqlseed_web/static/scrollbars.css +42 -0
- sqlseed_web/static/settings.css +108 -0
- sqlseed_web/static/style.css +3382 -0
- sqlseed_web/static/table-data.css +27 -0
- sqlseed_web/static/workbench.css +509 -0
- sqlseed_web/supervised_plugins.py +173 -0
- sqlseed_web/supervisor.py +238 -0
- sqlseed_web/workbench.py +381 -0
- sqlseed_web/workbench_ai.py +887 -0
- sqlseed_web/workbench_ai_relations.py +285 -0
- sqlseed_web/workbench_ai_stream.py +172 -0
- sqlseed_web/workbench_data.py +163 -0
- sqlseed_web/workbench_execution.py +199 -0
- sqlseed_web/workbench_runtime.py +1218 -0
- sqlseed_web/workbench_schema.py +277 -0
- sqlseed_web/workbench_store.py +458 -0
- sqlseed_web/worker_control.py +192 -0
- sqlseed_web-0.2.4.dist-info/METADATA +105 -0
- sqlseed_web-0.2.4.dist-info/RECORD +87 -0
- sqlseed_web-0.2.4.dist-info/WHEEL +4 -0
- sqlseed_web-0.2.4.dist-info/entry_points.txt +2 -0
- sqlseed_web-0.2.4.dist-info/licenses/LICENSE +679 -0
|
@@ -0,0 +1,970 @@
|
|
|
1
|
+
import './graph-layout.js';
|
|
2
|
+
import './dependency-view.js';
|
|
3
|
+
const SVG_NS = 'http://www.w3.org/2000/svg';
|
|
4
|
+
let graphSequence = 0;
|
|
5
|
+
function html(tag, attributes = {}, text = '') {
|
|
6
|
+
const el = document.createElement(tag);
|
|
7
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
8
|
+
el.setAttribute(key, value);
|
|
9
|
+
}
|
|
10
|
+
if (text) {
|
|
11
|
+
el.textContent = text;
|
|
12
|
+
}
|
|
13
|
+
return el;
|
|
14
|
+
}
|
|
15
|
+
function svgElement(tag, attributes = {}, text = '') {
|
|
16
|
+
const el = document.createElementNS(SVG_NS, tag);
|
|
17
|
+
for (const [key, value] of Object.entries(attributes)) {
|
|
18
|
+
el.setAttribute(key, value);
|
|
19
|
+
}
|
|
20
|
+
if (text) {
|
|
21
|
+
el.textContent = text;
|
|
22
|
+
}
|
|
23
|
+
return el;
|
|
24
|
+
}
|
|
25
|
+
function relationText(edge) {
|
|
26
|
+
const source = (edge.sourceColumns || []).join(' + ');
|
|
27
|
+
const target = (edge.targetColumns || []).join(' + ');
|
|
28
|
+
return `${edge.source}${source ? '.' + source : ''} → ${edge.target}${target ? '.' + target : ''}`;
|
|
29
|
+
}
|
|
30
|
+
function nodeTitle(text) {
|
|
31
|
+
let shortened = '',
|
|
32
|
+
width = 0;
|
|
33
|
+
for (const char of text) {
|
|
34
|
+
width += char.codePointAt(0) > 255 ? 14 : 8.5;
|
|
35
|
+
if (width > 164) {
|
|
36
|
+
return shortened + '…';
|
|
37
|
+
}
|
|
38
|
+
shortened += char;
|
|
39
|
+
}
|
|
40
|
+
return shortened;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Fit is a viewport-dependent scale; natural diagram dimensions are 100%.
|
|
44
|
+
* zoom remains relative to fit internally so existing view snapshots restore.
|
|
45
|
+
*/
|
|
46
|
+
export function graphViewport(layout, width, height, zoom = 1, actualSize = false) {
|
|
47
|
+
const fitScale = Math.min(1, Math.max(1, width - 24) / layout.width, Math.max(1, height - 24) / layout.height);
|
|
48
|
+
const scale = actualSize ? 1 : fitScale * zoom;
|
|
49
|
+
const svgWidth = layout.width * scale,
|
|
50
|
+
svgHeight = layout.height * scale;
|
|
51
|
+
const stageWidth = Math.max(width, svgWidth + 24),
|
|
52
|
+
stageHeight = Math.max(height, svgHeight + 24);
|
|
53
|
+
return {
|
|
54
|
+
fitScale,
|
|
55
|
+
scale,
|
|
56
|
+
zoom: scale / fitScale,
|
|
57
|
+
svgWidth,
|
|
58
|
+
svgHeight,
|
|
59
|
+
stageWidth,
|
|
60
|
+
stageHeight,
|
|
61
|
+
left: (stageWidth - svgWidth) / 2,
|
|
62
|
+
top: (stageHeight - svgHeight) / 2,
|
|
63
|
+
viewportWidth: width,
|
|
64
|
+
viewportHeight: height
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** Browse real schema relationships without changing the generation selection.
|
|
69
|
+
* onSelect may synchronously return false to keep the current graph focus.
|
|
70
|
+
*/
|
|
71
|
+
export function createSchemaGraph({
|
|
72
|
+
schema,
|
|
73
|
+
focus,
|
|
74
|
+
mode = 'all',
|
|
75
|
+
pathMode = 'complete',
|
|
76
|
+
initialView,
|
|
77
|
+
issues = [],
|
|
78
|
+
onSelect = () => true,
|
|
79
|
+
onEdge = () => {},
|
|
80
|
+
onExpand = () => {},
|
|
81
|
+
onViewChange = () => {}
|
|
82
|
+
}) {
|
|
83
|
+
function visibleGraph(visible) {
|
|
84
|
+
return {
|
|
85
|
+
nodes: data.nodes.filter(node => visible.has(node.id)),
|
|
86
|
+
edges: data.edges.filter(edge => visible.has(edge.source) && visible.has(edge.target))
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const data = {
|
|
91
|
+
nodes: (schema.nodes || []).map(node => ({
|
|
92
|
+
...node
|
|
93
|
+
})),
|
|
94
|
+
edges: (schema.edges || []).map(edge => ({
|
|
95
|
+
...edge
|
|
96
|
+
}))
|
|
97
|
+
};
|
|
98
|
+
const tableByName = new Map((schema.tables || []).map(table => [table.name, table]));
|
|
99
|
+
const nodeIds = new Set(data.nodes.map(node => node.id));
|
|
100
|
+
const restored = initialView && typeof initialView === 'object' ? initialView : {};
|
|
101
|
+
const nonnegative = value => Number.isFinite(value) && value >= 0 ? value : 0;
|
|
102
|
+
const requestedFocus = focus ?? restored.focus;
|
|
103
|
+
let currentFocus = nodeIds.has(requestedFocus) ? requestedFocus : data.nodes[0]?.id;
|
|
104
|
+
// Inspecting a node keeps the drawing stable; only an explicit path action
|
|
105
|
+
// changes its anchor. Older snapshots used focus for both purposes.
|
|
106
|
+
let pathFocus = nodeIds.has(restored.pathFocus) ? restored.pathFocus : currentFocus;
|
|
107
|
+
let currentMode = restoredMode();
|
|
108
|
+
let currentPath = restored.pathMode || pathMode;
|
|
109
|
+
if (currentPath === 'paths') {
|
|
110
|
+
currentPath = 'complete';
|
|
111
|
+
}
|
|
112
|
+
if (!['complete', 'upstream', 'downstream', 'neighbors'].includes(currentPath)) {
|
|
113
|
+
currentPath = 'complete';
|
|
114
|
+
}
|
|
115
|
+
let labels = restored.labels === true,
|
|
116
|
+
expanded = restored.expanded === true;
|
|
117
|
+
let selectedEdge = data.edges.some(edge => edge.id === restored.edgeId) ? restored.edgeId : null;
|
|
118
|
+
let relatedEdges = new Set();
|
|
119
|
+
let zoom = 1,
|
|
120
|
+
actualSize = false,
|
|
121
|
+
layout = null,
|
|
122
|
+
frame = null,
|
|
123
|
+
svg = null,
|
|
124
|
+
stage = null,
|
|
125
|
+
destroyed = false;
|
|
126
|
+
let pendingViewport = initialViewport();
|
|
127
|
+
const initialSearch = typeof restored.search === 'string' ? restored.search : '';
|
|
128
|
+
let searchText = initialSearch.trim(),
|
|
129
|
+
composing = false;
|
|
130
|
+
let lastView = '';
|
|
131
|
+
let drag = null;
|
|
132
|
+
const markerId = `wb-schema-arrow-${++graphSequence}`;
|
|
133
|
+
const listeners = [],
|
|
134
|
+
drawingListeners = [];
|
|
135
|
+
const listen = (el, type, callback, drawing = false) => {
|
|
136
|
+
el.addEventListener(type, callback);
|
|
137
|
+
(drawing ? drawingListeners : listeners).push(() => el.removeEventListener(type, callback));
|
|
138
|
+
};
|
|
139
|
+
const activate = (el, callback) => {
|
|
140
|
+
listen(el, 'click', callback, true);
|
|
141
|
+
listen(el, 'keydown', event => {
|
|
142
|
+
if (event.key === 'Enter' || event.key === ' ') {
|
|
143
|
+
event.preventDefault();
|
|
144
|
+
callback(event);
|
|
145
|
+
}
|
|
146
|
+
}, true);
|
|
147
|
+
};
|
|
148
|
+
const el = html('section', {
|
|
149
|
+
class: 'graph-visual wb-schema-graph',
|
|
150
|
+
'aria-label': '数据库关系图'
|
|
151
|
+
});
|
|
152
|
+
const toolbar = html('div', {
|
|
153
|
+
class: 'graph-controls'
|
|
154
|
+
});
|
|
155
|
+
const toolbarRow = html('div', {
|
|
156
|
+
class: 'graph-toolbar sg-toolbar'
|
|
157
|
+
}),
|
|
158
|
+
scopes = html('div', {
|
|
159
|
+
class: 'graph-filters sg-modes',
|
|
160
|
+
role: 'group',
|
|
161
|
+
'aria-label': '关系图范围'
|
|
162
|
+
});
|
|
163
|
+
const pathScopes = html('div', {
|
|
164
|
+
class: 'path-modes graph-path-filters sg-modes',
|
|
165
|
+
role: 'group',
|
|
166
|
+
'aria-label': '依赖路径深度'
|
|
167
|
+
});
|
|
168
|
+
const controls = new Map();
|
|
169
|
+
const control = (container, action, title, callback) => {
|
|
170
|
+
const button = html('button', {
|
|
171
|
+
type: 'button',
|
|
172
|
+
'data-graph-action': action
|
|
173
|
+
}, title);
|
|
174
|
+
listen(button, 'click', callback);
|
|
175
|
+
container.append(button);
|
|
176
|
+
controls.set(action, button);
|
|
177
|
+
return button;
|
|
178
|
+
};
|
|
179
|
+
for (const [value, title] of [['all', '整库'], ['paths', '依赖路径'], ['issues', '待处理']]) {
|
|
180
|
+
control(scopes, value, title, () => {
|
|
181
|
+
currentMode = value;
|
|
182
|
+
if (value === 'paths') {
|
|
183
|
+
pathFocus = currentFocus;
|
|
184
|
+
}
|
|
185
|
+
draw();
|
|
186
|
+
});
|
|
187
|
+
}
|
|
188
|
+
controls.get('issues').disabled = !issues.length;
|
|
189
|
+
for (const [value, title] of [['complete', '完整路径'], ['upstream', '全部上游'], ['downstream', '全部下游'], ['neighbors', '仅相邻']]) {
|
|
190
|
+
control(pathScopes, value, title, () => {
|
|
191
|
+
currentMode = 'paths';
|
|
192
|
+
currentPath = value;
|
|
193
|
+
pathFocus = currentFocus;
|
|
194
|
+
draw();
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
const search = html('div', {
|
|
198
|
+
class: 'graph-search sg-search'
|
|
199
|
+
});
|
|
200
|
+
const searchIcon = svgElement('svg', {
|
|
201
|
+
class: 'icon',
|
|
202
|
+
viewBox: '0 0 24 24',
|
|
203
|
+
'aria-hidden': 'true'
|
|
204
|
+
});
|
|
205
|
+
searchIcon.append(svgElement('circle', {
|
|
206
|
+
cx: 10,
|
|
207
|
+
cy: 10,
|
|
208
|
+
r: 6
|
|
209
|
+
}), svgElement('path', {
|
|
210
|
+
d: 'm15 15 5 5'
|
|
211
|
+
}));
|
|
212
|
+
search.append(searchIcon);
|
|
213
|
+
const searchInput = html('input', {
|
|
214
|
+
type: 'search',
|
|
215
|
+
'data-graph-search': '',
|
|
216
|
+
'aria-label': '查找表或字段',
|
|
217
|
+
placeholder: '查找表或字段',
|
|
218
|
+
autocomplete: 'off'
|
|
219
|
+
});
|
|
220
|
+
searchInput.value = initialSearch;
|
|
221
|
+
const updateSearch = () => {
|
|
222
|
+
const value = searchInput.value.trim();
|
|
223
|
+
if (value === searchText) {
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
searchText = value;
|
|
227
|
+
draw();
|
|
228
|
+
};
|
|
229
|
+
listen(searchInput, 'compositionstart', () => {
|
|
230
|
+
composing = true;
|
|
231
|
+
});
|
|
232
|
+
listen(searchInput, 'compositionend', () => {
|
|
233
|
+
composing = false;
|
|
234
|
+
updateSearch();
|
|
235
|
+
});
|
|
236
|
+
listen(searchInput, 'input', event => {
|
|
237
|
+
if (!composing && !event.isComposing) {
|
|
238
|
+
updateSearch();
|
|
239
|
+
}
|
|
240
|
+
});
|
|
241
|
+
listen(searchInput, 'keydown', event => {
|
|
242
|
+
if (event.key !== 'Enter' || composing || event.isComposing || !searchText) {
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
const matches = searchMatches();
|
|
246
|
+
const exact = matches.find(node => node.id.toLocaleLowerCase() === searchText.toLocaleLowerCase());
|
|
247
|
+
if (exact || matches.length === 1) {
|
|
248
|
+
event.preventDefault();
|
|
249
|
+
readDependencies((exact || matches[0]).id, true);
|
|
250
|
+
} else if (matches.length) {
|
|
251
|
+
event.preventDefault();
|
|
252
|
+
searchMatchesList.querySelector('button')?.focus();
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
search.append(searchInput);
|
|
256
|
+
control(search, 'clear-search', '清空', () => {
|
|
257
|
+
composing = false;
|
|
258
|
+
searchInput.value = '';
|
|
259
|
+
updateSearch();
|
|
260
|
+
searchInput.focus();
|
|
261
|
+
});
|
|
262
|
+
toolbarRow.append(scopes, search);
|
|
263
|
+
const scopeNote = html('p', {
|
|
264
|
+
class: 'graph-scope-note sg-scope'
|
|
265
|
+
}),
|
|
266
|
+
tools = html('div', {
|
|
267
|
+
class: 'graph-meta sg-tools'
|
|
268
|
+
}),
|
|
269
|
+
summary = html('span');
|
|
270
|
+
const pathTitle = html('strong', {
|
|
271
|
+
class: 'mono'
|
|
272
|
+
}),
|
|
273
|
+
pathHeading = html('div', {
|
|
274
|
+
class: 'path-scope-heading'
|
|
275
|
+
});
|
|
276
|
+
const inspectedTable = html('span', {
|
|
277
|
+
class: 'graph-inspected-table'
|
|
278
|
+
});
|
|
279
|
+
const pathScope = html('div', {
|
|
280
|
+
class: 'path-scope'
|
|
281
|
+
});
|
|
282
|
+
pathHeading.append(pathTitle, inspectedTable, pathScopes);
|
|
283
|
+
pathScope.append(pathHeading);
|
|
284
|
+
const readingGuide = html('div', {
|
|
285
|
+
class: 'graph-reading-guide'
|
|
286
|
+
});
|
|
287
|
+
readingGuide.append(scopeNote);
|
|
288
|
+
control(readingGuide, 'focus-readable', '阅读当前表依赖', () => readDependencies(currentFocus));
|
|
289
|
+
const searchResults = html('section', {
|
|
290
|
+
class: 'graph-search-results',
|
|
291
|
+
'aria-label': '匹配的表'
|
|
292
|
+
});
|
|
293
|
+
const searchCount = html('p', {
|
|
294
|
+
class: 'graph-search-count',
|
|
295
|
+
role: 'status',
|
|
296
|
+
'aria-live': 'polite'
|
|
297
|
+
});
|
|
298
|
+
const searchMatchesList = html('ul', {
|
|
299
|
+
class: 'graph-search-matches'
|
|
300
|
+
});
|
|
301
|
+
searchResults.append(searchCount, searchMatchesList);
|
|
302
|
+
const zoomTools = html('div', {
|
|
303
|
+
class: 'graph-tools sg-modes'
|
|
304
|
+
});
|
|
305
|
+
control(zoomTools, 'zoom-out', '−', () => changeZoom(zoom / 1.25)).setAttribute('aria-label', '缩小关系图');
|
|
306
|
+
const zoomLabel = html('span', {
|
|
307
|
+
class: 'graph-zoom-label sg-zoom',
|
|
308
|
+
'data-graph-zoom': '',
|
|
309
|
+
'aria-live': 'polite',
|
|
310
|
+
title: '相对图中节点原始尺寸的实际比例;100% 为自然阅读尺寸'
|
|
311
|
+
}, '100%');
|
|
312
|
+
zoomTools.append(zoomLabel);
|
|
313
|
+
control(zoomTools, 'zoom-in', '+', () => changeZoom(zoom * 1.25)).setAttribute('aria-label', '放大关系图');
|
|
314
|
+
control(zoomTools, 'fit', '适应画布', () => applyViewport(true)).setAttribute('title', '缩放当前范围,让全部表与关系进入画布');
|
|
315
|
+
control(zoomTools, 'readable', '100% 阅读', () => {
|
|
316
|
+
actualSize = true;
|
|
317
|
+
applyViewport();
|
|
318
|
+
centerFocus();
|
|
319
|
+
}).setAttribute('title', '按自然尺寸阅读当前范围,并居中当前表;可拖动或滚动画布');
|
|
320
|
+
control(zoomTools, 'expand', expanded ? '收起' : '展开', () => {
|
|
321
|
+
expanded = !expanded;
|
|
322
|
+
canvas.classList.toggle('expanded', expanded);
|
|
323
|
+
controls.get('expand').textContent = expanded ? '收起' : '展开';
|
|
324
|
+
controls.get('expand').setAttribute('aria-pressed', String(expanded));
|
|
325
|
+
onExpand(expanded);
|
|
326
|
+
applyViewport();
|
|
327
|
+
}).setAttribute('aria-pressed', String(expanded));
|
|
328
|
+
tools.append(summary, zoomTools);
|
|
329
|
+
const legend = html('div', {
|
|
330
|
+
class: 'graph-state-legend',
|
|
331
|
+
'data-graph-legend': '',
|
|
332
|
+
role: 'group',
|
|
333
|
+
'aria-label': '关系图图例'
|
|
334
|
+
});
|
|
335
|
+
for (const [state, label] of [['chosen', '本次生成'], ['referenced', '仅引用'], ['unused', '其他表'], ['current', '当前查看']]) {
|
|
336
|
+
const item = html('span', {
|
|
337
|
+
class: 'graph-legend-item'
|
|
338
|
+
});
|
|
339
|
+
item.append(html('i', {
|
|
340
|
+
class: `graph-legend-swatch ${state}`,
|
|
341
|
+
'aria-hidden': 'true'
|
|
342
|
+
}), html('span', {}, label));
|
|
343
|
+
legend.append(item);
|
|
344
|
+
}
|
|
345
|
+
const canvas = html('div', {
|
|
346
|
+
class: 'graph-canvas sg-canvas',
|
|
347
|
+
'data-graph-canvas': '',
|
|
348
|
+
tabindex: '0',
|
|
349
|
+
'aria-label': '数据库关系画布,可用方向键平移'
|
|
350
|
+
});
|
|
351
|
+
canvas.classList.toggle('expanded', expanded);
|
|
352
|
+
const footer = html('div', {
|
|
353
|
+
class: 'graph-footer sg-footer'
|
|
354
|
+
});
|
|
355
|
+
footer.append(html('span', {}, `父表 → 子表 · 粗线突出当前表的关联路径,浅线为其他关系。${issues.length ? '橙色表示待处理项。' : ''}拖动画布平移,点连线查看列映射。`));
|
|
356
|
+
const labelControl = html('label'),
|
|
357
|
+
labelToggle = html('input', {
|
|
358
|
+
type: 'checkbox',
|
|
359
|
+
'data-graph-label-toggle': ''
|
|
360
|
+
});
|
|
361
|
+
labelToggle.checked = labels;
|
|
362
|
+
labelControl.append(labelToggle, html('span', {}, '显示字段名'));
|
|
363
|
+
footer.append(labelControl);
|
|
364
|
+
listen(labelToggle, 'change', () => {
|
|
365
|
+
const preserveViewport = actualSize || Math.abs(zoom - 1) >= .001;
|
|
366
|
+
labels = labelToggle.checked;
|
|
367
|
+
draw(preserveViewport);
|
|
368
|
+
});
|
|
369
|
+
toolbar.append(toolbarRow, pathScope, readingGuide, searchResults);
|
|
370
|
+
el.append(tools, legend, canvas, footer);
|
|
371
|
+
const problemTables = new Set(issues.map(issue => issue.table).filter(Boolean));
|
|
372
|
+
const problemEdges = new Set(data.edges.filter(edge => issues.some(issue => issue.edge_id === edge.id || issue.table === edge.target && (!issue.column || edge.targetColumns?.includes(issue.column)))).map(edge => edge.id));
|
|
373
|
+
function getView() {
|
|
374
|
+
return {
|
|
375
|
+
mode: currentMode,
|
|
376
|
+
pathMode: currentPath,
|
|
377
|
+
focus: currentFocus || null,
|
|
378
|
+
pathFocus: pathFocus || null,
|
|
379
|
+
search: searchInput.value,
|
|
380
|
+
labels,
|
|
381
|
+
zoom,
|
|
382
|
+
actualSize,
|
|
383
|
+
expanded,
|
|
384
|
+
scrollLeft: nonnegative(canvas.scrollLeft),
|
|
385
|
+
scrollTop: nonnegative(canvas.scrollTop),
|
|
386
|
+
edgeId: selectedEdge
|
|
387
|
+
};
|
|
388
|
+
}
|
|
389
|
+
function publishView() {
|
|
390
|
+
if (destroyed || pendingViewport || composing) {
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
const view = getView(),
|
|
394
|
+
serialized = JSON.stringify(view);
|
|
395
|
+
if (serialized === lastView) {
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
lastView = serialized;
|
|
399
|
+
onViewChange(view);
|
|
400
|
+
}
|
|
401
|
+
function searchMatches() {
|
|
402
|
+
if (!searchText) {
|
|
403
|
+
return [];
|
|
404
|
+
}
|
|
405
|
+
const query = searchText.toLocaleLowerCase();
|
|
406
|
+
return data.nodes.filter(node => [node.id, node.name, node.label, ...(tableByName.get(node.id)?.columns || []).map(column => column.name)].some(value => typeof value === 'string' && value.toLocaleLowerCase().includes(query)));
|
|
407
|
+
}
|
|
408
|
+
function projection() {
|
|
409
|
+
if (searchText) {
|
|
410
|
+
const matches = searchMatches();
|
|
411
|
+
const visible = new Set();
|
|
412
|
+
for (const match of matches) {
|
|
413
|
+
const paths = globalThis.SqlseedDependencyView.selectGraph(data, match.id, 'paths');
|
|
414
|
+
for (const node of paths.nodes) {
|
|
415
|
+
visible.add(node.id);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
return visibleGraph(visible);
|
|
419
|
+
}
|
|
420
|
+
if (currentMode === 'all') {
|
|
421
|
+
return data;
|
|
422
|
+
}
|
|
423
|
+
if (currentMode === 'paths') {
|
|
424
|
+
return globalThis.SqlseedDependencyView.selectGraph(data, pathFocus, currentPath === 'complete' ? 'paths' : currentPath);
|
|
425
|
+
}
|
|
426
|
+
const visible = new Set(problemTables);
|
|
427
|
+
for (const edge of data.edges) {
|
|
428
|
+
if (problemEdges.has(edge.id)) {
|
|
429
|
+
visible.add(edge.source);
|
|
430
|
+
visible.add(edge.target);
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
return visibleGraph(visible);
|
|
434
|
+
}
|
|
435
|
+
function highlightFocus() {
|
|
436
|
+
// Highlight the complete dependency closure within the existing projection.
|
|
437
|
+
// This includes sources needed by child branches without moving any nodes.
|
|
438
|
+
const paths = globalThis.SqlseedDependencyView.selectGraph(data, currentFocus, 'paths');
|
|
439
|
+
const relatedNodes = new Set(paths.nodes.map(node => node.id));
|
|
440
|
+
relatedEdges = new Set(paths.edges.map(edge => edge.id));
|
|
441
|
+
for (const node of canvas.querySelectorAll('[data-graph-node]')) {
|
|
442
|
+
const id = node.dataset.graphNode,
|
|
443
|
+
selected = id === currentFocus;
|
|
444
|
+
node.dataset.current = String(selected);
|
|
445
|
+
node.setAttribute('aria-pressed', String(selected));
|
|
446
|
+
node.classList.toggle('focused', selected);
|
|
447
|
+
node.classList.toggle('path-related', relatedNodes.has(id));
|
|
448
|
+
node.classList.toggle('path-unrelated', !relatedNodes.has(id));
|
|
449
|
+
}
|
|
450
|
+
highlightEdges();
|
|
451
|
+
updateContext();
|
|
452
|
+
}
|
|
453
|
+
function highlightEdges() {
|
|
454
|
+
for (const element of canvas.querySelectorAll('[data-graph-edge],[data-graph-label]')) {
|
|
455
|
+
const id = element.dataset.graphEdge || element.dataset.graphLabel;
|
|
456
|
+
const selected = id === selectedEdge,
|
|
457
|
+
related = relatedEdges.has(id);
|
|
458
|
+
element.classList.toggle('focused', selected);
|
|
459
|
+
element.setAttribute('aria-pressed', String(selected));
|
|
460
|
+
element.classList.toggle('path-related', related);
|
|
461
|
+
element.classList.toggle('path-unrelated', !related);
|
|
462
|
+
let marker;
|
|
463
|
+
if (element.classList.contains('problem')) {
|
|
464
|
+
marker = 'problem';
|
|
465
|
+
} else if (selected) {
|
|
466
|
+
marker = 'focused';
|
|
467
|
+
} else if (related) {
|
|
468
|
+
marker = 'related';
|
|
469
|
+
} else {
|
|
470
|
+
marker = 'normal';
|
|
471
|
+
}
|
|
472
|
+
element.querySelector('.edge-line')?.setAttribute('marker-end', `url(#${markerId}-${marker})`);
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
function updateContext() {
|
|
476
|
+
pathTitle.textContent = pathFocus ? `${pathFocus} 的依赖路径` : '未选择表';
|
|
477
|
+
inspectedTable.textContent = currentFocus ? `当前查看:${currentFocus}` : '';
|
|
478
|
+
inspectedTable.hidden = !currentFocus || currentFocus === pathFocus;
|
|
479
|
+
const notes = {
|
|
480
|
+
complete: '保留下游所需的全部上游来源。',
|
|
481
|
+
upstream: '显示当前表及所有上游来源。',
|
|
482
|
+
downstream: '显示当前表及所有下游影响。',
|
|
483
|
+
neighbors: '仅显示直接相邻关系,不代表完整依赖路径。'
|
|
484
|
+
};
|
|
485
|
+
if (searchText) {
|
|
486
|
+
scopeNote.textContent = `搜索“${searchText}”匹配的表及完整依赖路径。选择结果可聚焦阅读。`;
|
|
487
|
+
} else if (currentMode === 'paths') {
|
|
488
|
+
scopeNote.textContent = `${pathFocus || '未选择表'} · ${notes[currentPath]}`;
|
|
489
|
+
} else if (currentMode === 'issues') {
|
|
490
|
+
scopeNote.textContent = '显示检查结果涉及的表及引用关系。';
|
|
491
|
+
} else {
|
|
492
|
+
scopeNote.textContent = '整库总览 · 总览用于看关系分布,阅读字段或路径请聚焦表。';
|
|
493
|
+
}
|
|
494
|
+
const read = controls.get('focus-readable');
|
|
495
|
+
read.disabled = !nodeIds.has(currentFocus);
|
|
496
|
+
read.setAttribute('aria-label', `以 100% 阅读 ${currentFocus || '当前表'} 的完整依赖`);
|
|
497
|
+
}
|
|
498
|
+
function renderSearchResults() {
|
|
499
|
+
searchResults.hidden = !searchText;
|
|
500
|
+
searchMatchesList.replaceChildren();
|
|
501
|
+
if (!searchText) {
|
|
502
|
+
searchCount.textContent = '';
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
const matches = searchMatches(),
|
|
506
|
+
query = searchText.toLocaleLowerCase();
|
|
507
|
+
searchCount.textContent = `匹配 ${matches.length} 张表 · 选择后以 100% 阅读完整依赖`;
|
|
508
|
+
for (const node of matches) {
|
|
509
|
+
const item = html('li'),
|
|
510
|
+
target = html('button', {
|
|
511
|
+
type: 'button',
|
|
512
|
+
class: 'graph-search-match',
|
|
513
|
+
'data-graph-match': node.id,
|
|
514
|
+
'aria-label': `定位 ${node.id} 并阅读完整依赖`
|
|
515
|
+
});
|
|
516
|
+
target.append(html('strong', {
|
|
517
|
+
class: 'mono'
|
|
518
|
+
}, node.id));
|
|
519
|
+
const columns = (tableByName.get(node.id)?.columns || []).filter(column => column.name.toLocaleLowerCase().includes(query));
|
|
520
|
+
if (columns.length) {
|
|
521
|
+
target.append(html('small', {}, `匹配字段:${columns.map(column => column.name).join('、')}`));
|
|
522
|
+
}
|
|
523
|
+
listen(target, 'click', () => readDependencies(node.id, true), true);
|
|
524
|
+
item.append(target);
|
|
525
|
+
searchMatchesList.append(item);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
function readDependencies(id, select = false) {
|
|
529
|
+
if (destroyed || !nodeIds.has(id)) {
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
if (select && (onSelect(id) === false || destroyed)) {
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
535
|
+
currentFocus = id;
|
|
536
|
+
pathFocus = id;
|
|
537
|
+
currentMode = 'paths';
|
|
538
|
+
currentPath = 'complete';
|
|
539
|
+
selectedEdge = null;
|
|
540
|
+
searchText = '';
|
|
541
|
+
searchInput.value = '';
|
|
542
|
+
composing = false;
|
|
543
|
+
draw();
|
|
544
|
+
actualSize = true;
|
|
545
|
+
applyViewport();
|
|
546
|
+
centerFocus();
|
|
547
|
+
const node = [...canvas.querySelectorAll('[data-graph-node]')].find(item => item.dataset.graphNode === id);
|
|
548
|
+
node?.focus?.({
|
|
549
|
+
preventScroll: true
|
|
550
|
+
});
|
|
551
|
+
publishView();
|
|
552
|
+
}
|
|
553
|
+
function highlightEdge(id) {
|
|
554
|
+
selectedEdge = id;
|
|
555
|
+
highlightEdges();
|
|
556
|
+
const edge = data.edges.find(item => item.id === id);
|
|
557
|
+
if (edge) {
|
|
558
|
+
onEdge(edge);
|
|
559
|
+
}
|
|
560
|
+
publishView();
|
|
561
|
+
}
|
|
562
|
+
function draw(preserveViewport = false) {
|
|
563
|
+
if (destroyed) {
|
|
564
|
+
return;
|
|
565
|
+
}
|
|
566
|
+
const previousFrame = preserveViewport ? frame : null;
|
|
567
|
+
drawingListeners.splice(0).forEach(remove => remove());
|
|
568
|
+
const visible = projection();
|
|
569
|
+
for (const value of ['all', 'paths', 'issues']) {
|
|
570
|
+
controls.get(value).setAttribute('aria-pressed', String(currentMode === value));
|
|
571
|
+
}
|
|
572
|
+
pathScope.hidden = currentMode !== 'paths' || Boolean(searchText);
|
|
573
|
+
for (const value of ['complete', 'upstream', 'downstream', 'neighbors']) {
|
|
574
|
+
controls.get(value).setAttribute('aria-pressed', String(currentPath === value));
|
|
575
|
+
}
|
|
576
|
+
updateContext();
|
|
577
|
+
renderSearchResults();
|
|
578
|
+
controls.get('clear-search').disabled = !searchText;
|
|
579
|
+
summary.textContent = `显示 ${visible.nodes.length} / ${data.nodes.length} 张表 · ${visible.edges.length} 条关系`;
|
|
580
|
+
canvas.replaceChildren();
|
|
581
|
+
frame = null;
|
|
582
|
+
if (!visible.nodes.length) {
|
|
583
|
+
svg = null;
|
|
584
|
+
stage = null;
|
|
585
|
+
layout = null;
|
|
586
|
+
zoomLabel.textContent = '—';
|
|
587
|
+
for (const action of ['zoom-in', 'zoom-out', 'fit', 'readable']) {
|
|
588
|
+
controls.get(action).disabled = true;
|
|
589
|
+
}
|
|
590
|
+
const emptyGraphHint = () => {
|
|
591
|
+
if (searchText) {
|
|
592
|
+
return '没有匹配的表或字段,可清空搜索或换一个关键词。';
|
|
593
|
+
} else if (currentMode === 'all') {
|
|
594
|
+
return '当前数据库没有可展示的表。';
|
|
595
|
+
} else {
|
|
596
|
+
return '当前范围没有匹配的表,可切换整库查看。';
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
canvas.append(html('div', {
|
|
600
|
+
class: 'graph-empty sg-empty'
|
|
601
|
+
}, emptyGraphHint()));
|
|
602
|
+
pendingViewport = null;
|
|
603
|
+
publishView();
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
layout = globalThis.SqlseedGraphLayout.layoutGraph(visible.nodes, visible.edges);
|
|
607
|
+
layout = globalThis.SqlseedGraphLayout.placeEdgeLabels(layout, labels ? visible.edges.map(edge => ({
|
|
608
|
+
edgeId: edge.id,
|
|
609
|
+
text: relationText(edge)
|
|
610
|
+
})) : []);
|
|
611
|
+
stage = html('div', {
|
|
612
|
+
class: 'graph-stage sg-stage'
|
|
613
|
+
});
|
|
614
|
+
svg = svgElement('svg', {
|
|
615
|
+
class: 'schema-graph',
|
|
616
|
+
viewBox: `0 0 ${layout.width} ${layout.height}`,
|
|
617
|
+
role: 'group',
|
|
618
|
+
'aria-label': '当前范围内的表与外键关系'
|
|
619
|
+
});
|
|
620
|
+
const defs = svgElement('defs');
|
|
621
|
+
for (const [state, color] of [['normal', '#71978a'], ['related', '#477f6a'], ['focused', '#2573a4'], ['problem', '#ad641c']]) {
|
|
622
|
+
const marker = svgElement('marker', {
|
|
623
|
+
id: `${markerId}-${state}`,
|
|
624
|
+
viewBox: '0 0 10 10',
|
|
625
|
+
refX: 9,
|
|
626
|
+
refY: 5,
|
|
627
|
+
markerWidth: 6,
|
|
628
|
+
markerHeight: 6,
|
|
629
|
+
orient: 'auto'
|
|
630
|
+
});
|
|
631
|
+
marker.append(svgElement('path', {
|
|
632
|
+
d: 'M0 0L10 5L0 10Z',
|
|
633
|
+
fill: color
|
|
634
|
+
}));
|
|
635
|
+
defs.append(marker);
|
|
636
|
+
}
|
|
637
|
+
svg.append(defs);
|
|
638
|
+
drawRelationships();
|
|
639
|
+
for (const node of layout.nodes) {
|
|
640
|
+
drawNode(node);
|
|
641
|
+
}
|
|
642
|
+
for (const label of layout.labels) {
|
|
643
|
+
drawLabel(label);
|
|
644
|
+
}
|
|
645
|
+
stage.append(svg);
|
|
646
|
+
canvas.append(stage);
|
|
647
|
+
highlightFocus();
|
|
648
|
+
if (previousFrame) {
|
|
649
|
+
frame = previousFrame;
|
|
650
|
+
const fitted = graphViewport(layout, canvas.clientWidth || 800, canvas.clientHeight || 420);
|
|
651
|
+
zoom = previousFrame.scale / fitted.fitScale;
|
|
652
|
+
actualSize = false;
|
|
653
|
+
applyViewport();
|
|
654
|
+
} else {
|
|
655
|
+
applyViewport(true);
|
|
656
|
+
}
|
|
657
|
+
function drawNode(node) {
|
|
658
|
+
const table = tableByName.get(node.id),
|
|
659
|
+
title = node.label || node.name || node.id;
|
|
660
|
+
const selected = node.selected === true,
|
|
661
|
+
referenced = !selected && node.referenced === true;
|
|
662
|
+
const group = svgElement('g', {
|
|
663
|
+
class: `graph-node sg-node${selected ? ' chosen' : ''}${referenced ? ' referenced' : ''}${problemTables.has(node.id) ? ' blocked' : ''}`,
|
|
664
|
+
'data-graph-node': node.id,
|
|
665
|
+
'data-selected': String(selected),
|
|
666
|
+
'data-referenced': String(referenced),
|
|
667
|
+
'data-issue': String(problemTables.has(node.id)),
|
|
668
|
+
role: 'button',
|
|
669
|
+
tabindex: 0,
|
|
670
|
+
'aria-label': `查看 ${title} 的字段与关系`
|
|
671
|
+
});
|
|
672
|
+
const titleText = nodeTitle(title);
|
|
673
|
+
const existing = table?.row_count != null ? `现有 ${table.row_count} 行` : '';
|
|
674
|
+
const metadata = nodeMetadata();
|
|
675
|
+
const state = node.readonly || selected || referenced ? metadata : `其他表,不参与本次生成;${metadata}`;
|
|
676
|
+
group.setAttribute('aria-label', `查看 ${title} 的字段与关系;${state}`);
|
|
677
|
+
group.append(svgElement('title', {}, title), svgElement('rect', {
|
|
678
|
+
class: 'graph-node-body',
|
|
679
|
+
x: node.x,
|
|
680
|
+
y: node.y,
|
|
681
|
+
width: node.width,
|
|
682
|
+
height: node.height,
|
|
683
|
+
rx: 8
|
|
684
|
+
}), svgElement('rect', {
|
|
685
|
+
class: 'graph-focus-ring',
|
|
686
|
+
x: node.x - 4,
|
|
687
|
+
y: node.y - 4,
|
|
688
|
+
width: node.width + 8,
|
|
689
|
+
height: node.height + 8,
|
|
690
|
+
rx: 12,
|
|
691
|
+
'aria-hidden': 'true'
|
|
692
|
+
}), svgElement('text', {
|
|
693
|
+
class: 'graph-table-name graph-node-title sg-node-title',
|
|
694
|
+
x: node.x + 16,
|
|
695
|
+
y: node.y + 29
|
|
696
|
+
}, titleText), svgElement('text', {
|
|
697
|
+
class: 'graph-table-state graph-node-meta sg-node-meta',
|
|
698
|
+
x: node.x + 16,
|
|
699
|
+
y: node.y + 53
|
|
700
|
+
}, metadata));
|
|
701
|
+
activate(group, () => {
|
|
702
|
+
if (onSelect(node.id) === false || destroyed) {
|
|
703
|
+
return;
|
|
704
|
+
}
|
|
705
|
+
currentFocus = node.id;
|
|
706
|
+
selectedEdge = null;
|
|
707
|
+
highlightFocus();
|
|
708
|
+
publishView();
|
|
709
|
+
});
|
|
710
|
+
svg.append(group);
|
|
711
|
+
function nodeMetadata() {
|
|
712
|
+
let metadata;
|
|
713
|
+
if (node.readonly) {
|
|
714
|
+
metadata = '外部引用 · 只读';
|
|
715
|
+
} else if (selected) {
|
|
716
|
+
metadata = `本次生成${node.count != null ? " " + node.count + " 行" : ''}`;
|
|
717
|
+
} else if (referenced) {
|
|
718
|
+
metadata = `仅引用${existing ? " · " + existing : ''}`;
|
|
719
|
+
} else {
|
|
720
|
+
metadata = `${table?.columns?.length || 0} 个字段${existing ? " · " + existing : ''}`;
|
|
721
|
+
}
|
|
722
|
+
return metadata;
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
function drawRelationships() {
|
|
726
|
+
for (const edge of layout.edges) {
|
|
727
|
+
const group = svgElement('g', {
|
|
728
|
+
class: `graph-edge sg-edge${problemEdges.has(edge.id) ? ' problem' : ''}${selectedEdge === edge.id ? ' focused' : ''}`,
|
|
729
|
+
'data-graph-edge': edge.id,
|
|
730
|
+
'data-issue': String(problemEdges.has(edge.id)),
|
|
731
|
+
role: 'button',
|
|
732
|
+
tabindex: 0,
|
|
733
|
+
'aria-label': relationText(edge)
|
|
734
|
+
});
|
|
735
|
+
group.append(svgElement('title', {}, relationText(edge)), svgElement('path', {
|
|
736
|
+
class: 'edge-hit sg-hit',
|
|
737
|
+
d: edge.path
|
|
738
|
+
}), svgElement('path', {
|
|
739
|
+
class: 'edge-line sg-route',
|
|
740
|
+
d: edge.path
|
|
741
|
+
}));
|
|
742
|
+
activate(group, () => highlightEdge(edge.id));
|
|
743
|
+
svg.append(group);
|
|
744
|
+
}
|
|
745
|
+
const leaders = svgElement('g', {
|
|
746
|
+
class: 'graph-label-leaders',
|
|
747
|
+
'aria-hidden': 'true'
|
|
748
|
+
});
|
|
749
|
+
for (const label of layout.labels) {
|
|
750
|
+
if (label.leaderPath) {
|
|
751
|
+
leaders.append(svgElement('path', {
|
|
752
|
+
class: 'sg-leader',
|
|
753
|
+
d: label.leaderPath
|
|
754
|
+
}));
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
svg.append(leaders);
|
|
758
|
+
}
|
|
759
|
+
function drawLabel(label) {
|
|
760
|
+
const group = svgElement('g', {
|
|
761
|
+
class: `graph-label sg-label${problemEdges.has(label.edgeId) ? ' problem' : ''}${selectedEdge === label.edgeId ? ' focused' : ''}`,
|
|
762
|
+
'data-graph-label': label.edgeId,
|
|
763
|
+
role: 'button',
|
|
764
|
+
tabindex: 0,
|
|
765
|
+
'aria-label': label.text
|
|
766
|
+
});
|
|
767
|
+
group.append(svgElement('title', {}, label.text), svgElement('rect', {
|
|
768
|
+
x: label.x,
|
|
769
|
+
y: label.y,
|
|
770
|
+
width: label.width,
|
|
771
|
+
height: label.height,
|
|
772
|
+
rx: 4
|
|
773
|
+
}));
|
|
774
|
+
const text = svgElement('text');
|
|
775
|
+
label.lines.forEach((line, index) => text.append(svgElement('tspan', {
|
|
776
|
+
x: label.textX,
|
|
777
|
+
y: label.textY + index * label.lineHeight
|
|
778
|
+
}, line)));
|
|
779
|
+
group.append(text);
|
|
780
|
+
activate(group, () => highlightEdge(label.edgeId));
|
|
781
|
+
svg.append(group);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
function applyViewport(reset = false) {
|
|
785
|
+
if (destroyed || !layout || !svg || !stage) {
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
const width = canvas.clientWidth || 800,
|
|
789
|
+
height = canvas.clientHeight || 420;
|
|
790
|
+
const old = frame;
|
|
791
|
+
const centerX = old ? ((canvas.scrollLeft || 0) + old.viewportWidth / 2 - old.left) / old.scale : layout.width / 2;
|
|
792
|
+
const centerY = old ? ((canvas.scrollTop || 0) + old.viewportHeight / 2 - old.top) / old.scale : layout.height / 2;
|
|
793
|
+
if (pendingViewport) {
|
|
794
|
+
zoom = pendingViewport.zoom;
|
|
795
|
+
actualSize = pendingViewport.actualSize;
|
|
796
|
+
} else if (reset) {
|
|
797
|
+
zoom = 1;
|
|
798
|
+
actualSize = false;
|
|
799
|
+
}
|
|
800
|
+
frame = graphViewport(layout, width, height, zoom, actualSize);
|
|
801
|
+
zoom = frame.zoom;
|
|
802
|
+
Object.assign(stage.style, {
|
|
803
|
+
width: frame.stageWidth + 'px',
|
|
804
|
+
height: frame.stageHeight + 'px'
|
|
805
|
+
});
|
|
806
|
+
svg.setAttribute('width', frame.svgWidth);
|
|
807
|
+
svg.setAttribute('height', frame.svgHeight);
|
|
808
|
+
Object.assign(svg.style, {
|
|
809
|
+
width: frame.svgWidth + 'px',
|
|
810
|
+
height: frame.svgHeight + 'px',
|
|
811
|
+
left: frame.left + 'px',
|
|
812
|
+
top: frame.top + 'px'
|
|
813
|
+
});
|
|
814
|
+
zoomLabel.textContent = Math.round(frame.scale * 100) + '%';
|
|
815
|
+
zoomLabel.setAttribute('aria-label', `图形实际缩放 ${zoomLabel.textContent}`);
|
|
816
|
+
for (const action of ['zoom-in', 'zoom-out', 'fit']) {
|
|
817
|
+
controls.get(action).disabled = false;
|
|
818
|
+
}
|
|
819
|
+
controls.get('fit').setAttribute('aria-pressed', String(!actualSize && Math.abs(zoom - 1) < .001));
|
|
820
|
+
controls.get('readable').disabled = Math.abs(frame.scale - 1) < .001;
|
|
821
|
+
if (pendingViewport) {
|
|
822
|
+
canvas.scrollLeft = pendingViewport.scrollLeft;
|
|
823
|
+
} else if (reset) {
|
|
824
|
+
canvas.scrollLeft = 0;
|
|
825
|
+
} else {
|
|
826
|
+
canvas.scrollLeft = Math.max(0, frame.left + centerX * frame.scale - width / 2);
|
|
827
|
+
}
|
|
828
|
+
if (pendingViewport) {
|
|
829
|
+
canvas.scrollTop = pendingViewport.scrollTop;
|
|
830
|
+
} else if (reset) {
|
|
831
|
+
canvas.scrollTop = 0;
|
|
832
|
+
} else {
|
|
833
|
+
canvas.scrollTop = Math.max(0, frame.top + centerY * frame.scale - height / 2);
|
|
834
|
+
} // A new graph may be built before its host is attached. Restore again on
|
|
835
|
+
// the first measured resize instead of committing fallback dimensions.
|
|
836
|
+
if (canvas.clientWidth > 0 && canvas.clientHeight > 0) {
|
|
837
|
+
pendingViewport = null;
|
|
838
|
+
}
|
|
839
|
+
publishView();
|
|
840
|
+
}
|
|
841
|
+
function centerFocus() {
|
|
842
|
+
const node = layout?.nodes.find(item => item.id === currentFocus);
|
|
843
|
+
if (!node || !frame) {
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
canvas.scrollLeft = Math.max(0, frame.left + (node.x + node.width / 2) * frame.scale - frame.viewportWidth / 2);
|
|
847
|
+
canvas.scrollTop = Math.max(0, frame.top + (node.y + node.height / 2) * frame.scale - frame.viewportHeight / 2);
|
|
848
|
+
publishView();
|
|
849
|
+
}
|
|
850
|
+
function changeZoom(value) {
|
|
851
|
+
zoom = Math.min(Math.max(8, 2 / (frame?.fitScale || 1)), Math.max(.25, value));
|
|
852
|
+
actualSize = false;
|
|
853
|
+
applyViewport();
|
|
854
|
+
}
|
|
855
|
+
listen(canvas, 'pointerdown', event => {
|
|
856
|
+
if (event.button !== 0 || event.target.closest('[data-graph-node],[data-graph-edge],[data-graph-label]')) {
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
drag = {
|
|
860
|
+
x: event.clientX,
|
|
861
|
+
y: event.clientY,
|
|
862
|
+
left: canvas.scrollLeft || 0,
|
|
863
|
+
top: canvas.scrollTop || 0
|
|
864
|
+
};
|
|
865
|
+
canvas.setPointerCapture?.(event.pointerId);
|
|
866
|
+
canvas.classList.add('dragging');
|
|
867
|
+
});
|
|
868
|
+
listen(canvas, 'pointermove', event => {
|
|
869
|
+
if (drag) {
|
|
870
|
+
canvas.scrollLeft = drag.left + drag.x - event.clientX;
|
|
871
|
+
canvas.scrollTop = drag.top + drag.y - event.clientY;
|
|
872
|
+
publishView();
|
|
873
|
+
}
|
|
874
|
+
});
|
|
875
|
+
const stopDrag = () => {
|
|
876
|
+
drag = null;
|
|
877
|
+
canvas.classList.remove('dragging');
|
|
878
|
+
};
|
|
879
|
+
listen(canvas, 'pointerup', stopDrag);
|
|
880
|
+
listen(canvas, 'pointercancel', stopDrag);
|
|
881
|
+
listen(canvas, 'keydown', event => {
|
|
882
|
+
if (event.target !== canvas) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
const delta = {
|
|
886
|
+
ArrowLeft: [-60, 0],
|
|
887
|
+
ArrowRight: [60, 0],
|
|
888
|
+
ArrowUp: [0, -60],
|
|
889
|
+
ArrowDown: [0, 60]
|
|
890
|
+
}[event.key];
|
|
891
|
+
if (delta) {
|
|
892
|
+
event.preventDefault();
|
|
893
|
+
canvas.scrollLeft += delta[0];
|
|
894
|
+
canvas.scrollTop += delta[1];
|
|
895
|
+
publishView();
|
|
896
|
+
}
|
|
897
|
+
});
|
|
898
|
+
listen(canvas, 'scroll', publishView);
|
|
899
|
+
if (expanded) {
|
|
900
|
+
onExpand(true);
|
|
901
|
+
}
|
|
902
|
+
draw();
|
|
903
|
+
const resizeObserver = typeof ResizeObserver === 'undefined' ? null : new ResizeObserver(() => applyViewport());
|
|
904
|
+
resizeObserver?.observe(canvas);
|
|
905
|
+
function focusTable(id) {
|
|
906
|
+
if (destroyed || !nodeIds.has(id)) {
|
|
907
|
+
return false;
|
|
908
|
+
}
|
|
909
|
+
const visible = projection().nodes.some(node => node.id === id),
|
|
910
|
+
hadSearch = Boolean(searchText);
|
|
911
|
+
currentFocus = id;
|
|
912
|
+
selectedEdge = null;
|
|
913
|
+
searchText = '';
|
|
914
|
+
searchInput.value = '';
|
|
915
|
+
composing = false;
|
|
916
|
+
if (!visible) {
|
|
917
|
+
currentMode = 'paths';
|
|
918
|
+
currentPath = 'complete';
|
|
919
|
+
pathFocus = id;
|
|
920
|
+
}
|
|
921
|
+
if (!visible || hadSearch) {
|
|
922
|
+
draw();
|
|
923
|
+
} else {
|
|
924
|
+
highlightFocus();
|
|
925
|
+
}
|
|
926
|
+
centerFocus();
|
|
927
|
+
const node = [...canvas.querySelectorAll('[data-graph-node]')].find(item => item.dataset.graphNode === id);
|
|
928
|
+
node?.focus?.({
|
|
929
|
+
preventScroll: true
|
|
930
|
+
});
|
|
931
|
+
return true;
|
|
932
|
+
}
|
|
933
|
+
return {
|
|
934
|
+
el,
|
|
935
|
+
toolbar,
|
|
936
|
+
getView,
|
|
937
|
+
focusTable,
|
|
938
|
+
destroy() {
|
|
939
|
+
destroyed = true;
|
|
940
|
+
resizeObserver?.disconnect();
|
|
941
|
+
drawingListeners.splice(0).forEach(remove => remove());
|
|
942
|
+
listeners.splice(0).forEach(remove => remove());
|
|
943
|
+
}
|
|
944
|
+
};
|
|
945
|
+
function restoredMode() {
|
|
946
|
+
let currentMode;
|
|
947
|
+
if (['all', 'paths', 'issues'].includes(restored.mode)) {
|
|
948
|
+
currentMode = restored.mode;
|
|
949
|
+
} else if (['all', 'paths', 'issues'].includes(mode)) {
|
|
950
|
+
currentMode = mode;
|
|
951
|
+
} else {
|
|
952
|
+
currentMode = 'all';
|
|
953
|
+
}
|
|
954
|
+
return currentMode;
|
|
955
|
+
}
|
|
956
|
+
function initialViewport() {
|
|
957
|
+
let pendingViewport;
|
|
958
|
+
if (initialView) {
|
|
959
|
+
pendingViewport = {
|
|
960
|
+
zoom: Number.isFinite(restored.zoom) && restored.zoom > 0 ? restored.zoom : 1,
|
|
961
|
+
actualSize: restored.actualSize === true,
|
|
962
|
+
scrollLeft: nonnegative(restored.scrollLeft),
|
|
963
|
+
scrollTop: nonnegative(restored.scrollTop)
|
|
964
|
+
};
|
|
965
|
+
} else {
|
|
966
|
+
pendingViewport = null;
|
|
967
|
+
}
|
|
968
|
+
return pendingViewport;
|
|
969
|
+
}
|
|
970
|
+
}
|