sqlite-hub 1.0.0 → 1.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/README.md +47 -33
- package/bin/sqlite-hub.js +127 -47
- package/frontend/js/api.js +6 -0
- package/frontend/js/app.js +199 -34
- package/frontend/js/components/modal.js +17 -1
- package/frontend/js/components/queryChartRenderer.js +28 -3
- package/frontend/js/components/queryEditor.js +20 -24
- package/frontend/js/components/queryHistoryDetail.js +1 -1
- package/frontend/js/components/queryHistoryHeader.js +48 -0
- package/frontend/js/components/queryHistoryList.js +132 -0
- package/frontend/js/components/queryHistoryPanel.js +72 -136
- package/frontend/js/components/structureGraph.js +699 -89
- package/frontend/js/components/tableDesignerEditor.js +3 -5
- package/frontend/js/store.js +73 -7
- package/frontend/js/utils/exportFilenames.js +3 -1
- package/frontend/js/views/charts.js +320 -169
- package/frontend/js/views/connections.js +59 -64
- package/frontend/js/views/data.js +12 -20
- package/frontend/js/views/editor.js +0 -2
- package/frontend/js/views/settings.js +78 -0
- package/frontend/js/views/structure.js +27 -13
- package/frontend/styles/components.css +155 -0
- package/frontend/styles/structure-graph.css +140 -35
- package/frontend/styles/tailwind.generated.css +1 -1
- package/frontend/styles/views.css +12 -6
- package/package.json +3 -2
- package/server/routes/export.js +89 -22
- package/server/routes/externalApi.js +84 -2
- package/server/routes/settings.js +37 -28
- package/server/services/appInfoService.js +215 -0
- package/server/services/databaseCommandService.js +50 -6
- package/server/services/sqlite/exportService.js +307 -22
- package/tests/api-token-auth.test.js +110 -1
- package/tests/cli-args.test.js +16 -3
- package/tests/database-command-service.test.js +39 -2
- package/tests/export-blob.test.js +54 -1
- package/tests/export-filenames.test.js +4 -0
- package/tests/settings-api-tokens-route.test.js +22 -1
- package/tests/settings-metadata.test.js +99 -1
- package/tests/settings-view.test.js +28 -0
|
@@ -7,6 +7,24 @@ let currentGraph = null;
|
|
|
7
7
|
let mountVersion = 0;
|
|
8
8
|
let persistedGraphState = null;
|
|
9
9
|
const STRUCTURE_INSPECTOR_VISIBLE_STORAGE_KEY = 'sqlite_hub_structure_inspector_visible';
|
|
10
|
+
const STRUCTURE_GRAPH_STATE_STORAGE_PREFIX = 'sqlite_hub_structure_graph_state';
|
|
11
|
+
const STRUCTURE_GRAPH_STATE_STORAGE_VERSION = 1;
|
|
12
|
+
|
|
13
|
+
function getHash(input) {
|
|
14
|
+
let hash = 2166136261;
|
|
15
|
+
const text = String(input ?? '');
|
|
16
|
+
|
|
17
|
+
for (let index = 0; index < text.length; index += 1) {
|
|
18
|
+
hash ^= text.charCodeAt(index);
|
|
19
|
+
hash = Math.imul(hash, 16777619);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return (hash >>> 0).toString(36);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function getGraphStateStorageKey(schemaSignature) {
|
|
26
|
+
return `${STRUCTURE_GRAPH_STATE_STORAGE_PREFIX}:${getHash(schemaSignature)}`;
|
|
27
|
+
}
|
|
10
28
|
|
|
11
29
|
function readStoredInspectorHidden() {
|
|
12
30
|
try {
|
|
@@ -53,19 +71,118 @@ function getSchemaSignature(schema) {
|
|
|
53
71
|
);
|
|
54
72
|
}
|
|
55
73
|
|
|
74
|
+
function normalizeGraphNodePositions(nodePositions) {
|
|
75
|
+
if (!nodePositions || typeof nodePositions !== 'object') {
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const normalized = {};
|
|
80
|
+
|
|
81
|
+
Object.entries(nodePositions).forEach(([nodeId, position]) => {
|
|
82
|
+
if (!position || typeof position !== 'object') {
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const x = Number(position.x);
|
|
87
|
+
const y = Number(position.y);
|
|
88
|
+
|
|
89
|
+
if (!Number.isFinite(x) || !Number.isFinite(y)) {
|
|
90
|
+
return;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
normalized[nodeId] = { x, y };
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
return Object.keys(normalized).length ? normalized : null;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function normalizeGraphStatePayload(payload, schemaSignature) {
|
|
100
|
+
if (
|
|
101
|
+
!payload ||
|
|
102
|
+
typeof payload !== 'object' ||
|
|
103
|
+
payload.version !== STRUCTURE_GRAPH_STATE_STORAGE_VERSION ||
|
|
104
|
+
payload.schemaSignature !== schemaSignature
|
|
105
|
+
) {
|
|
106
|
+
return null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const nodePositions = normalizeGraphNodePositions(payload.nodePositions);
|
|
110
|
+
|
|
111
|
+
if (!nodePositions) {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const zoom = Number(payload.zoom);
|
|
116
|
+
const panX = Number(payload.pan?.x);
|
|
117
|
+
const panY = Number(payload.pan?.y);
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
schemaSignature,
|
|
121
|
+
nodePositions,
|
|
122
|
+
pan: Number.isFinite(panX) && Number.isFinite(panY) ? { x: panX, y: panY } : null,
|
|
123
|
+
zoom: Number.isFinite(zoom) ? zoom : null,
|
|
124
|
+
inspectorHidden: Boolean(payload.inspectorHidden),
|
|
125
|
+
selectedTableName: typeof payload.selectedTableName === 'string' ? payload.selectedTableName : null,
|
|
126
|
+
layoutVariant: Number.isFinite(Number(payload.layoutVariant)) ? Number(payload.layoutVariant) : 0,
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readStoredGraphState(schemaSignature) {
|
|
131
|
+
try {
|
|
132
|
+
const rawValue = globalThis.localStorage?.getItem(getGraphStateStorageKey(schemaSignature));
|
|
133
|
+
|
|
134
|
+
if (!rawValue) {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return normalizeGraphStatePayload(JSON.parse(rawValue), schemaSignature);
|
|
139
|
+
} catch {
|
|
140
|
+
return null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function writeStoredGraphState(graphState) {
|
|
145
|
+
if (!graphState?.schemaSignature || !graphState.nodePositions) {
|
|
146
|
+
return false;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
try {
|
|
150
|
+
globalThis.localStorage?.setItem(
|
|
151
|
+
getGraphStateStorageKey(graphState.schemaSignature),
|
|
152
|
+
JSON.stringify({
|
|
153
|
+
version: STRUCTURE_GRAPH_STATE_STORAGE_VERSION,
|
|
154
|
+
schemaSignature: graphState.schemaSignature,
|
|
155
|
+
nodePositions: graphState.nodePositions,
|
|
156
|
+
pan: graphState.pan ?? null,
|
|
157
|
+
zoom: graphState.zoom ?? null,
|
|
158
|
+
inspectorHidden: Boolean(graphState.inspectorHidden),
|
|
159
|
+
selectedTableName: graphState.selectedTableName ?? null,
|
|
160
|
+
layoutVariant: Number.isFinite(Number(graphState.layoutVariant)) ? Number(graphState.layoutVariant) : 0,
|
|
161
|
+
savedAt: new Date().toISOString(),
|
|
162
|
+
}),
|
|
163
|
+
);
|
|
164
|
+
return true;
|
|
165
|
+
} catch {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
56
170
|
function getPersistedGraphState(schema) {
|
|
57
171
|
const schemaSignature = getSchemaSignature(schema);
|
|
58
172
|
|
|
59
|
-
if (persistedGraphState?.schemaSignature
|
|
173
|
+
if (persistedGraphState?.schemaSignature === schemaSignature) {
|
|
60
174
|
return {
|
|
61
175
|
schemaSignature,
|
|
62
|
-
state:
|
|
176
|
+
state: persistedGraphState,
|
|
63
177
|
};
|
|
64
178
|
}
|
|
65
179
|
|
|
180
|
+
const storedState = readStoredGraphState(schemaSignature);
|
|
181
|
+
persistedGraphState = storedState;
|
|
182
|
+
|
|
66
183
|
return {
|
|
67
184
|
schemaSignature,
|
|
68
|
-
state:
|
|
185
|
+
state: storedState,
|
|
69
186
|
};
|
|
70
187
|
}
|
|
71
188
|
|
|
@@ -120,6 +237,136 @@ function createColumnFlags(column, foreignKeyColumns) {
|
|
|
120
237
|
return flags.join('');
|
|
121
238
|
}
|
|
122
239
|
|
|
240
|
+
function quoteSqlIdentifier(identifier) {
|
|
241
|
+
return `"${String(identifier ?? '').replaceAll('"', '""')}"`;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function qualifySqlIdentifier(tableName, columnName) {
|
|
245
|
+
return `${quoteSqlIdentifier(tableName)}.${quoteSqlIdentifier(columnName)}`;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function getJoinConditions(edgeData) {
|
|
249
|
+
const conditions = Array.isArray(edgeData?.joinConditions) ? edgeData.joinConditions : [];
|
|
250
|
+
|
|
251
|
+
if (conditions.length) {
|
|
252
|
+
return conditions
|
|
253
|
+
.map(condition => ({
|
|
254
|
+
sourceTable: condition.sourceTable || edgeData.sourceTable || '',
|
|
255
|
+
targetTable: condition.targetTable || edgeData.targetTable || '',
|
|
256
|
+
sourceColumn: condition.sourceColumn || '',
|
|
257
|
+
targetColumn: condition.targetColumn || 'rowid',
|
|
258
|
+
}))
|
|
259
|
+
.filter(condition => condition.sourceTable && condition.targetTable && condition.sourceColumn);
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
return [
|
|
263
|
+
{
|
|
264
|
+
sourceTable: edgeData?.sourceTable || '',
|
|
265
|
+
targetTable: edgeData?.targetTable || '',
|
|
266
|
+
sourceColumn: edgeData?.sourceColumn || '',
|
|
267
|
+
targetColumn: edgeData?.targetColumn || 'rowid',
|
|
268
|
+
},
|
|
269
|
+
].filter(condition => condition.sourceTable && condition.targetTable && condition.sourceColumn);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function buildJoinSql(edgeData) {
|
|
273
|
+
const sourceTable = edgeData?.sourceTable || '';
|
|
274
|
+
const targetTable = edgeData?.targetTable || '';
|
|
275
|
+
const conditions = getJoinConditions(edgeData);
|
|
276
|
+
|
|
277
|
+
if (!sourceTable || !targetTable || !conditions.length) {
|
|
278
|
+
return '-- Join preview is unavailable for this relationship.';
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const joinLines = conditions.map((condition, index) => {
|
|
282
|
+
const prefix = index === 0 ? ' ON ' : ' AND ';
|
|
283
|
+
|
|
284
|
+
return [
|
|
285
|
+
prefix,
|
|
286
|
+
qualifySqlIdentifier(condition.sourceTable, condition.sourceColumn),
|
|
287
|
+
' = ',
|
|
288
|
+
qualifySqlIdentifier(condition.targetTable, condition.targetColumn),
|
|
289
|
+
].join('');
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
return [
|
|
293
|
+
'SELECT *',
|
|
294
|
+
`FROM ${quoteSqlIdentifier(sourceTable)}`,
|
|
295
|
+
`JOIN ${quoteSqlIdentifier(targetTable)}`,
|
|
296
|
+
`${joinLines.join('\n')};`,
|
|
297
|
+
].join('\n');
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
function renderJoinInspector(edgeData) {
|
|
301
|
+
const sourceTable = edgeData?.sourceTable || '';
|
|
302
|
+
const targetTable = edgeData?.targetTable || '';
|
|
303
|
+
const conditions = getJoinConditions(edgeData);
|
|
304
|
+
const joinSql = buildJoinSql(edgeData);
|
|
305
|
+
|
|
306
|
+
return `
|
|
307
|
+
<div class="structure-graph__panel">
|
|
308
|
+
<div class="space-y-3">
|
|
309
|
+
<div class="structure-graph__panel-heading">
|
|
310
|
+
<div class="structure-graph__eyebrow">Join Preview</div>
|
|
311
|
+
<button
|
|
312
|
+
class="query-history-icon-button"
|
|
313
|
+
data-structure-graph-action="toggle-inspector"
|
|
314
|
+
type="button"
|
|
315
|
+
aria-label="Hide inspector"
|
|
316
|
+
>
|
|
317
|
+
<span class="material-symbols-outlined text-[18px]">close</span>
|
|
318
|
+
</button>
|
|
319
|
+
</div>
|
|
320
|
+
<div class="structure-graph__title">${escapeHtml(sourceTable)} → ${escapeHtml(targetTable)}</div>
|
|
321
|
+
<div class="structure-graph__subtitle">Foreign key relationship</div>
|
|
322
|
+
</div>
|
|
323
|
+
|
|
324
|
+
<section class="structure-graph__section">
|
|
325
|
+
<div class="structure-graph__section-title">Relationship</div>
|
|
326
|
+
<div class="structure-graph__join-flow">
|
|
327
|
+
<div class="structure-graph__join-table">${escapeHtml(sourceTable)}</div>
|
|
328
|
+
<span class="material-symbols-outlined structure-graph__join-arrow" aria-hidden="true">arrow_forward</span>
|
|
329
|
+
<div class="structure-graph__join-table">${escapeHtml(targetTable)}</div>
|
|
330
|
+
</div>
|
|
331
|
+
<div class="structure-graph__join-condition-list">
|
|
332
|
+
${
|
|
333
|
+
conditions.length
|
|
334
|
+
? conditions
|
|
335
|
+
.map(
|
|
336
|
+
condition => `
|
|
337
|
+
<div class="structure-graph__join-condition">
|
|
338
|
+
<span>${escapeHtml(formatQualifiedEdgeColumn(condition.sourceTable, condition.sourceColumn))}</span>
|
|
339
|
+
<span aria-hidden="true">=</span>
|
|
340
|
+
<span>${escapeHtml(formatQualifiedEdgeColumn(condition.targetTable, condition.targetColumn))}</span>
|
|
341
|
+
</div>
|
|
342
|
+
`,
|
|
343
|
+
)
|
|
344
|
+
.join('')
|
|
345
|
+
: '<div class="text-sm text-on-surface-variant/45">No join columns found for this relationship.</div>'
|
|
346
|
+
}
|
|
347
|
+
</div>
|
|
348
|
+
</section>
|
|
349
|
+
|
|
350
|
+
<section class="structure-graph__section">
|
|
351
|
+
<div class="structure-graph__section-header">
|
|
352
|
+
<div class="structure-graph__section-title">SQL</div>
|
|
353
|
+
<button
|
|
354
|
+
class="standard-button"
|
|
355
|
+
data-structure-graph-action="copy-join-sql"
|
|
356
|
+
type="button"
|
|
357
|
+
>
|
|
358
|
+
<span class="material-symbols-outlined text-sm">content_copy</span>
|
|
359
|
+
Copy join
|
|
360
|
+
</button>
|
|
361
|
+
</div>
|
|
362
|
+
<pre class="structure-graph__ddl structure-graph__join-sql custom-scrollbar" data-structure-graph-join-sql>${escapeHtml(
|
|
363
|
+
joinSql,
|
|
364
|
+
)}</pre>
|
|
365
|
+
</section>
|
|
366
|
+
</div>
|
|
367
|
+
`;
|
|
368
|
+
}
|
|
369
|
+
|
|
123
370
|
export function renderDdlSection(ddl, emptyLabel = 'No DDL available.') {
|
|
124
371
|
const ddlText = typeof ddl === 'string' ? ddl : '';
|
|
125
372
|
const hasDdl = Boolean(ddlText.trim());
|
|
@@ -171,11 +418,18 @@ export function renderInspector(tableData) {
|
|
|
171
418
|
return `
|
|
172
419
|
<div class="structure-graph__panel">
|
|
173
420
|
<div class="space-y-3">
|
|
174
|
-
<div class="structure-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
421
|
+
<div class="structure-graph__panel-heading">
|
|
422
|
+
<div class="structure-graph__eyebrow">Table Inspector</div>
|
|
423
|
+
<button
|
|
424
|
+
class="query-history-icon-button"
|
|
425
|
+
data-structure-graph-action="toggle-inspector"
|
|
426
|
+
type="button"
|
|
427
|
+
aria-label="Hide inspector"
|
|
428
|
+
>
|
|
429
|
+
<span class="material-symbols-outlined text-[18px]">close</span>
|
|
430
|
+
</button>
|
|
178
431
|
</div>
|
|
432
|
+
<div class="structure-graph__title">${escapeHtml(tableData.name)}</div>
|
|
179
433
|
</div>
|
|
180
434
|
|
|
181
435
|
<div class="structure-graph__summary">
|
|
@@ -239,6 +493,13 @@ export function buildGraphElements(schema) {
|
|
|
239
493
|
return;
|
|
240
494
|
}
|
|
241
495
|
|
|
496
|
+
const joinConditions = (foreignKey.mappings ?? []).map(mapping => ({
|
|
497
|
+
sourceTable: table.name,
|
|
498
|
+
targetTable: foreignKey.referencedTable,
|
|
499
|
+
sourceColumn: mapping.from || '',
|
|
500
|
+
targetColumn: mapping.to || 'rowid',
|
|
501
|
+
}));
|
|
502
|
+
|
|
242
503
|
(foreignKey.mappings ?? []).forEach((mapping, mappingIndex) => {
|
|
243
504
|
edges.push({
|
|
244
505
|
group: 'edges',
|
|
@@ -259,6 +520,7 @@ export function buildGraphElements(schema) {
|
|
|
259
520
|
targetTable: foreignKey.referencedTable,
|
|
260
521
|
sourceColumn: mapping.from || '',
|
|
261
522
|
targetColumn: mapping.to || 'rowid',
|
|
523
|
+
joinConditions,
|
|
262
524
|
},
|
|
263
525
|
});
|
|
264
526
|
});
|
|
@@ -268,22 +530,14 @@ export function buildGraphElements(schema) {
|
|
|
268
530
|
return [...nodes, ...edges];
|
|
269
531
|
}
|
|
270
532
|
|
|
271
|
-
|
|
533
|
+
function getReadableLayoutGeometry(variant = 0) {
|
|
272
534
|
return {
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
algorithm: 'layered',
|
|
280
|
-
'elk.direction': 'RIGHT',
|
|
281
|
-
'elk.edgeRouting': 'ORTHOGONAL',
|
|
282
|
-
'elk.layered.spacing.nodeNodeBetweenLayers': 60,
|
|
283
|
-
'elk.spacing.nodeNode': 40,
|
|
284
|
-
'elk.padding': 30,
|
|
285
|
-
},
|
|
286
|
-
...overrides,
|
|
535
|
+
connectedBaseY: 150,
|
|
536
|
+
isolatedY: -170,
|
|
537
|
+
isolatedSpacing: 380,
|
|
538
|
+
rankSpacing: 440,
|
|
539
|
+
rowSpacing: 230,
|
|
540
|
+
variant,
|
|
287
541
|
};
|
|
288
542
|
}
|
|
289
543
|
|
|
@@ -310,92 +564,112 @@ export function createCytoscapeInstance(container, elements) {
|
|
|
310
564
|
width: 'data(width)',
|
|
311
565
|
height: 'data(height)',
|
|
312
566
|
label: 'data(label)',
|
|
313
|
-
'background-color': '#
|
|
314
|
-
'border-color': '#
|
|
315
|
-
'border-width': 1.
|
|
316
|
-
color: '#
|
|
567
|
+
'background-color': '#302b12',
|
|
568
|
+
'border-color': '#c0a31d',
|
|
569
|
+
'border-width': 1.8,
|
|
570
|
+
color: '#fff8dc',
|
|
317
571
|
'font-family': 'Space Grotesk, Inter, sans-serif',
|
|
318
|
-
'font-size': 13,
|
|
572
|
+
'font-size': 13.5,
|
|
319
573
|
'font-weight': 700,
|
|
320
574
|
'text-wrap': 'ellipsis',
|
|
321
|
-
'text-max-width':
|
|
575
|
+
'text-max-width': 170,
|
|
322
576
|
'text-halign': 'center',
|
|
323
577
|
'text-valign': 'center',
|
|
578
|
+
'text-outline-color': '#11100b',
|
|
579
|
+
'text-outline-width': 1.5,
|
|
324
580
|
'overlay-opacity': 0,
|
|
325
|
-
'transition-property': 'background-color, border-color, opacity, color',
|
|
581
|
+
'transition-property': 'background-color, border-color, opacity, color, width',
|
|
326
582
|
'transition-duration': '140ms',
|
|
327
583
|
},
|
|
328
584
|
},
|
|
329
585
|
{
|
|
330
586
|
selector: 'node.selected',
|
|
331
587
|
style: {
|
|
332
|
-
'background-color': '#
|
|
333
|
-
'border-color': '#
|
|
334
|
-
'border-width': 2
|
|
335
|
-
color: '#
|
|
588
|
+
'background-color': '#5a4e00',
|
|
589
|
+
'border-color': '#ffea00',
|
|
590
|
+
'border-width': 3.2,
|
|
591
|
+
color: '#fffbd3',
|
|
336
592
|
},
|
|
337
593
|
},
|
|
338
594
|
{
|
|
339
595
|
selector: 'node.related',
|
|
340
596
|
style: {
|
|
341
|
-
'background-color': '#
|
|
342
|
-
'border-color': '#
|
|
343
|
-
'border-width': 2.
|
|
344
|
-
color: '#
|
|
597
|
+
'background-color': '#06454a',
|
|
598
|
+
'border-color': '#1ef6ff',
|
|
599
|
+
'border-width': 2.8,
|
|
600
|
+
color: '#edfeff',
|
|
345
601
|
},
|
|
346
602
|
},
|
|
347
603
|
{
|
|
348
604
|
selector: 'node.dimmed',
|
|
349
605
|
style: {
|
|
350
|
-
opacity: 0.
|
|
606
|
+
opacity: 0.46,
|
|
351
607
|
},
|
|
352
608
|
},
|
|
353
609
|
{
|
|
354
610
|
selector: 'edge',
|
|
355
611
|
style: {
|
|
356
|
-
width:
|
|
612
|
+
width: 2.2,
|
|
357
613
|
label: 'data(label)',
|
|
358
|
-
color: '#
|
|
614
|
+
color: '#fff5b8',
|
|
359
615
|
'font-family': 'Roboto Mono, monospace',
|
|
360
|
-
'font-size':
|
|
361
|
-
'
|
|
362
|
-
'text-background-
|
|
363
|
-
'text-background-
|
|
364
|
-
'
|
|
365
|
-
'
|
|
616
|
+
'font-size': 9.5,
|
|
617
|
+
'font-weight': 700,
|
|
618
|
+
'text-background-color': '#050706',
|
|
619
|
+
'text-background-opacity': 0.98,
|
|
620
|
+
'text-background-padding': 5,
|
|
621
|
+
'text-background-shape': 'roundrectangle',
|
|
622
|
+
'text-border-color': '#3d3510',
|
|
623
|
+
'text-border-opacity': 0.9,
|
|
624
|
+
'text-border-width': 1,
|
|
625
|
+
'text-outline-color': '#050706',
|
|
626
|
+
'text-outline-width': 1.8,
|
|
627
|
+
'text-wrap': 'wrap',
|
|
628
|
+
'text-max-width': 170,
|
|
629
|
+
'text-margin-y': -7,
|
|
630
|
+
'line-color': '#c8a81d',
|
|
631
|
+
'target-arrow-color': '#c8a81d',
|
|
632
|
+
'arrow-scale': 1.18,
|
|
366
633
|
'target-arrow-shape': 'triangle',
|
|
367
|
-
'curve-style': '
|
|
368
|
-
'
|
|
369
|
-
'
|
|
634
|
+
'curve-style': 'bezier',
|
|
635
|
+
'control-point-step-size': 82,
|
|
636
|
+
'loop-direction': '45deg',
|
|
637
|
+
'loop-sweep': '90deg',
|
|
370
638
|
'source-endpoint': 'outside-to-node',
|
|
371
639
|
'target-endpoint': 'outside-to-node',
|
|
372
640
|
'overlay-opacity': 0,
|
|
373
|
-
'
|
|
641
|
+
'z-index': 1,
|
|
642
|
+
'transition-property': 'line-color, target-arrow-color, opacity, color, width',
|
|
374
643
|
'transition-duration': '120ms',
|
|
375
644
|
},
|
|
376
645
|
},
|
|
377
646
|
{
|
|
378
647
|
selector: 'edge.related',
|
|
379
648
|
style: {
|
|
380
|
-
'line-color': '#
|
|
381
|
-
'target-arrow-color': '#
|
|
382
|
-
color: '#
|
|
383
|
-
|
|
649
|
+
'line-color': '#1ef6ff',
|
|
650
|
+
'target-arrow-color': '#1ef6ff',
|
|
651
|
+
color: '#edfeff',
|
|
652
|
+
'text-border-color': '#1ef6ff',
|
|
653
|
+
width: 3.1,
|
|
654
|
+
'z-index': 24,
|
|
384
655
|
},
|
|
385
656
|
},
|
|
386
657
|
{
|
|
387
658
|
selector: 'edge.hovered',
|
|
388
659
|
style: {
|
|
389
|
-
'line-color': '#
|
|
390
|
-
'target-arrow-color': '#
|
|
391
|
-
|
|
392
|
-
|
|
660
|
+
'line-color': '#ffea00',
|
|
661
|
+
'target-arrow-color': '#ffea00',
|
|
662
|
+
'arrow-scale': 1.35,
|
|
663
|
+
color: '#fffbd3',
|
|
664
|
+
'text-border-color': '#ffea00',
|
|
665
|
+
width: 3.6,
|
|
666
|
+
'z-index': 32,
|
|
393
667
|
},
|
|
394
668
|
},
|
|
395
669
|
{
|
|
396
670
|
selector: 'edge.dimmed',
|
|
397
671
|
style: {
|
|
398
|
-
opacity: 0.
|
|
672
|
+
opacity: 0.36,
|
|
399
673
|
},
|
|
400
674
|
},
|
|
401
675
|
],
|
|
@@ -410,6 +684,129 @@ export function resetHighlights(cy) {
|
|
|
410
684
|
cy.elements().removeClass('selected related hovered dimmed');
|
|
411
685
|
}
|
|
412
686
|
|
|
687
|
+
function formatQualifiedEdgeColumn(tableName, columnName) {
|
|
688
|
+
const safeTableName = String(tableName || '?');
|
|
689
|
+
const safeColumnName = String(columnName || 'rowid');
|
|
690
|
+
|
|
691
|
+
return `${safeTableName}.${safeColumnName}`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function positionEdgeReadout(renderedPosition) {
|
|
695
|
+
if (!currentGraph?.edgeReadout || !currentGraph.canvasShell || !renderedPosition) {
|
|
696
|
+
return;
|
|
697
|
+
}
|
|
698
|
+
|
|
699
|
+
const readout = currentGraph.edgeReadout;
|
|
700
|
+
const shell = currentGraph.canvasShell;
|
|
701
|
+
const width = readout.offsetWidth || 280;
|
|
702
|
+
const height = readout.offsetHeight || 64;
|
|
703
|
+
const maxLeft = Math.max(12, shell.clientWidth - width - 12);
|
|
704
|
+
const maxTop = Math.max(12, shell.clientHeight - height - 12);
|
|
705
|
+
const left = Math.min(Math.max(renderedPosition.x + 16, 12), maxLeft);
|
|
706
|
+
const top = Math.min(Math.max(renderedPosition.y - height / 2, 12), maxTop);
|
|
707
|
+
|
|
708
|
+
readout.style.left = `${left}px`;
|
|
709
|
+
readout.style.top = `${top}px`;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
function showEdgeReadout(edge, renderedPosition) {
|
|
713
|
+
if (!currentGraph?.edgeReadout || !edge) {
|
|
714
|
+
return;
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
const sourceTable = edge.data('sourceTable') || edge.source().data('tableName') || '?';
|
|
718
|
+
const targetTable = edge.data('targetTable') || edge.target().data('tableName') || '?';
|
|
719
|
+
const sourceColumn = edge.data('sourceColumn') || '?';
|
|
720
|
+
const targetColumn = edge.data('targetColumn') || 'rowid';
|
|
721
|
+
const sourceLabel = formatQualifiedEdgeColumn(sourceTable, sourceColumn);
|
|
722
|
+
const targetLabel = formatQualifiedEdgeColumn(targetTable, targetColumn);
|
|
723
|
+
|
|
724
|
+
currentGraph.edgeReadout.innerHTML = `
|
|
725
|
+
<div class="structure-graph__edge-readout-meta">
|
|
726
|
+
${escapeHtml(sourceTable)} <span aria-hidden="true">→</span> ${escapeHtml(targetTable)}
|
|
727
|
+
</div>
|
|
728
|
+
<div class="structure-graph__edge-readout-path">
|
|
729
|
+
${escapeHtml(sourceLabel)} <span aria-hidden="true">→</span> ${escapeHtml(targetLabel)}
|
|
730
|
+
</div>
|
|
731
|
+
`;
|
|
732
|
+
currentGraph.edgeReadout.removeAttribute('hidden');
|
|
733
|
+
positionEdgeReadout(renderedPosition);
|
|
734
|
+
}
|
|
735
|
+
|
|
736
|
+
function hideEdgeReadout() {
|
|
737
|
+
if (!currentGraph?.edgeReadout) {
|
|
738
|
+
return;
|
|
739
|
+
}
|
|
740
|
+
|
|
741
|
+
currentGraph.edgeReadout.setAttribute('hidden', 'hidden');
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
function createGraphStateSnapshot(graph = currentGraph) {
|
|
745
|
+
if (!graph?.cy) {
|
|
746
|
+
return null;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
const nodePositions = {};
|
|
750
|
+
|
|
751
|
+
graph.cy.nodes().forEach(node => {
|
|
752
|
+
const x = node.position('x');
|
|
753
|
+
const y = node.position('y');
|
|
754
|
+
|
|
755
|
+
if (Number.isFinite(x) && Number.isFinite(y)) {
|
|
756
|
+
nodePositions[node.id()] = { x, y };
|
|
757
|
+
}
|
|
758
|
+
});
|
|
759
|
+
|
|
760
|
+
if (!Object.keys(nodePositions).length) {
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
return {
|
|
765
|
+
schemaSignature: getSchemaSignature(graph.schema),
|
|
766
|
+
nodePositions,
|
|
767
|
+
pan: graph.cy.pan(),
|
|
768
|
+
zoom: graph.cy.zoom(),
|
|
769
|
+
inspectorHidden: graph.inspectorHidden,
|
|
770
|
+
selectedTableName: graph.selectedTableName,
|
|
771
|
+
layoutVariant: graph.layoutVariant ?? 0,
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
function persistCurrentGraphState({ persistToStorage = true } = {}) {
|
|
776
|
+
const graphState = createGraphStateSnapshot();
|
|
777
|
+
|
|
778
|
+
if (!graphState) {
|
|
779
|
+
return false;
|
|
780
|
+
}
|
|
781
|
+
|
|
782
|
+
persistedGraphState = graphState;
|
|
783
|
+
|
|
784
|
+
if (persistToStorage) {
|
|
785
|
+
writeStoredGraphState(graphState);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
return true;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
function scheduleGraphStatePersist(delay = 180) {
|
|
792
|
+
if (!currentGraph || currentGraph.persistStateOnDestroy === false) {
|
|
793
|
+
return;
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
if (currentGraph.persistTimer) {
|
|
797
|
+
window.clearTimeout(currentGraph.persistTimer);
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
currentGraph.persistTimer = window.setTimeout(() => {
|
|
801
|
+
if (!currentGraph || currentGraph.persistStateOnDestroy === false) {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
currentGraph.persistTimer = null;
|
|
806
|
+
persistCurrentGraphState();
|
|
807
|
+
}, delay);
|
|
808
|
+
}
|
|
809
|
+
|
|
413
810
|
export function highlightConnectedElements(cy, element) {
|
|
414
811
|
if (!cy || !element) {
|
|
415
812
|
return;
|
|
@@ -446,27 +843,17 @@ function destroyCurrentGraph() {
|
|
|
446
843
|
}
|
|
447
844
|
|
|
448
845
|
if (currentGraph.cy && currentGraph.persistStateOnDestroy !== false) {
|
|
449
|
-
|
|
450
|
-
currentGraph.cy.nodes().forEach(node => {
|
|
451
|
-
nodePositions[node.id()] = {
|
|
452
|
-
x: node.position('x'),
|
|
453
|
-
y: node.position('y'),
|
|
454
|
-
};
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
persistedGraphState = {
|
|
458
|
-
schemaSignature: getSchemaSignature(currentGraph.schema),
|
|
459
|
-
nodePositions,
|
|
460
|
-
pan: currentGraph.cy.pan(),
|
|
461
|
-
zoom: currentGraph.cy.zoom(),
|
|
462
|
-
inspectorHidden: currentGraph.inspectorHidden,
|
|
463
|
-
selectedTableName: currentGraph.selectedTableName,
|
|
464
|
-
};
|
|
846
|
+
persistCurrentGraphState();
|
|
465
847
|
}
|
|
466
848
|
|
|
467
849
|
currentGraph.cleanup.forEach(cleanup => cleanup());
|
|
468
850
|
currentGraph.cleanup = [];
|
|
469
851
|
|
|
852
|
+
if (currentGraph.persistTimer) {
|
|
853
|
+
window.clearTimeout(currentGraph.persistTimer);
|
|
854
|
+
currentGraph.persistTimer = null;
|
|
855
|
+
}
|
|
856
|
+
|
|
470
857
|
if (currentGraph.resizeObserver) {
|
|
471
858
|
currentGraph.resizeObserver.disconnect();
|
|
472
859
|
}
|
|
@@ -483,6 +870,10 @@ function destroyCurrentGraph() {
|
|
|
483
870
|
}
|
|
484
871
|
|
|
485
872
|
export function resetPersistedStructureGraphState() {
|
|
873
|
+
if (currentGraph?.persistStateOnDestroy !== false) {
|
|
874
|
+
persistCurrentGraphState();
|
|
875
|
+
}
|
|
876
|
+
|
|
486
877
|
persistedGraphState = null;
|
|
487
878
|
|
|
488
879
|
if (currentGraph) {
|
|
@@ -543,6 +934,23 @@ async function copyInspectorDdl(button) {
|
|
|
543
934
|
}
|
|
544
935
|
}
|
|
545
936
|
|
|
937
|
+
async function copyJoinSql(button) {
|
|
938
|
+
const sqlNode = button?.closest('.structure-graph__section')?.querySelector('[data-structure-graph-join-sql]');
|
|
939
|
+
const sql = sqlNode?.textContent ?? '';
|
|
940
|
+
|
|
941
|
+
if (!sql.trim()) {
|
|
942
|
+
showToast('No join SQL available to copy.', 'alert');
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
try {
|
|
947
|
+
await navigator.clipboard.writeText(sql);
|
|
948
|
+
showToast('Join SQL copied.', 'success');
|
|
949
|
+
} catch (error) {
|
|
950
|
+
showToast('Clipboard access failed.', 'alert');
|
|
951
|
+
}
|
|
952
|
+
}
|
|
953
|
+
|
|
546
954
|
function syncInspectorLayout() {
|
|
547
955
|
if (!currentGraph?.root) {
|
|
548
956
|
return;
|
|
@@ -571,7 +979,9 @@ function clearSelection() {
|
|
|
571
979
|
return;
|
|
572
980
|
}
|
|
573
981
|
|
|
982
|
+
hideEdgeReadout();
|
|
574
983
|
currentGraph.selectedTableName = null;
|
|
984
|
+
currentGraph.selectedEdgeId = null;
|
|
575
985
|
resetHighlights(currentGraph.cy);
|
|
576
986
|
syncEntryHighlights();
|
|
577
987
|
replaceChildrenFromRenderedMarkup(currentGraph.inspector, getDefaultInspectorMarkup());
|
|
@@ -583,7 +993,9 @@ function applyTableSelection(node, { focus = true } = {}) {
|
|
|
583
993
|
return null;
|
|
584
994
|
}
|
|
585
995
|
|
|
996
|
+
hideEdgeReadout();
|
|
586
997
|
const tableData = node.data('table');
|
|
998
|
+
currentGraph.selectedEdgeId = null;
|
|
587
999
|
currentGraph.selectedTableName = tableData.name;
|
|
588
1000
|
replaceChildrenFromRenderedMarkup(currentGraph.inspector, renderInspector(tableData));
|
|
589
1001
|
highlightConnectedElements(currentGraph.cy, node);
|
|
@@ -608,6 +1020,21 @@ function applyTableSelection(node, { focus = true } = {}) {
|
|
|
608
1020
|
return node;
|
|
609
1021
|
}
|
|
610
1022
|
|
|
1023
|
+
function applyEdgeSelection(edge) {
|
|
1024
|
+
if (!currentGraph || !edge) {
|
|
1025
|
+
return null;
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
hideEdgeReadout();
|
|
1029
|
+
currentGraph.selectedTableName = null;
|
|
1030
|
+
currentGraph.selectedEdgeId = edge.id();
|
|
1031
|
+
replaceChildrenFromRenderedMarkup(currentGraph.inspector, renderJoinInspector(edge.data()));
|
|
1032
|
+
highlightConnectedElements(currentGraph.cy, edge);
|
|
1033
|
+
syncEntryHighlights([], [edge.data('sourceTable'), edge.data('targetTable')]);
|
|
1034
|
+
updateOpenDataButton();
|
|
1035
|
+
return edge;
|
|
1036
|
+
}
|
|
1037
|
+
|
|
611
1038
|
function restoreGraphState() {
|
|
612
1039
|
if (!currentGraph) {
|
|
613
1040
|
return;
|
|
@@ -623,24 +1050,163 @@ function restoreGraphState() {
|
|
|
623
1050
|
}
|
|
624
1051
|
}
|
|
625
1052
|
|
|
1053
|
+
if (currentGraph.selectedEdgeId) {
|
|
1054
|
+
const selectedEdge = currentGraph.cy.getElementById(currentGraph.selectedEdgeId);
|
|
1055
|
+
|
|
1056
|
+
if (selectedEdge.nonempty()) {
|
|
1057
|
+
highlightConnectedElements(currentGraph.cy, selectedEdge);
|
|
1058
|
+
syncEntryHighlights([], [selectedEdge.data('sourceTable'), selectedEdge.data('targetTable')]);
|
|
1059
|
+
return;
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
626
1063
|
resetHighlights(currentGraph.cy);
|
|
627
1064
|
syncEntryHighlights();
|
|
628
1065
|
updateOpenDataButton();
|
|
629
1066
|
}
|
|
630
1067
|
|
|
631
|
-
function
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
1068
|
+
function getSortedGraphNodes(nodes) {
|
|
1069
|
+
return nodes
|
|
1070
|
+
.toArray()
|
|
1071
|
+
.sort((left, right) => String(left.data('tableName')).localeCompare(String(right.data('tableName'))));
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1074
|
+
function getIncomingAverageY(node) {
|
|
1075
|
+
const incomingY = [];
|
|
1076
|
+
|
|
1077
|
+
node.incomers('edge').forEach(edge => {
|
|
1078
|
+
const y = edge.source().position('y');
|
|
1079
|
+
|
|
1080
|
+
if (Number.isFinite(y)) {
|
|
1081
|
+
incomingY.push(y);
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
642
1084
|
|
|
643
|
-
|
|
1085
|
+
if (!incomingY.length) {
|
|
1086
|
+
return null;
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
return incomingY.reduce((sum, value) => sum + value, 0) / incomingY.length;
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1092
|
+
function getLayoutVariantOffset(variant, rank, index, axis) {
|
|
1093
|
+
if (!variant) {
|
|
1094
|
+
return 0;
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
const factor = axis === 'x' ? 28 : 46;
|
|
1098
|
+
const wave = axis === 'x' ? Math.cos : Math.sin;
|
|
1099
|
+
|
|
1100
|
+
return wave((variant + 1) * (rank + 1.7) * (index + 2.3)) * factor;
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function applyReadableLayoutPositions(cy, { variant = 0 } = {}) {
|
|
1104
|
+
const nodes = getSortedGraphNodes(cy.nodes());
|
|
1105
|
+
|
|
1106
|
+
if (!nodes.length) {
|
|
1107
|
+
return;
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
const geometry = getReadableLayoutGeometry(variant);
|
|
1111
|
+
const rankById = new Map(nodes.map(node => [node.id(), 0]));
|
|
1112
|
+
|
|
1113
|
+
for (let iteration = 0; iteration < nodes.length; iteration += 1) {
|
|
1114
|
+
let changed = false;
|
|
1115
|
+
|
|
1116
|
+
cy.edges().forEach(edge => {
|
|
1117
|
+
const source = edge.source();
|
|
1118
|
+
const target = edge.target();
|
|
1119
|
+
|
|
1120
|
+
if (source.id() === target.id()) {
|
|
1121
|
+
return;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
const sourceRank = rankById.get(source.id()) ?? 0;
|
|
1125
|
+
const targetRank = rankById.get(target.id()) ?? 0;
|
|
1126
|
+
|
|
1127
|
+
if (targetRank < sourceRank + 1) {
|
|
1128
|
+
rankById.set(target.id(), sourceRank + 1);
|
|
1129
|
+
changed = true;
|
|
1130
|
+
}
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
if (!changed) {
|
|
1134
|
+
break;
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
const connectedNodes = nodes.filter(node => node.connectedEdges().nonempty());
|
|
1139
|
+
const isolatedNodes = nodes.filter(node => node.connectedEdges().empty());
|
|
1140
|
+
const nodesByRank = new Map();
|
|
1141
|
+
|
|
1142
|
+
connectedNodes.forEach(node => {
|
|
1143
|
+
const rank = rankById.get(node.id()) ?? 0;
|
|
1144
|
+
const group = nodesByRank.get(rank) ?? [];
|
|
1145
|
+
|
|
1146
|
+
group.push(node);
|
|
1147
|
+
nodesByRank.set(rank, group);
|
|
1148
|
+
});
|
|
1149
|
+
|
|
1150
|
+
const ranks = [...nodesByRank.keys()].sort((left, right) => left - right);
|
|
1151
|
+
const maxRank = ranks.at(-1) ?? 0;
|
|
1152
|
+
const connectedBaseX = -(maxRank * geometry.rankSpacing) / 2;
|
|
1153
|
+
|
|
1154
|
+
ranks.forEach(rank => {
|
|
1155
|
+
const group = (nodesByRank.get(rank) ?? []).sort((left, right) => {
|
|
1156
|
+
const leftAverageY = getIncomingAverageY(left);
|
|
1157
|
+
const rightAverageY = getIncomingAverageY(right);
|
|
1158
|
+
|
|
1159
|
+
if (leftAverageY !== null && rightAverageY !== null && leftAverageY !== rightAverageY) {
|
|
1160
|
+
return leftAverageY - rightAverageY;
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
const degreeDelta = right.degree(false) - left.degree(false);
|
|
1164
|
+
|
|
1165
|
+
if (degreeDelta !== 0) {
|
|
1166
|
+
return degreeDelta;
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
return String(left.data('tableName')).localeCompare(String(right.data('tableName')));
|
|
1170
|
+
});
|
|
1171
|
+
|
|
1172
|
+
group.forEach((node, index) => {
|
|
1173
|
+
node.position({
|
|
1174
|
+
x:
|
|
1175
|
+
connectedBaseX +
|
|
1176
|
+
rank * geometry.rankSpacing +
|
|
1177
|
+
getLayoutVariantOffset(geometry.variant, rank, index, 'x'),
|
|
1178
|
+
y:
|
|
1179
|
+
geometry.connectedBaseY +
|
|
1180
|
+
(index - (group.length - 1) / 2) * geometry.rowSpacing +
|
|
1181
|
+
(rank % 2 === 0 ? 0 : 58) +
|
|
1182
|
+
getLayoutVariantOffset(geometry.variant, rank, index, 'y'),
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
isolatedNodes.forEach((node, index) => {
|
|
1188
|
+
node.position({
|
|
1189
|
+
x:
|
|
1190
|
+
(index - (isolatedNodes.length - 1) / 2) * geometry.isolatedSpacing +
|
|
1191
|
+
getLayoutVariantOffset(geometry.variant, -1, index, 'x'),
|
|
1192
|
+
y: geometry.isolatedY + getLayoutVariantOffset(geometry.variant, -1, index, 'y'),
|
|
1193
|
+
});
|
|
1194
|
+
});
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
function runLayout(cy, onStop, { randomize = false } = {}) {
|
|
1198
|
+
if (currentGraph) {
|
|
1199
|
+
currentGraph.layoutVariant = randomize ? (currentGraph.layoutVariant ?? 0) + 1 : 0;
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
applyReadableLayoutPositions(cy, { variant: currentGraph?.layoutVariant ?? 0 });
|
|
1203
|
+
cy.fit(cy.elements(), 90);
|
|
1204
|
+
|
|
1205
|
+
if (typeof onStop === 'function') {
|
|
1206
|
+
onStop();
|
|
1207
|
+
}
|
|
1208
|
+
|
|
1209
|
+
persistCurrentGraphState();
|
|
644
1210
|
}
|
|
645
1211
|
|
|
646
1212
|
function restorePersistedViewport(cy, persistedState) {
|
|
@@ -696,16 +1262,18 @@ export function setupToolbar(cy) {
|
|
|
696
1262
|
switch (button.dataset.structureGraphAction) {
|
|
697
1263
|
case 'fit':
|
|
698
1264
|
cy.fit(cy.elements(), 60);
|
|
1265
|
+
scheduleGraphStatePersist();
|
|
699
1266
|
break;
|
|
700
1267
|
case 'relayout':
|
|
1268
|
+
hideEdgeReadout();
|
|
701
1269
|
runLayout(cy, () => {
|
|
702
1270
|
if (currentGraph?.selectedTableName) {
|
|
703
1271
|
const selectedNode = cy.getElementById(getTableId(currentGraph.selectedTableName));
|
|
704
1272
|
if (selectedNode.nonempty()) {
|
|
705
|
-
applyTableSelection(selectedNode, { focus:
|
|
1273
|
+
applyTableSelection(selectedNode, { focus: false });
|
|
706
1274
|
}
|
|
707
1275
|
}
|
|
708
|
-
});
|
|
1276
|
+
}, { randomize: true });
|
|
709
1277
|
break;
|
|
710
1278
|
case 'clear':
|
|
711
1279
|
clearSelection();
|
|
@@ -720,10 +1288,14 @@ export function setupToolbar(cy) {
|
|
|
720
1288
|
storeInspectorHidden(currentGraph.inspectorHidden);
|
|
721
1289
|
updateInspectorToggleButton();
|
|
722
1290
|
syncInspectorLayout();
|
|
1291
|
+
scheduleGraphStatePersist();
|
|
723
1292
|
break;
|
|
724
1293
|
case 'copy-ddl':
|
|
725
1294
|
await copyInspectorDdl(button);
|
|
726
1295
|
break;
|
|
1296
|
+
case 'copy-join-sql':
|
|
1297
|
+
await copyJoinSql(button);
|
|
1298
|
+
break;
|
|
727
1299
|
default:
|
|
728
1300
|
}
|
|
729
1301
|
};
|
|
@@ -762,6 +1334,8 @@ export async function mountStructureGraph(snapshot) {
|
|
|
762
1334
|
const canvas = root.querySelector('[data-structure-graph-canvas]');
|
|
763
1335
|
const inspector = root.querySelector('[data-structure-graph-inspector]');
|
|
764
1336
|
const empty = root.querySelector('[data-structure-graph-empty]');
|
|
1337
|
+
const canvasShell = canvas?.closest('.structure-graph__canvas-shell');
|
|
1338
|
+
const edgeReadout = root.querySelector('[data-structure-graph-edge-readout]');
|
|
765
1339
|
const schema = snapshot.structure.data?.graph ?? { tables: [] };
|
|
766
1340
|
const { schemaSignature, state: cachedState } = getPersistedGraphState(schema);
|
|
767
1341
|
|
|
@@ -782,6 +1356,8 @@ export async function mountStructureGraph(snapshot) {
|
|
|
782
1356
|
currentGraph = {
|
|
783
1357
|
root,
|
|
784
1358
|
canvas,
|
|
1359
|
+
canvasShell: canvasShell instanceof HTMLElement ? canvasShell : null,
|
|
1360
|
+
edgeReadout: edgeReadout instanceof HTMLElement ? edgeReadout : null,
|
|
785
1361
|
inspector,
|
|
786
1362
|
initialInspectorMarkup: inspector.innerHTML,
|
|
787
1363
|
initialSelectedTableName: null,
|
|
@@ -794,14 +1370,26 @@ export async function mountStructureGraph(snapshot) {
|
|
|
794
1370
|
openDataButton: null,
|
|
795
1371
|
inspectorToggleButton: null,
|
|
796
1372
|
inspectorHidden: cachedState?.inspectorHidden ?? readStoredInspectorHidden(),
|
|
1373
|
+
layoutVariant: cachedState?.layoutVariant ?? 0,
|
|
797
1374
|
persistStateOnDestroy: true,
|
|
1375
|
+
selectedEdgeId: null,
|
|
798
1376
|
selectedTableName: null,
|
|
799
1377
|
};
|
|
800
1378
|
|
|
801
1379
|
currentGraph.cleanup.push(setupToolbar(cy));
|
|
802
1380
|
|
|
1381
|
+
const persistBeforeUnload = () => {
|
|
1382
|
+
persistCurrentGraphState();
|
|
1383
|
+
};
|
|
1384
|
+
|
|
1385
|
+
window.addEventListener('beforeunload', persistBeforeUnload);
|
|
1386
|
+
currentGraph.cleanup.push(() => {
|
|
1387
|
+
window.removeEventListener('beforeunload', persistBeforeUnload);
|
|
1388
|
+
});
|
|
1389
|
+
|
|
803
1390
|
cy.on('tap', 'node', event => {
|
|
804
1391
|
applyTableSelection(event.target, { focus: false });
|
|
1392
|
+
scheduleGraphStatePersist();
|
|
805
1393
|
});
|
|
806
1394
|
|
|
807
1395
|
cy.on('tap', event => {
|
|
@@ -810,16 +1398,38 @@ export async function mountStructureGraph(snapshot) {
|
|
|
810
1398
|
}
|
|
811
1399
|
});
|
|
812
1400
|
|
|
1401
|
+
cy.on('tap', 'edge', event => {
|
|
1402
|
+
applyEdgeSelection(event.target);
|
|
1403
|
+
showEdgeReadout(event.target, event.renderedPosition);
|
|
1404
|
+
updateOpenDataButton();
|
|
1405
|
+
scheduleGraphStatePersist();
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
cy.on('dragfree', 'node', () => {
|
|
1409
|
+
scheduleGraphStatePersist(0);
|
|
1410
|
+
});
|
|
1411
|
+
|
|
813
1412
|
cy.on('mouseover', 'edge', event => {
|
|
814
1413
|
const edge = event.target;
|
|
815
1414
|
highlightConnectedElements(cy, edge);
|
|
816
1415
|
syncEntryHighlights([], [edge.data('sourceTable'), edge.data('targetTable')]);
|
|
1416
|
+
showEdgeReadout(edge, event.renderedPosition);
|
|
1417
|
+
});
|
|
1418
|
+
|
|
1419
|
+
cy.on('mousemove', 'edge', event => {
|
|
1420
|
+
showEdgeReadout(event.target, event.renderedPosition);
|
|
817
1421
|
});
|
|
818
1422
|
|
|
819
1423
|
cy.on('mouseout', 'edge', () => {
|
|
1424
|
+
hideEdgeReadout();
|
|
820
1425
|
restoreGraphState();
|
|
821
1426
|
});
|
|
822
1427
|
|
|
1428
|
+
cy.on('pan zoom', () => {
|
|
1429
|
+
hideEdgeReadout();
|
|
1430
|
+
scheduleGraphStatePersist(300);
|
|
1431
|
+
});
|
|
1432
|
+
|
|
823
1433
|
const selectedTableName = snapshot.structure.data?.grouped?.tables?.some(
|
|
824
1434
|
table => table.name === snapshot.structure.selectedName,
|
|
825
1435
|
)
|