@reddb-io/ui 0.1.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.
Files changed (169) hide show
  1. package/LICENSE +17 -0
  2. package/bin/red-ui.js +259 -0
  3. package/build/CNAME +1 -0
  4. package/build/_app/env.js +1 -0
  5. package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
  6. package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
  7. package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
  8. package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
  9. package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
  10. package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
  11. package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
  12. package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
  13. package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
  14. package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
  15. package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
  16. package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
  17. package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
  18. package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
  19. package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
  20. package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
  21. package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
  22. package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
  23. package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
  24. package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
  25. package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
  26. package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
  27. package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
  28. package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
  29. package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
  30. package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
  31. package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
  32. package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
  33. package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
  34. package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
  35. package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
  36. package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
  37. package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
  38. package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
  39. package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
  40. package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
  41. package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
  42. package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
  43. package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
  44. package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
  45. package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
  46. package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
  47. package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
  48. package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
  49. package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
  50. package/build/_app/version.json +1 -0
  51. package/build/favicon.png +0 -0
  52. package/build/index.html +59 -0
  53. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
  54. package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
  55. package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
  56. package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
  57. package/dist/embed/core-QuRYvMt8.js +118 -0
  58. package/dist/embed/core-QuRYvMt8.js.map +1 -0
  59. package/dist/embed/index-B6ZQhCsu.js +577 -0
  60. package/dist/embed/index-B6ZQhCsu.js.map +1 -0
  61. package/dist/embed/index-client-ItCEU64C.js +3087 -0
  62. package/dist/embed/index-client-ItCEU64C.js.map +1 -0
  63. package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
  64. package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
  65. package/dist/embed/index.js +11 -0
  66. package/dist/embed/index.js.map +1 -0
  67. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
  68. package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
  69. package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
  70. package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
  71. package/dist/embed/ui.css +1 -0
  72. package/package.json +101 -0
  73. package/src/app.css +238 -0
  74. package/src/lib/ActivityIndicator.svelte +100 -0
  75. package/src/lib/ClusterNode.svelte +119 -0
  76. package/src/lib/ClusterView.svelte +541 -0
  77. package/src/lib/CollectionHistory.svelte +266 -0
  78. package/src/lib/CollectionsView.svelte +14 -0
  79. package/src/lib/CommandPalette.svelte +237 -0
  80. package/src/lib/ConnectDropdown.svelte +222 -0
  81. package/src/lib/EmptyState.svelte +27 -0
  82. package/src/lib/LiveChanges.svelte +195 -0
  83. package/src/lib/MasterPasswordDialog.svelte +168 -0
  84. package/src/lib/PageHeader.svelte +32 -0
  85. package/src/lib/PendingChangesPanel.svelte +153 -0
  86. package/src/lib/QueryEditor.svelte +122 -0
  87. package/src/lib/ResultsPane.svelte +747 -0
  88. package/src/lib/SchemaTree.svelte +193 -0
  89. package/src/lib/SecurityView.svelte +255 -0
  90. package/src/lib/ShortcutOverlay.svelte +104 -0
  91. package/src/lib/Splash.svelte +46 -0
  92. package/src/lib/StatusBar.svelte +163 -0
  93. package/src/lib/SubNav.svelte +62 -0
  94. package/src/lib/Topbar.svelte +119 -0
  95. package/src/lib/Workspace.svelte +247 -0
  96. package/src/lib/activity.svelte.ts +91 -0
  97. package/src/lib/build-info.ts +41 -0
  98. package/src/lib/capability.ts +221 -0
  99. package/src/lib/cn.ts +6 -0
  100. package/src/lib/collection-catalog.ts +116 -0
  101. package/src/lib/collection-pages.ts +33 -0
  102. package/src/lib/connection-history.ts +301 -0
  103. package/src/lib/connections.svelte.ts +493 -0
  104. package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
  105. package/src/lib/embed/index.ts +234 -0
  106. package/src/lib/graph-loader/contract.ts +336 -0
  107. package/src/lib/graph-loader/drilldown.ts +334 -0
  108. package/src/lib/graph-loader/index.ts +13 -0
  109. package/src/lib/index.ts +114 -0
  110. package/src/lib/pending-changes.svelte.ts +149 -0
  111. package/src/lib/query-tabs.svelte.ts +130 -0
  112. package/src/lib/reddb/boot-params.ts +254 -0
  113. package/src/lib/reddb/cdc-stream-client.ts +234 -0
  114. package/src/lib/reddb/client.ts +698 -0
  115. package/src/lib/reddb/connection-bootstrap.ts +260 -0
  116. package/src/lib/reddb/connection-provider.contract.ts +72 -0
  117. package/src/lib/reddb/connection-provider.ts +72 -0
  118. package/src/lib/reddb/index.ts +14 -0
  119. package/src/lib/reddb/injected-client-provider.ts +76 -0
  120. package/src/lib/reddb/local-url-provider.ts +245 -0
  121. package/src/lib/reddb/secure-store.ts +41 -0
  122. package/src/lib/reddb/server-capabilities.ts +71 -0
  123. package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
  124. package/src/lib/reddb/transports.ts +89 -0
  125. package/src/lib/reddb/types.ts +32 -0
  126. package/src/lib/reddb/uri.ts +54 -0
  127. package/src/lib/reddb/web-encrypted-store.ts +201 -0
  128. package/src/lib/renderers/DiffRenderer.svelte +184 -0
  129. package/src/lib/renderers/DocumentRenderer.svelte +134 -0
  130. package/src/lib/renderers/GraphRenderer.svelte +1328 -0
  131. package/src/lib/renderers/HypertableRenderer.svelte +253 -0
  132. package/src/lib/renderers/JsonRenderer.svelte +30 -0
  133. package/src/lib/renderers/KvRenderer.svelte +150 -0
  134. package/src/lib/renderers/QueueRenderer.svelte +286 -0
  135. package/src/lib/renderers/StatsRenderer.svelte +123 -0
  136. package/src/lib/renderers/TableRenderer.svelte +440 -0
  137. package/src/lib/renderers/VectorRenderer.svelte +388 -0
  138. package/src/lib/renderers/diff-render.ts +122 -0
  139. package/src/lib/renderers/diff.ts +9 -0
  140. package/src/lib/renderers/document-render.ts +64 -0
  141. package/src/lib/renderers/document.ts +9 -0
  142. package/src/lib/renderers/graph-render.ts +631 -0
  143. package/src/lib/renderers/graph-sigma.ts +395 -0
  144. package/src/lib/renderers/graph.ts +9 -0
  145. package/src/lib/renderers/hypertable-render.ts +320 -0
  146. package/src/lib/renderers/hypertable.ts +9 -0
  147. package/src/lib/renderers/index.ts +42 -0
  148. package/src/lib/renderers/json-render.ts +89 -0
  149. package/src/lib/renderers/json.ts +10 -0
  150. package/src/lib/renderers/kv-render.ts +193 -0
  151. package/src/lib/renderers/kv.ts +9 -0
  152. package/src/lib/renderers/queue-render.ts +197 -0
  153. package/src/lib/renderers/queue.ts +9 -0
  154. package/src/lib/renderers/registry.ts +68 -0
  155. package/src/lib/renderers/stats-render.ts +73 -0
  156. package/src/lib/renderers/stats.ts +9 -0
  157. package/src/lib/renderers/table-render.ts +156 -0
  158. package/src/lib/renderers/table.ts +11 -0
  159. package/src/lib/renderers/types.ts +33 -0
  160. package/src/lib/renderers/vector-render.ts +154 -0
  161. package/src/lib/renderers/vector.ts +9 -0
  162. package/src/lib/router.svelte.ts +163 -0
  163. package/src/lib/secureStore.svelte.ts +148 -0
  164. package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
  165. package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
  166. package/src/lib/shortcuts.ts +22 -0
  167. package/src/lib/surface.ts +80 -0
  168. package/src/lib/tabs.svelte.ts +115 -0
  169. package/src/lib/theme.svelte.ts +63 -0
