sqlite-hub 0.2.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,925 @@
1
+ import { escapeHtml, formatNumber } from "../utils/format.js";
2
+
3
+ let cytoscapeFactory = null;
4
+ let currentGraph = null;
5
+ let mountVersion = 0;
6
+ let persistedGraphState = null;
7
+
8
+ function getTableId(tableName) {
9
+ return `table:${tableName}`;
10
+ }
11
+
12
+ function getSchemaSignature(schema) {
13
+ return JSON.stringify(
14
+ (schema?.tables ?? []).map((table) => ({
15
+ name: table.name,
16
+ columns: (table.columns ?? []).map((column) => column.name),
17
+ foreignKeys: (table.foreignKeys ?? []).map((foreignKey) => ({
18
+ referencedTable: foreignKey.referencedTable,
19
+ mappings: (foreignKey.mappings ?? []).map(
20
+ (mapping) => `${mapping.from || "?"}->${mapping.to || "rowid"}`
21
+ ),
22
+ })),
23
+ }))
24
+ );
25
+ }
26
+
27
+ function getPersistedGraphState(schema) {
28
+ const schemaSignature = getSchemaSignature(schema);
29
+
30
+ if (persistedGraphState?.schemaSignature !== schemaSignature) {
31
+ return {
32
+ schemaSignature,
33
+ state: null,
34
+ };
35
+ }
36
+
37
+ return {
38
+ schemaSignature,
39
+ state: persistedGraphState,
40
+ };
41
+ }
42
+
43
+ function collectForeignKeyColumns(tableData) {
44
+ const columns = new Set();
45
+
46
+ (tableData?.foreignKeys ?? []).forEach((foreignKey) => {
47
+ (foreignKey.mappings ?? []).forEach((mapping) => {
48
+ if (mapping.from) {
49
+ columns.add(mapping.from);
50
+ }
51
+ });
52
+ });
53
+
54
+ return columns;
55
+ }
56
+
57
+ function getVisibleColumns(tableData) {
58
+ return (tableData?.columns ?? []).filter((column) => column.visible !== false);
59
+ }
60
+
61
+ function getRelationshipCount(tableData) {
62
+ return (tableData?.foreignKeys ?? []).reduce(
63
+ (count, foreignKey) => count + (foreignKey.mappings?.length ?? 0),
64
+ 0
65
+ );
66
+ }
67
+
68
+ function createSummaryCard(label, value) {
69
+ return `
70
+ <div class="structure-graph__summary-card">
71
+ <div class="structure-graph__summary-label">${escapeHtml(label)}</div>
72
+ <div class="structure-graph__summary-value">${escapeHtml(String(value))}</div>
73
+ </div>
74
+ `;
75
+ }
76
+
77
+ function createColumnFlags(column, foreignKeyColumns) {
78
+ const flags = [];
79
+
80
+ if (column.primaryKeyPosition > 0) {
81
+ flags.push(
82
+ `<span class="structure-graph__flag is-key">PK${
83
+ column.primaryKeyPosition > 1 ? ` ${escapeHtml(String(column.primaryKeyPosition))}` : ""
84
+ }</span>`
85
+ );
86
+ }
87
+
88
+ if (foreignKeyColumns.has(column.name)) {
89
+ flags.push('<span class="structure-graph__flag is-link">FK</span>');
90
+ }
91
+
92
+ flags.push(
93
+ `<span class="structure-graph__flag is-nullable">${
94
+ column.notNull ? "NOT NULL" : "NULLABLE"
95
+ }</span>`
96
+ );
97
+
98
+ return flags.join("");
99
+ }
100
+
101
+ export function clearInspector() {
102
+ return `
103
+ <div class="structure-graph__panel is-empty">
104
+ <span class="material-symbols-outlined structure-graph__empty-icon">hub</span>
105
+ <div class="structure-graph__title">Graph Ready</div>
106
+ <p class="text-sm leading-7 text-on-surface-variant/55">
107
+ Select a table node to inspect its columns, key markers, and foreign-key links.
108
+ </p>
109
+ </div>
110
+ `;
111
+ }
112
+
113
+ export function renderInspector(tableData) {
114
+ if (!tableData || tableData.type !== "table") {
115
+ return clearInspector();
116
+ }
117
+
118
+ const visibleColumns = getVisibleColumns(tableData);
119
+ const foreignKeyColumns = collectForeignKeyColumns(tableData);
120
+ const primaryKeyCount = visibleColumns.filter((column) => column.primaryKeyPosition > 0).length;
121
+ const foreignKeyCount = getRelationshipCount(tableData);
122
+ const nullableCount = visibleColumns.filter((column) => !column.notNull).length;
123
+
124
+ return `
125
+ <div class="structure-graph__panel">
126
+ <div class="space-y-3">
127
+ <div class="structure-graph__eyebrow">Table Inspector</div>
128
+ <div class="structure-graph__title">${escapeHtml(tableData.name)}</div>
129
+ <div class="flex flex-wrap items-center gap-3">
130
+ <div class="structure-graph__subtitle">${escapeHtml(tableData.type)}</div>
131
+ </div>
132
+ </div>
133
+
134
+ <div class="structure-graph__summary">
135
+ ${createSummaryCard("Columns", formatNumber(visibleColumns.length))}
136
+ ${createSummaryCard("PK Columns", formatNumber(primaryKeyCount))}
137
+ ${createSummaryCard("FK Links", formatNumber(foreignKeyCount))}
138
+ ${createSummaryCard("Nullable", formatNumber(nullableCount))}
139
+ </div>
140
+
141
+ <section class="structure-graph__section">
142
+ <div class="structure-graph__section-title">Columns</div>
143
+ <div class="structure-graph__column-list">
144
+ ${
145
+ visibleColumns.length
146
+ ? visibleColumns
147
+ .map(
148
+ (column) => `
149
+ <div class="structure-graph__column-row">
150
+ <div class="min-w-0 space-y-1">
151
+ <div class="structure-graph__column-name">${escapeHtml(column.name)}</div>
152
+ <div class="structure-graph__column-type">${escapeHtml(
153
+ column.declaredType || column.affinity || "BLOB"
154
+ )}</div>
155
+ </div>
156
+ <div class="structure-graph__column-flags">
157
+ ${createColumnFlags(column, foreignKeyColumns)}
158
+ </div>
159
+ </div>
160
+ `
161
+ )
162
+ .join("")
163
+ : '<div class="text-sm text-on-surface-variant/45">No visible columns found.</div>'
164
+ }
165
+ </div>
166
+ </section>
167
+
168
+ <section class="structure-graph__section">
169
+ <div class="structure-graph__section-title">DDL</div>
170
+ <pre class="structure-graph__ddl custom-scrollbar">${escapeHtml(
171
+ tableData.ddl || "No DDL available."
172
+ )}</pre>
173
+ </section>
174
+ </div>
175
+ `;
176
+ }
177
+
178
+ export function buildGraphElements(schema) {
179
+ const tables = schema?.tables ?? [];
180
+ const tableMap = new Map(tables.map((table) => [table.name, table]));
181
+ const nodes = tables.map((table) => ({
182
+ group: "nodes",
183
+ data: {
184
+ id: getTableId(table.name),
185
+ label: table.name,
186
+ tableName: table.name,
187
+ width: Math.max(184, table.name.length * 9 + 64),
188
+ height: 56,
189
+ table,
190
+ },
191
+ }));
192
+ const edges = [];
193
+
194
+ tables.forEach((table) => {
195
+ (table.foreignKeys ?? []).forEach((foreignKey, foreignKeyIndex) => {
196
+ if (!tableMap.has(foreignKey.referencedTable)) {
197
+ return;
198
+ }
199
+
200
+ (foreignKey.mappings ?? []).forEach((mapping, mappingIndex) => {
201
+ edges.push({
202
+ group: "edges",
203
+ data: {
204
+ id: [
205
+ "edge",
206
+ table.name,
207
+ foreignKey.referencedTable,
208
+ mapping.from || `source_${mappingIndex}`,
209
+ mapping.to || `target_${mappingIndex}`,
210
+ foreignKeyIndex,
211
+ mappingIndex,
212
+ ].join(":"),
213
+ source: getTableId(table.name),
214
+ target: getTableId(foreignKey.referencedTable),
215
+ label: `${mapping.from || "?"} → ${mapping.to || "rowid"}`,
216
+ sourceTable: table.name,
217
+ targetTable: foreignKey.referencedTable,
218
+ sourceColumn: mapping.from || "",
219
+ targetColumn: mapping.to || "rowid",
220
+ },
221
+ });
222
+ });
223
+ });
224
+ });
225
+
226
+ return [...nodes, ...edges];
227
+ }
228
+
229
+ export function getElkLayoutOptions(overrides = {}) {
230
+ return {
231
+ name: "elk",
232
+ fit: false,
233
+ padding: 30,
234
+ animate: false,
235
+ nodeDimensionsIncludeLabels: false,
236
+ elk: {
237
+ algorithm: "layered",
238
+ "elk.direction": "RIGHT",
239
+ "elk.edgeRouting": "ORTHOGONAL",
240
+ "elk.layered.spacing.nodeNodeBetweenLayers": 60,
241
+ "elk.spacing.nodeNode": 40,
242
+ "elk.padding": 30,
243
+ },
244
+ ...overrides,
245
+ };
246
+ }
247
+
248
+ export function createCytoscapeInstance(container, elements) {
249
+ if (!cytoscapeFactory) {
250
+ throw new Error("Cytoscape runtime has not been initialized.");
251
+ }
252
+
253
+ return cytoscapeFactory({
254
+ container,
255
+ elements,
256
+ layout: { name: "preset" },
257
+ minZoom: 0.2,
258
+ maxZoom: 2.5,
259
+ wheelSensitivity: 0.18,
260
+ motionBlur: false,
261
+ textureOnViewport: true,
262
+ boxSelectionEnabled: false,
263
+ style: [
264
+ {
265
+ selector: "node",
266
+ style: {
267
+ shape: "roundrectangle",
268
+ width: "data(width)",
269
+ height: "data(height)",
270
+ label: "data(label)",
271
+ "background-color": "#262421",
272
+ "border-color": "#8a7b34",
273
+ "border-width": 1.4,
274
+ color: "#f4efe8",
275
+ "font-family": "Space Grotesk, Inter, sans-serif",
276
+ "font-size": 13,
277
+ "font-weight": 700,
278
+ "text-wrap": "ellipsis",
279
+ "text-max-width": 150,
280
+ "text-halign": "center",
281
+ "text-valign": "center",
282
+ "overlay-opacity": 0,
283
+ "transition-property": "background-color, border-color, opacity, color",
284
+ "transition-duration": "140ms",
285
+ },
286
+ },
287
+ {
288
+ selector: "node.selected",
289
+ style: {
290
+ "background-color": "#3b340b",
291
+ "border-color": "#fce300",
292
+ "border-width": 2.8,
293
+ color: "#fff6ae",
294
+ },
295
+ },
296
+ {
297
+ selector: "node.related",
298
+ style: {
299
+ "background-color": "#0d2d30",
300
+ "border-color": "#2dfaff",
301
+ "border-width": 2.2,
302
+ color: "#fbfffe",
303
+ },
304
+ },
305
+ {
306
+ selector: "node.dimmed",
307
+ style: {
308
+ opacity: 0.24,
309
+ },
310
+ },
311
+ {
312
+ selector: "edge",
313
+ style: {
314
+ width: 1.6,
315
+ label: "data(label)",
316
+ color: "#e7dfbd",
317
+ "font-family": "Roboto Mono, monospace",
318
+ "font-size": 8,
319
+ "text-background-color": "#171715",
320
+ "text-background-opacity": 0.94,
321
+ "text-background-padding": 4,
322
+ "line-color": "#8a7b34",
323
+ "target-arrow-color": "#8a7b34",
324
+ "target-arrow-shape": "triangle",
325
+ "curve-style": "taxi",
326
+ "taxi-direction": "rightward",
327
+ "taxi-turn": 20,
328
+ "source-endpoint": "outside-to-node",
329
+ "target-endpoint": "outside-to-node",
330
+ "overlay-opacity": 0,
331
+ "transition-property": "line-color, target-arrow-color, opacity, color",
332
+ "transition-duration": "120ms",
333
+ },
334
+ },
335
+ {
336
+ selector: "edge.related",
337
+ style: {
338
+ "line-color": "#2dfaff",
339
+ "target-arrow-color": "#2dfaff",
340
+ color: "#fbfffe",
341
+ width: 2.3,
342
+ },
343
+ },
344
+ {
345
+ selector: "edge.hovered",
346
+ style: {
347
+ "line-color": "#fce300",
348
+ "target-arrow-color": "#fce300",
349
+ color: "#fff6ae",
350
+ width: 2.8,
351
+ },
352
+ },
353
+ {
354
+ selector: "edge.dimmed",
355
+ style: {
356
+ opacity: 0.16,
357
+ },
358
+ },
359
+ ],
360
+ });
361
+ }
362
+
363
+ export function resetHighlights(cy) {
364
+ if (!cy) {
365
+ return;
366
+ }
367
+
368
+ cy.elements().removeClass("selected related hovered dimmed");
369
+ }
370
+
371
+ export function highlightConnectedElements(cy, element) {
372
+ if (!cy || !element) {
373
+ return;
374
+ }
375
+
376
+ resetHighlights(cy);
377
+ const allElements = cy.elements();
378
+
379
+ if (element.isNode()) {
380
+ const connectedEdges = element.connectedEdges();
381
+ const relatedNodes = connectedEdges.connectedNodes().union(element);
382
+ const highlighted = relatedNodes.union(connectedEdges);
383
+
384
+ allElements.difference(highlighted).addClass("dimmed");
385
+ element.addClass("selected");
386
+ relatedNodes.difference(element).addClass("related");
387
+ connectedEdges.addClass("related");
388
+ return;
389
+ }
390
+
391
+ const source = element.source();
392
+ const target = element.target();
393
+ const highlighted = source.union(target).union(element);
394
+
395
+ allElements.difference(highlighted).addClass("dimmed");
396
+ source.addClass("related");
397
+ target.addClass("related");
398
+ element.addClass("hovered");
399
+ }
400
+
401
+ function getBestMatchingNode(cy, name) {
402
+ const normalizedQuery = String(name ?? "").trim().toLowerCase();
403
+
404
+ if (!normalizedQuery) {
405
+ return null;
406
+ }
407
+
408
+ const nodes = cy.nodes().toArray();
409
+ const exactMatch = nodes.find(
410
+ (node) => String(node.data("tableName")).toLowerCase() === normalizedQuery
411
+ );
412
+
413
+ if (exactMatch) {
414
+ return exactMatch;
415
+ }
416
+
417
+ const startsWithMatch = nodes.find((node) =>
418
+ String(node.data("tableName")).toLowerCase().startsWith(normalizedQuery)
419
+ );
420
+
421
+ if (startsWithMatch) {
422
+ return startsWithMatch;
423
+ }
424
+
425
+ return (
426
+ nodes.find((node) =>
427
+ String(node.data("tableName")).toLowerCase().includes(normalizedQuery)
428
+ ) ?? null
429
+ );
430
+ }
431
+
432
+ export function focusTableByName(cy, name) {
433
+ const node = getBestMatchingNode(cy, name);
434
+
435
+ if (!node) {
436
+ return null;
437
+ }
438
+
439
+ cy.animate(
440
+ {
441
+ fit: {
442
+ eles: node.closedNeighborhood(),
443
+ padding: 120,
444
+ },
445
+ duration: 240,
446
+ },
447
+ {
448
+ queue: false,
449
+ }
450
+ );
451
+
452
+ return node;
453
+ }
454
+
455
+ function destroyCurrentGraph() {
456
+ if (!currentGraph) {
457
+ return;
458
+ }
459
+
460
+ if (currentGraph.cy && currentGraph.persistStateOnDestroy !== false) {
461
+ const nodePositions = {};
462
+ currentGraph.cy.nodes().forEach((node) => {
463
+ nodePositions[node.id()] = {
464
+ x: node.position("x"),
465
+ y: node.position("y"),
466
+ };
467
+ });
468
+
469
+ persistedGraphState = {
470
+ schemaSignature: getSchemaSignature(currentGraph.schema),
471
+ nodePositions,
472
+ pan: currentGraph.cy.pan(),
473
+ zoom: currentGraph.cy.zoom(),
474
+ inspectorHidden: currentGraph.inspectorHidden,
475
+ selectedTableName: currentGraph.selectedTableName,
476
+ searchValue: currentGraph.searchInput?.value ?? "",
477
+ searchMiss: currentGraph.searchRoot?.classList.contains("is-miss") ?? false,
478
+ };
479
+ }
480
+
481
+ currentGraph.cleanup.forEach((cleanup) => cleanup());
482
+ currentGraph.cleanup = [];
483
+
484
+ if (currentGraph.resizeObserver) {
485
+ currentGraph.resizeObserver.disconnect();
486
+ }
487
+
488
+ if (currentGraph.resizeHandler) {
489
+ window.removeEventListener("resize", currentGraph.resizeHandler);
490
+ }
491
+
492
+ if (currentGraph.cy) {
493
+ currentGraph.cy.destroy();
494
+ }
495
+
496
+ currentGraph = null;
497
+ }
498
+
499
+ export function resetPersistedStructureGraphState() {
500
+ persistedGraphState = null;
501
+
502
+ if (currentGraph) {
503
+ currentGraph.persistStateOnDestroy = false;
504
+ }
505
+ }
506
+
507
+ function syncEntryHighlights(selectedNames = [], relatedNames = []) {
508
+ if (!currentGraph?.root) {
509
+ return;
510
+ }
511
+
512
+ const selectedSet = new Set(selectedNames);
513
+ const relatedSet = new Set(relatedNames);
514
+ const scope = currentGraph.root.closest(".view-frame") ?? document;
515
+
516
+ scope
517
+ .querySelectorAll("[data-structure-entry-name]")
518
+ .forEach((node) => {
519
+ const tableName = node.dataset.structureEntryName ?? "";
520
+ node.classList.toggle("structure-entry--graph-active", selectedSet.has(tableName));
521
+ node.classList.toggle("structure-entry--graph-related", relatedSet.has(tableName));
522
+ });
523
+ }
524
+
525
+ function getDefaultInspectorMarkup() {
526
+ if (!currentGraph) {
527
+ return clearInspector();
528
+ }
529
+
530
+ return currentGraph.initialSelectedTableName
531
+ ? clearInspector()
532
+ : currentGraph.initialInspectorMarkup || clearInspector();
533
+ }
534
+
535
+ function updateOpenDataButton() {
536
+ if (!currentGraph?.openDataButton) {
537
+ return;
538
+ }
539
+
540
+ const isEnabled = Boolean(currentGraph.selectedTableName);
541
+ currentGraph.openDataButton.disabled = !isEnabled;
542
+ currentGraph.openDataButton.classList.toggle("is-disabled", !isEnabled);
543
+ }
544
+
545
+ function syncInspectorLayout() {
546
+ if (!currentGraph?.root) {
547
+ return;
548
+ }
549
+
550
+ currentGraph.root.classList.toggle("is-inspector-hidden", currentGraph.inspectorHidden);
551
+ currentGraph.cy?.resize();
552
+ }
553
+
554
+ function updateInspectorToggleButton() {
555
+ if (!currentGraph?.inspectorToggleButton) {
556
+ return;
557
+ }
558
+
559
+ currentGraph.inspectorToggleButton.classList.toggle("is-active", currentGraph.inspectorHidden);
560
+ currentGraph.inspectorToggleButton.innerHTML = `
561
+ <span class="material-symbols-outlined text-sm">${
562
+ currentGraph.inspectorHidden ? "right_panel_open" : "right_panel_close"
563
+ }</span>
564
+ ${currentGraph.inspectorHidden ? "Show Inspector" : "Hide Inspector"}
565
+ `;
566
+ }
567
+
568
+ function clearSelection({ preserveSearch = true } = {}) {
569
+ if (!currentGraph) {
570
+ return;
571
+ }
572
+
573
+ currentGraph.selectedTableName = null;
574
+ if (!preserveSearch && currentGraph.searchInput) {
575
+ currentGraph.searchInput.value = "";
576
+ currentGraph.searchRoot.classList.remove("is-miss");
577
+ }
578
+
579
+ resetHighlights(currentGraph.cy);
580
+ syncEntryHighlights();
581
+ currentGraph.inspector.innerHTML = getDefaultInspectorMarkup();
582
+ updateOpenDataButton();
583
+ }
584
+
585
+ function applyTableSelection(node, { focus = true } = {}) {
586
+ if (!currentGraph || !node) {
587
+ return null;
588
+ }
589
+
590
+ const tableData = node.data("table");
591
+ currentGraph.selectedTableName = tableData.name;
592
+ currentGraph.inspector.innerHTML = renderInspector(tableData);
593
+ highlightConnectedElements(currentGraph.cy, node);
594
+ syncEntryHighlights([tableData.name]);
595
+ updateOpenDataButton();
596
+
597
+ if (focus) {
598
+ currentGraph.cy.animate(
599
+ {
600
+ fit: {
601
+ eles: node.closedNeighborhood(),
602
+ padding: 120,
603
+ },
604
+ duration: 240,
605
+ },
606
+ {
607
+ queue: false,
608
+ }
609
+ );
610
+ }
611
+
612
+ return node;
613
+ }
614
+
615
+ function restoreGraphState() {
616
+ if (!currentGraph) {
617
+ return;
618
+ }
619
+
620
+ if (currentGraph.selectedTableName) {
621
+ const selectedNode = currentGraph.cy.getElementById(
622
+ getTableId(currentGraph.selectedTableName)
623
+ );
624
+
625
+ if (selectedNode.nonempty()) {
626
+ highlightConnectedElements(currentGraph.cy, selectedNode);
627
+ syncEntryHighlights([currentGraph.selectedTableName]);
628
+ return;
629
+ }
630
+ }
631
+
632
+ resetHighlights(currentGraph.cy);
633
+ syncEntryHighlights();
634
+ updateOpenDataButton();
635
+ }
636
+
637
+ function runLayout(cy, onStop) {
638
+ const layout = cy.layout(
639
+ getElkLayoutOptions({
640
+ stop: () => {
641
+ cy.fit(cy.elements(), 60);
642
+ if (typeof onStop === "function") {
643
+ onStop();
644
+ }
645
+ },
646
+ })
647
+ );
648
+
649
+ layout.run();
650
+ }
651
+
652
+ function restorePersistedViewport(cy, persistedState) {
653
+ if (!persistedState?.nodePositions) {
654
+ return false;
655
+ }
656
+
657
+ cy.batch(() => {
658
+ cy.nodes().forEach((node) => {
659
+ const nextPosition = persistedState.nodePositions[node.id()];
660
+
661
+ if (!nextPosition) {
662
+ return;
663
+ }
664
+
665
+ node.position(nextPosition);
666
+ });
667
+ });
668
+
669
+ if (typeof persistedState.zoom === "number") {
670
+ cy.zoom(persistedState.zoom);
671
+ }
672
+
673
+ if (persistedState.pan) {
674
+ cy.pan(persistedState.pan);
675
+ }
676
+
677
+ return true;
678
+ }
679
+
680
+ export function setupToolbar(cy, schema) {
681
+ if (!currentGraph) {
682
+ return () => {};
683
+ }
684
+
685
+ const { root } = currentGraph;
686
+ const searchRoot = root.querySelector("[data-structure-graph-search]");
687
+ const searchInput = root.querySelector("[data-structure-graph-search-input]");
688
+ const openDataButton = root.querySelector('[data-structure-graph-action="open-data"]');
689
+ const inspectorToggleButton = root.querySelector(
690
+ '[data-structure-graph-action="toggle-inspector"]'
691
+ );
692
+ let searchTimer = null;
693
+
694
+ currentGraph.searchRoot = searchRoot;
695
+ currentGraph.searchInput = searchInput;
696
+ currentGraph.openDataButton = openDataButton;
697
+ currentGraph.inspectorToggleButton = inspectorToggleButton;
698
+ updateOpenDataButton();
699
+ updateInspectorToggleButton();
700
+ syncInspectorLayout();
701
+
702
+ const handleSearch = () => {
703
+ if (!searchInput || !searchRoot) {
704
+ return;
705
+ }
706
+
707
+ const value = searchInput.value.trim();
708
+
709
+ if (!value) {
710
+ searchRoot.classList.remove("is-miss");
711
+ restoreGraphState();
712
+ if (!currentGraph.selectedTableName) {
713
+ currentGraph.inspector.innerHTML = getDefaultInspectorMarkup();
714
+ }
715
+ return;
716
+ }
717
+
718
+ const matchedNode = focusTableByName(cy, value);
719
+
720
+ if (!matchedNode) {
721
+ searchRoot.classList.add("is-miss");
722
+ return;
723
+ }
724
+
725
+ searchRoot.classList.remove("is-miss");
726
+ applyTableSelection(matchedNode, { focus: true });
727
+ };
728
+
729
+ const onSearchInput = () => {
730
+ window.clearTimeout(searchTimer);
731
+ searchTimer = window.setTimeout(handleSearch, 140);
732
+ };
733
+
734
+ const onToolbarClick = (event) => {
735
+ const button = event.target.closest("[data-structure-graph-action]");
736
+
737
+ if (!button) {
738
+ return;
739
+ }
740
+
741
+ switch (button.dataset.structureGraphAction) {
742
+ case "fit":
743
+ cy.fit(cy.elements(), 60);
744
+ break;
745
+ case "relayout":
746
+ runLayout(cy, () => {
747
+ if (currentGraph?.selectedTableName) {
748
+ const selectedNode = cy.getElementById(
749
+ getTableId(currentGraph.selectedTableName)
750
+ );
751
+ if (selectedNode.nonempty()) {
752
+ applyTableSelection(selectedNode, { focus: true });
753
+ }
754
+ }
755
+ });
756
+ break;
757
+ case "clear":
758
+ clearSelection({ preserveSearch: false });
759
+ break;
760
+ case "open-data":
761
+ if (currentGraph?.selectedTableName) {
762
+ window.location.hash = `#/data/${encodeURIComponent(currentGraph.selectedTableName)}`;
763
+ }
764
+ break;
765
+ case "toggle-inspector":
766
+ currentGraph.inspectorHidden = !currentGraph.inspectorHidden;
767
+ updateInspectorToggleButton();
768
+ syncInspectorLayout();
769
+ break;
770
+ default:
771
+ }
772
+ };
773
+
774
+ searchInput?.addEventListener("input", onSearchInput);
775
+ root.addEventListener("click", onToolbarClick);
776
+
777
+ return () => {
778
+ window.clearTimeout(searchTimer);
779
+ searchInput?.removeEventListener("input", onSearchInput);
780
+ root.removeEventListener("click", onToolbarClick);
781
+ };
782
+ }
783
+
784
+ export function teardownStructureGraph() {
785
+ mountVersion += 1;
786
+ destroyCurrentGraph();
787
+ }
788
+
789
+ export async function mountStructureGraph(snapshot) {
790
+ const root = document.querySelector("[data-structure-graph-root]");
791
+
792
+ if (!root) {
793
+ teardownStructureGraph();
794
+ return;
795
+ }
796
+
797
+ const version = ++mountVersion;
798
+ destroyCurrentGraph();
799
+
800
+ const [{ getCytoscape }] = await Promise.all([import("../lib/cytoscapeRuntime.js")]);
801
+
802
+ if (version !== mountVersion || !root.isConnected) {
803
+ return;
804
+ }
805
+
806
+ cytoscapeFactory = getCytoscape();
807
+
808
+ const canvas = root.querySelector("[data-structure-graph-canvas]");
809
+ const inspector = root.querySelector("[data-structure-graph-inspector]");
810
+ const empty = root.querySelector("[data-structure-graph-empty]");
811
+ const schema = snapshot.structure.data?.graph ?? { tables: [] };
812
+ const { schemaSignature, state: cachedState } = getPersistedGraphState(schema);
813
+
814
+ if (!(canvas instanceof HTMLElement) || !(inspector instanceof HTMLElement)) {
815
+ return;
816
+ }
817
+
818
+ if (!schema.tables?.length) {
819
+ empty?.removeAttribute("hidden");
820
+ inspector.innerHTML = clearInspector();
821
+ return;
822
+ }
823
+
824
+ empty?.setAttribute("hidden", "hidden");
825
+
826
+ const cy = createCytoscapeInstance(canvas, buildGraphElements(schema));
827
+
828
+ currentGraph = {
829
+ root,
830
+ canvas,
831
+ inspector,
832
+ initialInspectorMarkup: inspector.innerHTML,
833
+ initialSelectedTableName: null,
834
+ schemaSignature,
835
+ schema,
836
+ cy,
837
+ cleanup: [],
838
+ resizeObserver: null,
839
+ resizeHandler: null,
840
+ searchRoot: null,
841
+ searchInput: null,
842
+ openDataButton: null,
843
+ inspectorToggleButton: null,
844
+ inspectorHidden: cachedState?.inspectorHidden ?? false,
845
+ persistStateOnDestroy: true,
846
+ selectedTableName: null,
847
+ };
848
+
849
+ currentGraph.cleanup.push(setupToolbar(cy, schema));
850
+
851
+ cy.on("tap", "node", (event) => {
852
+ applyTableSelection(event.target, { focus: false });
853
+ });
854
+
855
+ cy.on("tap", (event) => {
856
+ if (event.target === cy) {
857
+ clearSelection();
858
+ }
859
+ });
860
+
861
+ cy.on("mouseover", "edge", (event) => {
862
+ const edge = event.target;
863
+ highlightConnectedElements(cy, edge);
864
+ syncEntryHighlights([], [edge.data("sourceTable"), edge.data("targetTable")]);
865
+ });
866
+
867
+ cy.on("mouseout", "edge", () => {
868
+ restoreGraphState();
869
+ });
870
+
871
+ const selectedTableName =
872
+ snapshot.structure.data?.grouped?.tables?.some(
873
+ (table) => table.name === snapshot.structure.selectedName
874
+ )
875
+ ? snapshot.structure.selectedName
876
+ : null;
877
+
878
+ currentGraph.initialSelectedTableName = selectedTableName;
879
+
880
+ if (currentGraph.searchInput && cachedState?.searchValue) {
881
+ currentGraph.searchInput.value = cachedState.searchValue;
882
+ }
883
+
884
+ if (currentGraph.searchRoot) {
885
+ currentGraph.searchRoot.classList.toggle("is-miss", Boolean(cachedState?.searchMiss));
886
+ }
887
+
888
+ if (restorePersistedViewport(cy, cachedState)) {
889
+ if (selectedTableName) {
890
+ const selectedNode = cy.getElementById(getTableId(selectedTableName));
891
+ if (selectedNode.nonempty()) {
892
+ applyTableSelection(selectedNode, { focus: false });
893
+ } else {
894
+ restoreGraphState();
895
+ }
896
+ } else {
897
+ restoreGraphState();
898
+ }
899
+ } else {
900
+ runLayout(cy, () => {
901
+ if (!currentGraph || version !== mountVersion) {
902
+ return;
903
+ }
904
+
905
+ if (selectedTableName) {
906
+ const selectedNode = cy.getElementById(getTableId(selectedTableName));
907
+ if (selectedNode.nonempty()) {
908
+ applyTableSelection(selectedNode, { focus: false });
909
+ }
910
+ }
911
+ });
912
+ }
913
+
914
+ if (typeof ResizeObserver === "function") {
915
+ currentGraph.resizeObserver = new ResizeObserver(() => {
916
+ cy.resize();
917
+ });
918
+ currentGraph.resizeObserver.observe(canvas);
919
+ } else {
920
+ currentGraph.resizeHandler = () => {
921
+ cy.resize();
922
+ };
923
+ window.addEventListener("resize", currentGraph.resizeHandler);
924
+ }
925
+ }