@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.
- package/LICENSE +17 -0
- package/bin/red-ui.js +259 -0
- package/build/CNAME +1 -0
- package/build/_app/env.js +1 -0
- package/build/_app/immutable/assets/0.C63jY5jX.css +1 -0
- package/build/_app/immutable/assets/10.Dblyk0Kr.css +1 -0
- package/build/_app/immutable/assets/EmptyState.RXcH7ryE.css +1 -0
- package/build/_app/immutable/assets/Workspace.BCzG1DsH.css +1 -0
- package/build/_app/immutable/assets/inter-cyrillic-ext-wght-normal.BOeWTOD4.woff2 +0 -0
- package/build/_app/immutable/assets/inter-cyrillic-wght-normal.DqGufNeO.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-ext-wght-normal.DlzME5K_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-greek-wght-normal.CkhJZR-_.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-ext-wght-normal.DO1Apj_S.woff2 +0 -0
- package/build/_app/immutable/assets/inter-latin-wght-normal.Dx4kXJAl.woff2 +0 -0
- package/build/_app/immutable/assets/inter-vietnamese-wght-normal.CBcvBZtf.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-cyrillic-wght-normal.D73BlboJ.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-greek-wght-normal.Bw9x6K1M.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-ext-wght-normal.DBQx-q_a.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-latin-wght-normal.B9CIFXIH.woff2 +0 -0
- package/build/_app/immutable/assets/jetbrains-mono-vietnamese-wght-normal.Bt-aOZkq.woff2 +0 -0
- package/build/_app/immutable/chunks/3j4cZ58b.js +1 -0
- package/build/_app/immutable/chunks/69_IOA4Y.js +1 -0
- package/build/_app/immutable/chunks/ALqaDrNQ.js +2 -0
- package/build/_app/immutable/chunks/BGcIe2ep.js +2 -0
- package/build/_app/immutable/chunks/BMHKbvs8.js +1 -0
- package/build/_app/immutable/chunks/BQHbwf54.js +916 -0
- package/build/_app/immutable/chunks/Bgso8cDH.js +311 -0
- package/build/_app/immutable/chunks/Bmgx1mfv.js +310 -0
- package/build/_app/immutable/chunks/BrvvOVaJ.js +1 -0
- package/build/_app/immutable/chunks/Bzak7iHL.js +1 -0
- package/build/_app/immutable/chunks/D9CTkMjK.js +1 -0
- package/build/_app/immutable/chunks/DIeogL5L.js +1 -0
- package/build/_app/immutable/chunks/DXY_Rxa7.js +113 -0
- package/build/_app/immutable/chunks/DhEqZVGG.js +1 -0
- package/build/_app/immutable/chunks/Ov8N-vxt.js +1 -0
- package/build/_app/immutable/chunks/oiO4RLqp.js +1 -0
- package/build/_app/immutable/entry/app.D_IVtpdv.js +2 -0
- package/build/_app/immutable/entry/start.3oC0UQ_Q.js +1 -0
- package/build/_app/immutable/nodes/0.CHPiFxDo.js +1 -0
- package/build/_app/immutable/nodes/1.BC_4YZ0h.js +1 -0
- package/build/_app/immutable/nodes/10.B7DMKSNc.js +60 -0
- package/build/_app/immutable/nodes/2.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/3.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/4.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/5.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/6.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/7.BZX9Iird.js +134 -0
- package/build/_app/immutable/nodes/8.CAG5_cGP.js +1 -0
- package/build/_app/immutable/nodes/9.CAG5_cGP.js +1 -0
- package/build/_app/version.json +1 -0
- package/build/favicon.png +0 -0
- package/build/index.html +59 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js +26562 -0
- package/dist/embed/RedUiEmbedRoot-BGoLxJf1.js.map +1 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js +3047 -0
- package/dist/embed/bundle-mjs-UuVRz3O4.js.map +1 -0
- package/dist/embed/core-QuRYvMt8.js +118 -0
- package/dist/embed/core-QuRYvMt8.js.map +1 -0
- package/dist/embed/index-B6ZQhCsu.js +577 -0
- package/dist/embed/index-B6ZQhCsu.js.map +1 -0
- package/dist/embed/index-client-ItCEU64C.js +3087 -0
- package/dist/embed/index-client-ItCEU64C.js.map +1 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js +1318 -0
- package/dist/embed/index-fad77a13.esm-BqPj0saH.js.map +1 -0
- package/dist/embed/index.js +11 -0
- package/dist/embed/index.js.map +1 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js +432 -0
- package/dist/embed/sigma-node-border.esm-CGtlgqmo.js.map +1 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js +2355 -0
- package/dist/embed/sigma.esm-y0qgY-OC.js.map +1 -0
- package/dist/embed/ui.css +1 -0
- package/package.json +101 -0
- package/src/app.css +238 -0
- package/src/lib/ActivityIndicator.svelte +100 -0
- package/src/lib/ClusterNode.svelte +119 -0
- package/src/lib/ClusterView.svelte +541 -0
- package/src/lib/CollectionHistory.svelte +266 -0
- package/src/lib/CollectionsView.svelte +14 -0
- package/src/lib/CommandPalette.svelte +237 -0
- package/src/lib/ConnectDropdown.svelte +222 -0
- package/src/lib/EmptyState.svelte +27 -0
- package/src/lib/LiveChanges.svelte +195 -0
- package/src/lib/MasterPasswordDialog.svelte +168 -0
- package/src/lib/PageHeader.svelte +32 -0
- package/src/lib/PendingChangesPanel.svelte +153 -0
- package/src/lib/QueryEditor.svelte +122 -0
- package/src/lib/ResultsPane.svelte +747 -0
- package/src/lib/SchemaTree.svelte +193 -0
- package/src/lib/SecurityView.svelte +255 -0
- package/src/lib/ShortcutOverlay.svelte +104 -0
- package/src/lib/Splash.svelte +46 -0
- package/src/lib/StatusBar.svelte +163 -0
- package/src/lib/SubNav.svelte +62 -0
- package/src/lib/Topbar.svelte +119 -0
- package/src/lib/Workspace.svelte +247 -0
- package/src/lib/activity.svelte.ts +91 -0
- package/src/lib/build-info.ts +41 -0
- package/src/lib/capability.ts +221 -0
- package/src/lib/cn.ts +6 -0
- package/src/lib/collection-catalog.ts +116 -0
- package/src/lib/collection-pages.ts +33 -0
- package/src/lib/connection-history.ts +301 -0
- package/src/lib/connections.svelte.ts +493 -0
- package/src/lib/embed/RedUiEmbedRoot.svelte +31 -0
- package/src/lib/embed/index.ts +234 -0
- package/src/lib/graph-loader/contract.ts +336 -0
- package/src/lib/graph-loader/drilldown.ts +334 -0
- package/src/lib/graph-loader/index.ts +13 -0
- package/src/lib/index.ts +114 -0
- package/src/lib/pending-changes.svelte.ts +149 -0
- package/src/lib/query-tabs.svelte.ts +130 -0
- package/src/lib/reddb/boot-params.ts +254 -0
- package/src/lib/reddb/cdc-stream-client.ts +234 -0
- package/src/lib/reddb/client.ts +698 -0
- package/src/lib/reddb/connection-bootstrap.ts +260 -0
- package/src/lib/reddb/connection-provider.contract.ts +72 -0
- package/src/lib/reddb/connection-provider.ts +72 -0
- package/src/lib/reddb/index.ts +14 -0
- package/src/lib/reddb/injected-client-provider.ts +76 -0
- package/src/lib/reddb/local-url-provider.ts +245 -0
- package/src/lib/reddb/secure-store.ts +41 -0
- package/src/lib/reddb/server-capabilities.ts +71 -0
- package/src/lib/reddb/tauri-encrypted-store.ts +64 -0
- package/src/lib/reddb/transports.ts +89 -0
- package/src/lib/reddb/types.ts +32 -0
- package/src/lib/reddb/uri.ts +54 -0
- package/src/lib/reddb/web-encrypted-store.ts +201 -0
- package/src/lib/renderers/DiffRenderer.svelte +184 -0
- package/src/lib/renderers/DocumentRenderer.svelte +134 -0
- package/src/lib/renderers/GraphRenderer.svelte +1328 -0
- package/src/lib/renderers/HypertableRenderer.svelte +253 -0
- package/src/lib/renderers/JsonRenderer.svelte +30 -0
- package/src/lib/renderers/KvRenderer.svelte +150 -0
- package/src/lib/renderers/QueueRenderer.svelte +286 -0
- package/src/lib/renderers/StatsRenderer.svelte +123 -0
- package/src/lib/renderers/TableRenderer.svelte +440 -0
- package/src/lib/renderers/VectorRenderer.svelte +388 -0
- package/src/lib/renderers/diff-render.ts +122 -0
- package/src/lib/renderers/diff.ts +9 -0
- package/src/lib/renderers/document-render.ts +64 -0
- package/src/lib/renderers/document.ts +9 -0
- package/src/lib/renderers/graph-render.ts +631 -0
- package/src/lib/renderers/graph-sigma.ts +395 -0
- package/src/lib/renderers/graph.ts +9 -0
- package/src/lib/renderers/hypertable-render.ts +320 -0
- package/src/lib/renderers/hypertable.ts +9 -0
- package/src/lib/renderers/index.ts +42 -0
- package/src/lib/renderers/json-render.ts +89 -0
- package/src/lib/renderers/json.ts +10 -0
- package/src/lib/renderers/kv-render.ts +193 -0
- package/src/lib/renderers/kv.ts +9 -0
- package/src/lib/renderers/queue-render.ts +197 -0
- package/src/lib/renderers/queue.ts +9 -0
- package/src/lib/renderers/registry.ts +68 -0
- package/src/lib/renderers/stats-render.ts +73 -0
- package/src/lib/renderers/stats.ts +9 -0
- package/src/lib/renderers/table-render.ts +156 -0
- package/src/lib/renderers/table.ts +11 -0
- package/src/lib/renderers/types.ts +33 -0
- package/src/lib/renderers/vector-render.ts +154 -0
- package/src/lib/renderers/vector.ts +9 -0
- package/src/lib/router.svelte.ts +163 -0
- package/src/lib/secureStore.svelte.ts +148 -0
- package/src/lib/shadow-dom-spike/ShadowDomSpikeCore.svelte +198 -0
- package/src/lib/shadow-dom-spike/mount-shadow-dom-spike.ts +94 -0
- package/src/lib/shortcuts.ts +22 -0
- package/src/lib/surface.ts +80 -0
- package/src/lib/tabs.svelte.ts +115 -0
- 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, "&")
|
|
483
|
+
.replace(/</g, "<")
|
|
484
|
+
.replace(/>/g, ">")
|
|
485
|
+
.replace(/"/g, """);
|
|
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
|
+
}
|