@@ -0,0 +1,631 @@
1
+ // Pure rendering helpers for the graph capability. Shared between the
2
+ // Svelte GraphRenderer (xyflow canvas) and the golden-snapshot test
3
+ // (which checks the adjacency-list / DOM-free shape).
4
+
5
+ import type { QueryResult, QueryRow } from "#reddb";
6
+ import Graph from "graphology";
7
+ import louvain from "graphology-communities-louvain";
8
+ import forceAtlas2 from "graphology-layout-forceatlas2";
9
+
10
+ export interface GraphNode {
11
+ id: string;
12
+ label: string;
13
+ /** Full underlying row/object — surfaced when the user clicks a node. */
14
+ data: Record<string, unknown>;
15
+ }
16
+
17
+ export interface GraphEdge {
18
+ id: string;
19
+ source: string;
20
+ target: string;
21
+ label?: string;
22
+ /** Full underlying row/object — surfaced when the user clicks an edge. */
23
+ data?: Record<string, unknown>;
24
+ }
25
+
26
+ export interface GraphData {
27
+ nodes: GraphNode[];
28
+ edges: GraphEdge[];
29
+ }
30
+
31
+ export interface GraphNodeCentrality {
32
+ degree: number;
33
+ inDegree: number;
34
+ outDegree: number;
35
+ score: number;
36
+ }
37
+
38
+ export type GraphEdgeKind = "imports" | "defines" | "references";
39
+
40
+ export interface GraphNodeRelation {
41
+ id: string;
42
+ kind: GraphEdgeKind;
43
+ direction: "incoming" | "outgoing";
44
+ label: string;
45
+ edgeLabel?: string;
46
+ nodeId: string;
47
+ nodeLabel: string;
48
+ }
49
+
50
+ export interface GraphNodeDetail {
51
+ description: string | null;
52
+ exports: string[];
53
+ orphan: boolean;
54
+ incoming: GraphNodeRelation[];
55
+ outgoing: GraphNodeRelation[];
56
+ whereUsed: GraphNodeRelation[];
57
+ }
58
+
59
+ const CENTRALITY_FIELDS = [
60
+ "centrality_score",
61
+ "centrality",
62
+ "pagerank",
63
+ "page_rank",
64
+ "betweenness",
65
+ "closeness",
66
+ "eigenvector",
67
+ "degree",
68
+ "score",
69
+ ];
70
+
71
+ function asString(v: unknown): string {
72
+ if (v === null || v === undefined) return "";
73
+ return String(v);
74
+ }
75
+
76
+ function asFiniteNumber(v: unknown): number | null {
77
+ const n =
78
+ typeof v === "number"
79
+ ? v
80
+ : typeof v === "string" && v.trim()
81
+ ? Number(v)
82
+ : NaN;
83
+ return Number.isFinite(n) ? n : null;
84
+ }
85
+
86
+ function explicitCentralityScore(node: GraphNode): number | null {
87
+ for (const field of CENTRALITY_FIELDS) {
88
+ const n = asFiniteNumber(node.data[field]);
89
+ if (n !== null) return n;
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function firstString(
95
+ data: Record<string, unknown>,
96
+ fields: string[]
97
+ ): string | null {
98
+ for (const field of fields) {
99
+ const value = data[field];
100
+ if (typeof value === "string" && value.trim()) return value;
101
+ }
102
+ return null;
103
+ }
104
+
105
+ function nestedObject(
106
+ data: Record<string, unknown>,
107
+ field: string
108
+ ): Record<string, unknown> | null {
109
+ const value = data[field];
110
+ return value && typeof value === "object" && !Array.isArray(value)
111
+ ? (value as Record<string, unknown>)
112
+ : null;
113
+ }
114
+
115
+ function stringList(value: unknown): string[] {
116
+ if (!Array.isArray(value)) return [];
117
+ return value
118
+ .filter(
119
+ (item): item is string | number =>
120
+ typeof item === "string" || typeof item === "number"
121
+ )
122
+ .map((item) => String(item).trim())
123
+ .filter(Boolean);
124
+ }
125
+
126
+ export function graphNodeDescription(node: GraphNode): string | null {
127
+ const metadata = nestedObject(node.data, "metadata");
128
+ return (
129
+ firstString(node.data, ["description", "summary", "content"]) ??
130
+ (metadata
131
+ ? firstString(metadata, ["description", "summary", "content"])
132
+ : null)
133
+ );
134
+ }
135
+
136
+ export function graphNodeExports(node: GraphNode): string[] {
137
+ const direct = stringList(node.data.exports);
138
+ if (direct.length > 0) return direct;
139
+ const metadata = nestedObject(node.data, "metadata");
140
+ return metadata ? stringList(metadata.exports) : [];
141
+ }
142
+
143
+ function flagValue(
144
+ data: Record<string, unknown>,
145
+ fields: string[]
146
+ ): boolean | null {
147
+ for (const field of fields) {
148
+ const value = data[field];
149
+ if (typeof value === "boolean") return value;
150
+ if (typeof value === "string") {
151
+ const lowered = value.trim().toLowerCase();
152
+ if (["true", "yes", "1"].includes(lowered)) return true;
153
+ if (["false", "no", "0"].includes(lowered)) return false;
154
+ }
155
+ }
156
+ return null;
157
+ }
158
+
159
+ export function graphNodeIsOrphan(
160
+ node: GraphNode,
161
+ centrality?: Map<string, GraphNodeCentrality>
162
+ ): boolean {
163
+ const metadata = nestedObject(node.data, "metadata");
164
+ const explicit =
165
+ flagValue(node.data, [
166
+ "orphan",
167
+ "is_orphan",
168
+ "isOrphan",
169
+ "dead_code",
170
+ "deadCode",
171
+ ]) ??
172
+ (metadata
173
+ ? flagValue(metadata, [
174
+ "orphan",
175
+ "is_orphan",
176
+ "isOrphan",
177
+ "dead_code",
178
+ "deadCode",
179
+ ])
180
+ : null);
181
+ if (explicit !== null) return explicit;
182
+ return (centrality?.get(node.id)?.inDegree ?? 1) === 0;
183
+ }
184
+
185
+ function normalizeEdgeKind(edge: GraphEdge): GraphEdgeKind {
186
+ const raw = edge.data?.kind ?? edge.data?.type ?? edge.label;
187
+ const value = typeof raw === "string" ? raw.toLowerCase() : "";
188
+ if (value === "imports" || value === "import" || value === "imports_from")
189
+ return "imports";
190
+ if (value === "defines" || value === "defined_in" || value === "contains")
191
+ return "defines";
192
+ return "references";
193
+ }
194
+
195
+ function relationLabel(
196
+ kind: GraphEdgeKind,
197
+ direction: "incoming" | "outgoing"
198
+ ): string {
199
+ if (kind === "imports")
200
+ return direction === "incoming" ? "imported by" : "imports";
201
+ if (kind === "defines")
202
+ return direction === "incoming" ? "defined by" : "defines";
203
+ return direction === "incoming" ? "referenced by" : "references";
204
+ }
205
+
206
+ export function graphNodeDetail(
207
+ node: GraphNode,
208
+ nodes: GraphNode[],
209
+ edges: GraphEdge[],
210
+ centrality?: Map<string, GraphNodeCentrality>
211
+ ): GraphNodeDetail {
212
+ const nodesById = new Map(nodes.map((entry) => [entry.id, entry]));
213
+ const relations: GraphNodeRelation[] = [];
214
+
215
+ for (const edge of edges) {
216
+ const direction =
217
+ edge.source === node.id
218
+ ? "outgoing"
219
+ : edge.target === node.id
220
+ ? "incoming"
221
+ : null;
222
+ if (!direction) continue;
223
+ const otherId = direction === "outgoing" ? edge.target : edge.source;
224
+ const other = nodesById.get(otherId);
225
+ const kind = normalizeEdgeKind(edge);
226
+ relations.push({
227
+ id: edge.id,
228
+ kind,
229
+ direction,
230
+ label: relationLabel(kind, direction),
231
+ edgeLabel: edge.label,
232
+ nodeId: otherId,
233
+ nodeLabel: other?.label ?? otherId,
234
+ });
235
+ }
236
+
237
+ const byKindThenLabel = (a: GraphNodeRelation, b: GraphNodeRelation) =>
238
+ a.kind.localeCompare(b.kind) ||
239
+ a.nodeLabel.localeCompare(b.nodeLabel) ||
240
+ a.nodeId.localeCompare(b.nodeId);
241
+ const incoming = relations
242
+ .filter((relation) => relation.direction === "incoming")
243
+ .sort(byKindThenLabel);
244
+ const outgoing = relations
245
+ .filter((relation) => relation.direction === "outgoing")
246
+ .sort(byKindThenLabel);
247
+
248
+ return {
249
+ description: graphNodeDescription(node),
250
+ exports: graphNodeExports(node),
251
+ orphan: graphNodeIsOrphan(node, centrality),
252
+ incoming,
253
+ outgoing,
254
+ whereUsed: incoming,
255
+ };
256
+ }
257
+
258
+ export function graphNodeCentrality(
259
+ nodes: GraphNode[],
260
+ edges: GraphEdge[]
261
+ ): Map<string, GraphNodeCentrality> {
262
+ const stats = new Map<string, GraphNodeCentrality>();
263
+ for (const node of nodes) {
264
+ stats.set(node.id, {
265
+ degree: 0,
266
+ inDegree: 0,
267
+ outDegree: 0,
268
+ score: explicitCentralityScore(node) ?? 0,
269
+ });
270
+ }
271
+
272
+ for (const edge of edges) {
273
+ const source = stats.get(edge.source);
274
+ const target = stats.get(edge.target);
275
+ if (source) {
276
+ source.outDegree += 1;
277
+ source.degree += 1;
278
+ }
279
+ if (target) {
280
+ target.inDegree += 1;
281
+ target.degree += 1;
282
+ }
283
+ }
284
+
285
+ for (const node of nodes) {
286
+ const stat = stats.get(node.id);
287
+ if (!stat) continue;
288
+ stat.score = explicitCentralityScore(node) ?? stat.degree;
289
+ }
290
+ return stats;
291
+ }
292
+
293
+ export function compareGraphNodesByCentrality(
294
+ a: GraphNode,
295
+ b: GraphNode,
296
+ centrality: Map<string, GraphNodeCentrality>
297
+ ): number {
298
+ const ca = centrality.get(a.id);
299
+ const cb = centrality.get(b.id);
300
+ const scoreDelta = (cb?.score ?? 0) - (ca?.score ?? 0);
301
+ if (scoreDelta !== 0) return scoreDelta;
302
+ const degreeDelta = (cb?.degree ?? 0) - (ca?.degree ?? 0);
303
+ if (degreeDelta !== 0) return degreeDelta;
304
+ const labelDelta = a.label.localeCompare(b.label);
305
+ if (labelDelta !== 0) return labelDelta;
306
+ return a.id.localeCompare(b.id);
307
+ }
308
+
309
+ export function graphNodeIncomingSizeScales(
310
+ nodes: GraphNode[],
311
+ centrality: Map<string, GraphNodeCentrality>
312
+ ): Map<string, number> {
313
+ const values = nodes.map((node) => centrality.get(node.id)?.inDegree ?? 0);
314
+ const max = Math.max(0, ...values);
315
+ if (max <= 0) return new Map(nodes.map((node) => [node.id, 1]));
316
+
317
+ const unique = [...new Set(values)].sort((a, b) => a - b);
318
+ if (unique.length === 1) return new Map(nodes.map((node) => [node.id, 1]));
319
+
320
+ return new Map(
321
+ nodes.map((node) => {
322
+ const incoming = centrality.get(node.id)?.inDegree ?? 0;
323
+ if (incoming <= 0) return [node.id, 1] as const;
324
+ const percentile =
325
+ values.filter((v) => v <= incoming).length / values.length;
326
+ if (percentile > 0.95) return [node.id, 2] as const;
327
+ if (percentile > 0.8) return [node.id, 1.5] as const;
328
+ if (percentile > 0.5) return [node.id, 1.25] as const;
329
+ return [node.id, 1] as const;
330
+ })
331
+ );
332
+ }
333
+
334
+ function pickNodeId(node: Record<string, unknown>): string | null {
335
+ const id = node.id ?? node.rid ?? node._id ?? node.uid;
336
+ if (id === null || id === undefined) return null;
337
+ return String(id);
338
+ }
339
+
340
+ function pickNodeLabel(node: Record<string, unknown>, id: string): string {
341
+ const lbl = node.label ?? node.name ?? node.title;
342
+ if (lbl !== undefined && lbl !== null) return String(lbl);
343
+ return id;
344
+ }
345
+
346
+ function pickEdgeEndpoints(
347
+ edge: Record<string, unknown>
348
+ ): { source: string; target: string } | null {
349
+ const s = edge.source ?? edge.from ?? edge.src ?? edge.start;
350
+ const t = edge.target ?? edge.to ?? edge.dst ?? edge.end;
351
+ if (s === null || s === undefined || t === null || t === undefined)
352
+ return null;
353
+ return { source: String(s), target: String(t) };
354
+ }
355
+
356
+ /**
357
+ * Pull nodes and edges out of a graph-shaped result.
358
+ *
359
+ * A graph query (`MATCH (n)-[r]->(m) RETURN n, r, m`) returns rows where
360
+ * each alias maps to a node or edge object. reddb surfaces these on the
361
+ * QueryRow as optional `nodes` and `edges` maps (keyed by alias). We
362
+ * dedupe nodes by id across rows and edges by (source, target, label).
363
+ */
364
+ export function extractGraph(result: QueryResult): GraphData {
365
+ const nodes = new Map<string, GraphNode>();
366
+ const edges = new Map<string, GraphEdge>();
367
+
368
+ for (const rec of result.result.records as QueryRow[]) {
369
+ // Shape A: graph-query result (`MATCH … RETURN n, r, m`).
370
+ // reddb populates `nodes`/`edges` maps on each row keyed by alias.
371
+ if (rec.nodes) {
372
+ for (const value of Object.values(rec.nodes)) {
373
+ if (!value || typeof value !== "object") continue;
374
+ const node = value as Record<string, unknown>;
375
+ const id = pickNodeId(node);
376
+ if (!id) continue;
377
+ if (!nodes.has(id)) {
378
+ nodes.set(id, { id, label: pickNodeLabel(node, id), data: node });
379
+ }
380
+ }
381
+ }
382
+ if (rec.edges) {
383
+ for (const value of Object.values(rec.edges)) {
384
+ if (!value || typeof value !== "object") continue;
385
+ const edge = value as Record<string, unknown>;
386
+ const ends = pickEdgeEndpoints(edge);
387
+ if (!ends) continue;
388
+ const label = edge.label ?? edge.type ?? edge.rel;
389
+ const key = `${ends.source}->${ends.target}:${asString(label)}`;
390
+ if (!edges.has(key)) {
391
+ edges.set(key, {
392
+ id: key,
393
+ source: ends.source,
394
+ target: ends.target,
395
+ label: label !== undefined ? String(label) : undefined,
396
+ data: edge,
397
+ });
398
+ }
399
+ }
400
+ }
401
+
402
+ // Shape B: row-shape from `SELECT * FROM <graph>` where each row IS a
403
+ // node OR an edge, discriminated by reddb's `kind` system column.
404
+ const row = (rec as QueryRow & { values?: Record<string, unknown> }).values;
405
+ if (row && typeof row === "object") {
406
+ const kind = row.kind;
407
+ if (kind === "node") {
408
+ const id = pickNodeId(row);
409
+ if (id && !nodes.has(id)) {
410
+ nodes.set(id, { id, label: pickNodeLabel(row, id), data: row });
411
+ }
412
+ } else if (kind === "edge") {
413
+ const s = row.from_rid ?? row.from;
414
+ const t = row.to_rid ?? row.to;
415
+ if (s !== undefined && s !== null && t !== undefined && t !== null) {
416
+ const source = String(s);
417
+ const target = String(t);
418
+ const label = row.label ?? row.type ?? row.rel;
419
+ const rid = row.rid ?? row.id ?? `${source}->${target}`;
420
+ const key = `e:${rid}`;
421
+ if (!edges.has(key)) {
422
+ edges.set(key, {
423
+ id: key,
424
+ source,
425
+ target,
426
+ label: label !== undefined ? String(label) : undefined,
427
+ data: row,
428
+ });
429
+ }
430
+ }
431
+ }
432
+ }
433
+ }
434
+
435
+ return { nodes: [...nodes.values()], edges: [...edges.values()] };
436
+ }
437
+
438
+ export function hasGraphShape(result: QueryResult): boolean {
439
+ const records = result.result?.records as QueryRow[] | undefined;
440
+ if (!records || records.length === 0) return false;
441
+ return records.some((r) => {
442
+ if (r.nodes && Object.keys(r.nodes).length > 0) return true;
443
+ if (r.edges && Object.keys(r.edges).length > 0) return true;
444
+ const row = (r as QueryRow & { values?: Record<string, unknown> }).values;
445
+ if (row && (row.kind === "node" || row.kind === "edge")) return true;
446
+ return false;
447
+ });
448
+ }
449
+
450
+ /**
451
+ * DOM-free adjacency-list HTML for snapshot tests and for the "view as
452
+ * adjacency list to export" affordance.
453
+ */
454
+ export function renderGraphHtml(result: QueryResult): string {
455
+ const { nodes, edges } = extractGraph(result);
456
+ const lines: string[] = [];
457
+ lines.push('<section class="graph">');
458
+ lines.push(
459
+ `<header class="summary">${nodes.length} nodes · ${edges.length} edges</header>`
460
+ );
461
+ lines.push('<ul class="nodes">');
462
+ for (const n of nodes) {
463
+ lines.push(
464
+ `<li class="node" data-id="${escapeHtml(n.id)}">${escapeHtml(n.label)}</li>`
465
+ );
466
+ }
467
+ lines.push("</ul>");
468
+ lines.push('<ul class="edges">');
469
+ for (const e of edges) {
470
+ const lbl = e.label ? ` [${escapeHtml(e.label)}]` : "";
471
+ lines.push(
472
+ `<li class="edge">${escapeHtml(e.source)} →${lbl} ${escapeHtml(e.target)}</li>`
473
+ );
474
+ }
475
+ lines.push("</ul>");
476
+ lines.push("</section>");
477
+ return lines.join("");
478
+ }
479
+
480
+ function escapeHtml(s: string): string {
481
+ return s
482
+ .replace(/&/g, "&amp;")
483
+ .replace(/</g, "&lt;")
484
+ .replace(/>/g, "&gt;")
485
+ .replace(/"/g, "&quot;");
486
+ }
487
+
488
+ export interface GraphLayoutNode {
489
+ x: number;
490
+ y: number;
491
+ community: number;
492
+ }
493
+
494
+ export type GraphLayout = Map<string, GraphLayoutNode>;
495
+
496
+ // Deterministic seed so consecutive renders of the same query don't jitter.
497
+ // Mulberry32 — small, fast, good enough for FA2 initialisation.
498
+ function seededRng(seed: number): () => number {
499
+ let s = seed >>> 0;
500
+ return () => {
501
+ s = (s + 0x6d2b79f5) >>> 0;
502
+ let t = s;
503
+ t = Math.imul(t ^ (t >>> 15), t | 1);
504
+ t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
505
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
506
+ };
507
+ }
508
+
509
+ /**
510
+ * Compute a community-aware layout for the graph.
511
+ *
512
+ * Pipeline: Louvain detects communities, ForceAtlas2 positions nodes so
513
+ * communities cluster spatially. Output is normalised to a [0, 100] box
514
+ * matching the existing renderer's coordinate space, with a 5% padding so
515
+ * nodes don't touch the viewport edge.
516
+ *
517
+ * Disconnected / trivial graphs (0–1 nodes, or no edges) bypass FA2 and
518
+ * fall back to a simple grid so the renderer still has positions.
519
+ */
520
+ export function runGraphLayout(
521
+ nodes: GraphNode[],
522
+ edges: GraphEdge[]
523
+ ): GraphLayout {
524
+ const out: GraphLayout = new Map();
525
+ if (nodes.length === 0) return out;
526
+ if (nodes.length === 1) {
527
+ out.set(nodes[0].id, { x: 50, y: 50, community: 0 });
528
+ return out;
529
+ }
530
+
531
+ const g = new Graph({
532
+ type: "undirected",
533
+ multi: false,
534
+ allowSelfLoops: true,
535
+ });
536
+ const rng = seededRng(nodes.length * 131 + edges.length);
537
+
538
+ // Initial positions on a unit circle — FA2 can't start from all-zero
539
+ // (force vectors collapse). Random-ish placement breaks symmetry.
540
+ const tau = Math.PI * 2;
541
+ nodes.forEach((n, i) => {
542
+ const angle = (i / nodes.length) * tau + rng() * 0.1;
543
+ g.addNode(n.id, { x: Math.cos(angle), y: Math.sin(angle) });
544
+ });
545
+
546
+ for (const e of edges) {
547
+ if (!g.hasNode(e.source) || !g.hasNode(e.target)) continue;
548
+ if (e.source === e.target) continue;
549
+ // Collapse parallel edges so the simple undirected graph stays valid;
550
+ // FA2 doesn't gain anything from multiplicity here.
551
+ if (g.hasEdge(e.source, e.target)) continue;
552
+ g.addEdge(e.source, e.target);
553
+ }
554
+
555
+ // Louvain needs at least one edge; degenerate graphs get every node in
556
+ // its own community and a grid fallback below.
557
+ if (g.size === 0) {
558
+ const cols = Math.ceil(Math.sqrt(nodes.length));
559
+ nodes.forEach((n, i) => {
560
+ const row = Math.floor(i / cols);
561
+ const col = i % cols;
562
+ out.set(n.id, {
563
+ x: 10 + (col / Math.max(1, cols - 1)) * 80,
564
+ y: 10 + (row / Math.max(1, cols - 1)) * 80,
565
+ community: i,
566
+ });
567
+ });
568
+ return out;
569
+ }
570
+
571
+ louvain.assign(g, { nodeCommunityAttribute: "community", rng });
572
+
573
+ const settings = forceAtlas2.inferSettings(g);
574
+ forceAtlas2.assign(g, {
575
+ iterations: nodes.length > 200 ? 120 : 200,
576
+ settings: {
577
+ ...settings,
578
+ strongGravityMode: true,
579
+ gravity: 1.2,
580
+ scalingRatio: 10,
581
+ barnesHutOptimize: nodes.length > 500,
582
+ },
583
+ });
584
+
585
+ let minX = Infinity,
586
+ maxX = -Infinity,
587
+ minY = Infinity,
588
+ maxY = -Infinity;
589
+ g.forEachNode((_, attrs) => {
590
+ if (attrs.x < minX) minX = attrs.x;
591
+ if (attrs.x > maxX) maxX = attrs.x;
592
+ if (attrs.y < minY) minY = attrs.y;
593
+ if (attrs.y > maxY) maxY = attrs.y;
594
+ });
595
+ const spanX = maxX - minX || 1;
596
+ const spanY = maxY - minY || 1;
597
+ const PAD = 5;
598
+ const RANGE = 100 - PAD * 2;
599
+
600
+ g.forEachNode((id, attrs) => {
601
+ out.set(id, {
602
+ x: PAD + ((attrs.x - minX) / spanX) * RANGE,
603
+ y: PAD + ((attrs.y - minY) / spanY) * RANGE,
604
+ community: (attrs.community as number) ?? 0,
605
+ });
606
+ });
607
+
608
+ return out;
609
+ }
610
+
611
+ /**
612
+ * Stable, distinguishable hue per community. The first ~12 communities
613
+ * pick from a hand-tuned palette; beyond that, golden-angle on the hue
614
+ * wheel keeps neighbours distinct without coordination.
615
+ *
616
+ * Saturation/lightness are theme-aware so the palette reads well on the
617
+ * dark canvas without going neon.
618
+ */
619
+ export function colorForCommunity(
620
+ community: number,
621
+ dark: boolean = true
622
+ ): string {
623
+ const PALETTE = [200, 25, 145, 290, 50, 175, 320, 95, 0, 260, 220, 130];
624
+ const hue =
625
+ community < PALETTE.length
626
+ ? PALETTE[community]
627
+ : (PALETTE[0] + community * 137.508) % 360;
628
+ const sat = dark ? 62 : 58;
629
+ const light = dark ? 60 : 48;
630
+ return `hsl(${hue.toFixed(0)} ${sat}% ${light}%)`;
631
+ }