@nbt-dev/components 0.0.5
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 +177 -0
- package/README.md +10 -0
- package/TRADEMARKS.md +49 -0
- package/dist/chunk-3ZM6YOA4.js +704 -0
- package/dist/chunk-3ZM6YOA4.js.map +7 -0
- package/dist/chunk-7B2T5ZNG.js +467 -0
- package/dist/chunk-7B2T5ZNG.js.map +7 -0
- package/dist/chunk-S7VBQE6Y.js +636 -0
- package/dist/chunk-S7VBQE6Y.js.map +7 -0
- package/dist/chunk-UPEOXMLZ.js +625 -0
- package/dist/chunk-UPEOXMLZ.js.map +7 -0
- package/dist/core/auth.d.ts +13 -0
- package/dist/core/bulk-decoder.d.ts +13 -0
- package/dist/core/config.d.ts +10 -0
- package/dist/core/data-store.d.ts +20 -0
- package/dist/core/index.d.ts +9 -0
- package/dist/core/use-bulk-stream.d.ts +24 -0
- package/dist/core/use-cartridge-info.d.ts +14 -0
- package/dist/core/utils.d.ts +2 -0
- package/dist/editor/index.d.ts +7 -0
- package/dist/editor/index.js +16 -0
- package/dist/editor/index.js.map +7 -0
- package/dist/editor/lsp-client.d.ts +57 -0
- package/dist/editor/lsp-extensions.d.ts +4 -0
- package/dist/editor/nbt-editor.d.ts +13 -0
- package/dist/editor/nbt-language.d.ts +7 -0
- package/dist/generated/bulk-protocol.d.ts +36 -0
- package/dist/graph/diagram.d.ts +5 -0
- package/dist/graph/entity-graph-utils.d.ts +92 -0
- package/dist/graph/entity-node.d.ts +9 -0
- package/dist/graph/index.d.ts +5 -0
- package/dist/graph/index.js +19 -0
- package/dist/graph/index.js.map +7 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.js +134 -0
- package/dist/index.js.map +7 -0
- package/dist/styles.css +2 -0
- package/dist/table/data-table.d.ts +9 -0
- package/dist/table/index.d.ts +3 -0
- package/dist/table/index.js +11 -0
- package/dist/table/index.js.map +7 -0
- package/dist/table/value-popover.d.ts +18 -0
- package/package.json +77 -0
- package/src/core/auth.ts +100 -0
- package/src/core/bulk-decoder.ts +178 -0
- package/src/core/config.tsx +39 -0
- package/src/core/data-store.ts +113 -0
- package/src/core/index.ts +34 -0
- package/src/core/use-bulk-stream.ts +412 -0
- package/src/core/use-cartridge-info.ts +100 -0
- package/src/core/utils.ts +6 -0
- package/src/editor/index.ts +13 -0
- package/src/editor/lsp-client.ts +227 -0
- package/src/editor/lsp-extensions.ts +191 -0
- package/src/editor/nbt-editor.tsx +142 -0
- package/src/editor/nbt-language.ts +151 -0
- package/src/generated/bulk-protocol.ts +63 -0
- package/src/graph/diagram.tsx +296 -0
- package/src/graph/entity-graph-utils.ts +423 -0
- package/src/graph/entity-node.tsx +122 -0
- package/src/graph/index.ts +19 -0
- package/src/index.ts +7 -0
- package/src/styles.css +94 -0
- package/src/table/data-table.tsx +274 -0
- package/src/table/index.ts +5 -0
- package/src/table/value-popover.tsx +230 -0
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
// Entity-relationship graph model for the devtools Diagram tab. Ported from the
|
|
2
|
+
// portal-ui entity-graph (apps/portal/.../systems/entity-graph) but fed off the
|
|
3
|
+
// daemon's `/_console/contracts` directly instead of the portal BFF's
|
|
4
|
+
// cartridges-in-use + resources endpoints. Contract fields use snake_case
|
|
5
|
+
// relation keys (`target_cart`, `fk_field`, `relation_kind`); we normalize to
|
|
6
|
+
// the camelCase model below in `cartsFromContracts`. Row counts are overlaid
|
|
7
|
+
// live from the bulk-stream schema preload, not baked into the model here.
|
|
8
|
+
//
|
|
9
|
+
// Placement uses dagre (layered LR) — the portal's hand-rolled column stacker
|
|
10
|
+
// sprawled here because the devtools shows every installed cart at once rather
|
|
11
|
+
// than a project-scoped subset.
|
|
12
|
+
|
|
13
|
+
import dagre from "@dagrejs/dagre";
|
|
14
|
+
|
|
15
|
+
export type GraphInputField = {
|
|
16
|
+
name: string;
|
|
17
|
+
type: string;
|
|
18
|
+
optional?: boolean;
|
|
19
|
+
array?: boolean;
|
|
20
|
+
kind?: string;
|
|
21
|
+
target?: string;
|
|
22
|
+
targetCart?: string;
|
|
23
|
+
relationKind?: string;
|
|
24
|
+
fkField?: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type GraphInputEntity = {
|
|
28
|
+
name: string;
|
|
29
|
+
fields: GraphInputField[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type GraphInputCart = {
|
|
33
|
+
name: string;
|
|
34
|
+
entities: GraphInputEntity[];
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
export type EntityGraphField = {
|
|
38
|
+
name: string;
|
|
39
|
+
displayName: string;
|
|
40
|
+
type: string;
|
|
41
|
+
kind: string;
|
|
42
|
+
optional: boolean;
|
|
43
|
+
array: boolean;
|
|
44
|
+
target?: string;
|
|
45
|
+
targetCart?: string;
|
|
46
|
+
relationKind?: string;
|
|
47
|
+
fkField?: string;
|
|
48
|
+
implicit?: boolean;
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
export type EntityGraphNodeData = {
|
|
52
|
+
id: string;
|
|
53
|
+
cartridge: string;
|
|
54
|
+
entity: string;
|
|
55
|
+
fields: EntityGraphField[];
|
|
56
|
+
fieldCount: number;
|
|
57
|
+
scalarCount: number;
|
|
58
|
+
relationCount: number;
|
|
59
|
+
documentCount: number;
|
|
60
|
+
rowCount: number;
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
export type EntityGraphNode = EntityGraphNodeData & {
|
|
64
|
+
position: { x: number; y: number };
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
export type EntityGraphEdge = {
|
|
68
|
+
id: string;
|
|
69
|
+
source: string;
|
|
70
|
+
target: string;
|
|
71
|
+
sourceField: string;
|
|
72
|
+
targetField: string;
|
|
73
|
+
label: string;
|
|
74
|
+
relationKind?: string;
|
|
75
|
+
fkField?: string;
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
export type EntityGraphTotals = {
|
|
79
|
+
entities: number;
|
|
80
|
+
relationships: number;
|
|
81
|
+
rows: number;
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
export type EntityGraphModel = {
|
|
85
|
+
nodes: EntityGraphNode[];
|
|
86
|
+
edges: EntityGraphEdge[];
|
|
87
|
+
totals: EntityGraphTotals;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const NODE_WIDTH = 260;
|
|
91
|
+
const MIN_NODE_HEIGHT = 150;
|
|
92
|
+
const FIELD_ROW_HEIGHT = 26;
|
|
93
|
+
const COLUMN_WIDTH = 390; // initial pre-layout spread; dagre overrides positions
|
|
94
|
+
|
|
95
|
+
// Raw contract shapes (subset). `/_console/contracts` returns one object per
|
|
96
|
+
// running cart with an `owns` map of entity -> { fields, ... }.
|
|
97
|
+
type ContractField = {
|
|
98
|
+
name: string;
|
|
99
|
+
type: string;
|
|
100
|
+
optional?: boolean;
|
|
101
|
+
array?: boolean;
|
|
102
|
+
kind?: string;
|
|
103
|
+
target?: string;
|
|
104
|
+
target_cart?: string;
|
|
105
|
+
relation_kind?: string;
|
|
106
|
+
fk_field?: string;
|
|
107
|
+
};
|
|
108
|
+
type ContractEntity = { fields?: ContractField[] };
|
|
109
|
+
export type Contract = {
|
|
110
|
+
cartridge?: string;
|
|
111
|
+
owns?: Record<string, ContractEntity>;
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
export function cartsFromContracts(contracts: Contract[]): GraphInputCart[] {
|
|
115
|
+
const carts: GraphInputCart[] = [];
|
|
116
|
+
for (const c of contracts) {
|
|
117
|
+
if (!c.cartridge || !c.owns) continue;
|
|
118
|
+
const entities: GraphInputEntity[] = [];
|
|
119
|
+
for (const [name, ent] of Object.entries(c.owns)) {
|
|
120
|
+
const fields: GraphInputField[] = (ent.fields ?? []).map((f) => ({
|
|
121
|
+
name: f.name,
|
|
122
|
+
type: f.type,
|
|
123
|
+
optional: f.optional,
|
|
124
|
+
array: f.array,
|
|
125
|
+
kind: f.kind,
|
|
126
|
+
target: f.target,
|
|
127
|
+
targetCart: f.target_cart,
|
|
128
|
+
relationKind: f.relation_kind,
|
|
129
|
+
fkField: f.fk_field,
|
|
130
|
+
}));
|
|
131
|
+
entities.push({ name, fields });
|
|
132
|
+
}
|
|
133
|
+
if (entities.length > 0) carts.push({ name: c.cartridge, entities });
|
|
134
|
+
}
|
|
135
|
+
return carts;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
export function entityGraphId(cartridge: string, entity: string): string {
|
|
139
|
+
return `${cartridge}:${entity}`;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function fieldKind(field: GraphInputField): string {
|
|
143
|
+
return field.kind ?? (field.target ? "relation" : "scalar");
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function graphField(field: GraphInputField): EntityGraphField {
|
|
147
|
+
const kind = fieldKind(field);
|
|
148
|
+
return {
|
|
149
|
+
name: field.name,
|
|
150
|
+
displayName: kind === "relation" ? (field.fkField ?? field.name) : field.name,
|
|
151
|
+
type: kind === "relation" && field.target ? field.target : field.type,
|
|
152
|
+
kind,
|
|
153
|
+
optional: field.optional === true,
|
|
154
|
+
array: field.array === true,
|
|
155
|
+
target: field.target,
|
|
156
|
+
targetCart: field.targetCart,
|
|
157
|
+
relationKind: field.relationKind,
|
|
158
|
+
fkField: field.fkField,
|
|
159
|
+
};
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function makeGraphFields(fields: GraphInputField[]): EntityGraphField[] {
|
|
163
|
+
const out = fields.map(graphField);
|
|
164
|
+
if (!out.some((field) => field.displayName.toLowerCase() === "id")) {
|
|
165
|
+
out.unshift({
|
|
166
|
+
name: "id",
|
|
167
|
+
displayName: "id",
|
|
168
|
+
type: "id",
|
|
169
|
+
kind: "scalar",
|
|
170
|
+
optional: false,
|
|
171
|
+
array: false,
|
|
172
|
+
implicit: true,
|
|
173
|
+
});
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function normalizeName(value: string): string {
|
|
179
|
+
return value.replace(/[^a-zA-Z0-9]/g, "").toLowerCase();
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function words(value: string): string[] {
|
|
183
|
+
return value
|
|
184
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
185
|
+
.split(/[^a-zA-Z0-9]+/)
|
|
186
|
+
.map((part) => part.toLowerCase())
|
|
187
|
+
.filter(Boolean);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function entityAliases(entity: string): string[] {
|
|
191
|
+
const parts = words(entity);
|
|
192
|
+
const aliases = new Set<string>([normalizeName(entity)]);
|
|
193
|
+
const last = parts.length > 0 ? parts[parts.length - 1] : undefined;
|
|
194
|
+
if (last) aliases.add(last);
|
|
195
|
+
if (parts.length > 1 && last) {
|
|
196
|
+
aliases.add(`${parts.slice(0, -1).map((part) => part[0]).join("")}${last}`);
|
|
197
|
+
aliases.add(parts.map((part) => part[0]).join(""));
|
|
198
|
+
}
|
|
199
|
+
return [...aliases].filter((alias) => alias.length >= 2);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function inferTargetNode(fieldName: string, nodes: EntityGraphNode[]): EntityGraphNode | null {
|
|
203
|
+
if (!/id$/i.test(fieldName) || /^id$/i.test(fieldName)) return null;
|
|
204
|
+
const base = normalizeName(fieldName.replace(/id$/i, ""));
|
|
205
|
+
if (base.length < 3) return null;
|
|
206
|
+
|
|
207
|
+
const candidates = nodes
|
|
208
|
+
.flatMap((node) =>
|
|
209
|
+
entityAliases(node.entity).map((alias) => {
|
|
210
|
+
const exact = base === alias;
|
|
211
|
+
const suffix = !exact && alias.length >= 4 && base.endsWith(alias);
|
|
212
|
+
if (!exact && !suffix) return null;
|
|
213
|
+
return {
|
|
214
|
+
node,
|
|
215
|
+
score: (exact ? 1000 : 500) + alias.length,
|
|
216
|
+
};
|
|
217
|
+
}),
|
|
218
|
+
)
|
|
219
|
+
.filter((candidate): candidate is { node: EntityGraphNode; score: number } => candidate !== null)
|
|
220
|
+
.sort((a, b) => b.score - a.score || a.node.entity.localeCompare(b.node.entity));
|
|
221
|
+
|
|
222
|
+
const first = candidates[0];
|
|
223
|
+
if (!first) return null;
|
|
224
|
+
const second = candidates[1];
|
|
225
|
+
if (second && first.score === second.score) return null;
|
|
226
|
+
return first.node;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function markRelationField(node: EntityGraphNode | undefined, fieldName: string, target: EntityGraphNode) {
|
|
230
|
+
const field = node?.fields.find(
|
|
231
|
+
(candidate) => candidate.displayName === fieldName || candidate.name === fieldName,
|
|
232
|
+
);
|
|
233
|
+
if (!field || field.displayName.toLowerCase() === "id") return;
|
|
234
|
+
field.kind = "relation";
|
|
235
|
+
field.target = target.entity;
|
|
236
|
+
field.targetCart = target.cartridge;
|
|
237
|
+
field.relationKind ??= "inferred";
|
|
238
|
+
field.fkField ??= fieldName;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function recomputeNodeCounts(nodes: EntityGraphNode[]) {
|
|
242
|
+
for (const node of nodes) {
|
|
243
|
+
node.scalarCount = node.fields.filter((field) => field.kind === "scalar").length;
|
|
244
|
+
node.relationCount = node.fields.filter((field) => field.kind === "relation").length;
|
|
245
|
+
node.documentCount = node.fields.filter((field) => field.kind === "document").length;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function nodeHeight(node: EntityGraphNode): number {
|
|
250
|
+
return Math.max(MIN_NODE_HEIGHT, 74 + node.fields.length * FIELD_ROW_HEIGHT);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// Layered left-to-right placement via dagre. Connected components get clean
|
|
254
|
+
// ranks with minimized crossings; isolated nodes (no edges) are gathered into a
|
|
255
|
+
// trailing grid so they don't inflate dagre's ranks.
|
|
256
|
+
function layoutNodes(nodes: EntityGraphNode[], edges: EntityGraphEdge[]) {
|
|
257
|
+
const degree = new Map(nodes.map((n) => [n.id, 0]));
|
|
258
|
+
for (const edge of edges) {
|
|
259
|
+
degree.set(edge.source, (degree.get(edge.source) ?? 0) + 1);
|
|
260
|
+
degree.set(edge.target, (degree.get(edge.target) ?? 0) + 1);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const connected = nodes.filter((n) => (degree.get(n.id) ?? 0) > 0);
|
|
264
|
+
const isolated = nodes.filter((n) => (degree.get(n.id) ?? 0) === 0);
|
|
265
|
+
const connectedIds = new Set(connected.map((n) => n.id));
|
|
266
|
+
|
|
267
|
+
let maxX = 0;
|
|
268
|
+
let maxY = 0;
|
|
269
|
+
|
|
270
|
+
if (connected.length > 0) {
|
|
271
|
+
const g = new dagre.graphlib.Graph();
|
|
272
|
+
g.setGraph({ rankdir: "LR", ranksep: 160, nodesep: 48, marginx: 40, marginy: 40 });
|
|
273
|
+
g.setDefaultEdgeLabel(() => ({}));
|
|
274
|
+
for (const node of connected) {
|
|
275
|
+
g.setNode(node.id, { width: NODE_WIDTH, height: nodeHeight(node) });
|
|
276
|
+
}
|
|
277
|
+
for (const edge of edges) {
|
|
278
|
+
// dagre can't rank self-loops; the edge still renders (handle picks the
|
|
279
|
+
// same node's left+right). Cross-component edges are all kept.
|
|
280
|
+
if (edge.source === edge.target) continue;
|
|
281
|
+
if (!connectedIds.has(edge.source) || !connectedIds.has(edge.target)) continue;
|
|
282
|
+
g.setEdge(edge.source, edge.target);
|
|
283
|
+
}
|
|
284
|
+
dagre.layout(g);
|
|
285
|
+
for (const node of connected) {
|
|
286
|
+
const p = g.node(node.id);
|
|
287
|
+
const h = nodeHeight(node);
|
|
288
|
+
node.position = { x: p.x - NODE_WIDTH / 2, y: p.y - h / 2 };
|
|
289
|
+
maxX = Math.max(maxX, node.position.x + NODE_WIDTH);
|
|
290
|
+
maxY = Math.max(maxY, node.position.y + h);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// Isolated nodes: a tidy grid to the right of the laid-out graph.
|
|
295
|
+
if (isolated.length > 0) {
|
|
296
|
+
isolated.sort((a, b) => a.entity.localeCompare(b.entity));
|
|
297
|
+
const startX = connected.length > 0 ? maxX + COLUMN_WIDTH : 0;
|
|
298
|
+
const perColumn = Math.max(1, Math.ceil(isolated.length / Math.ceil(isolated.length / 6)));
|
|
299
|
+
let x = startX;
|
|
300
|
+
let y = 0;
|
|
301
|
+
let inColumn = 0;
|
|
302
|
+
for (const node of isolated) {
|
|
303
|
+
node.position = { x, y };
|
|
304
|
+
y += nodeHeight(node) + 48;
|
|
305
|
+
inColumn += 1;
|
|
306
|
+
if (inColumn >= perColumn) {
|
|
307
|
+
inColumn = 0;
|
|
308
|
+
y = 0;
|
|
309
|
+
x += COLUMN_WIDTH;
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function makeTotals(nodes: EntityGraphNode[], edges: EntityGraphEdge[]): EntityGraphTotals {
|
|
316
|
+
return {
|
|
317
|
+
entities: nodes.length,
|
|
318
|
+
relationships: edges.length,
|
|
319
|
+
rows: nodes.reduce((sum, node) => sum + node.rowCount, 0),
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function buildEntityGraphModel(cartridges: GraphInputCart[]): EntityGraphModel {
|
|
324
|
+
const sortedCarts = [...cartridges].sort((a, b) => a.name.localeCompare(b.name));
|
|
325
|
+
const nodes: EntityGraphNode[] = [];
|
|
326
|
+
|
|
327
|
+
sortedCarts.forEach((cart, cartIndex) => {
|
|
328
|
+
const entities = [...cart.entities].sort((a, b) => a.name.localeCompare(b.name));
|
|
329
|
+
let y = 0;
|
|
330
|
+
entities.forEach((entity) => {
|
|
331
|
+
const id = entityGraphId(cart.name, entity.name);
|
|
332
|
+
const fields = entity.fields ?? [];
|
|
333
|
+
const graphFields = makeGraphFields(fields);
|
|
334
|
+
nodes.push({
|
|
335
|
+
id,
|
|
336
|
+
cartridge: cart.name,
|
|
337
|
+
entity: entity.name,
|
|
338
|
+
fields: graphFields,
|
|
339
|
+
fieldCount: graphFields.length,
|
|
340
|
+
scalarCount: graphFields.filter((field) => field.kind === "scalar").length,
|
|
341
|
+
relationCount: graphFields.filter((field) => field.kind === "relation").length,
|
|
342
|
+
documentCount: graphFields.filter((field) => field.kind === "document").length,
|
|
343
|
+
rowCount: 0,
|
|
344
|
+
position: { x: cartIndex * COLUMN_WIDTH, y },
|
|
345
|
+
});
|
|
346
|
+
y += Math.max(MIN_NODE_HEIGHT, 74 + graphFields.length * FIELD_ROW_HEIGHT) + 56;
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
const nodeIds = new Set(nodes.map((node) => node.id));
|
|
351
|
+
const nodeById = new Map(nodes.map((node) => [node.id, node]));
|
|
352
|
+
const edges: EntityGraphEdge[] = [];
|
|
353
|
+
const edgeKeys = new Set<string>();
|
|
354
|
+
for (const cart of sortedCarts) {
|
|
355
|
+
for (const entity of cart.entities) {
|
|
356
|
+
const source = entityGraphId(cart.name, entity.name);
|
|
357
|
+
const sourceNode = nodeById.get(source);
|
|
358
|
+
for (const field of entity.fields ?? []) {
|
|
359
|
+
const explicitRelation = fieldKind(field) === "relation" && !!field.target;
|
|
360
|
+
const inferredTargetNode = explicitRelation ? null : inferTargetNode(field.name, nodes);
|
|
361
|
+
if (!explicitRelation && !inferredTargetNode) continue;
|
|
362
|
+
|
|
363
|
+
const targetCart = explicitRelation
|
|
364
|
+
? field.targetCart ?? cart.name
|
|
365
|
+
: inferredTargetNode?.cartridge;
|
|
366
|
+
const targetEntity = explicitRelation ? field.target : inferredTargetNode?.entity;
|
|
367
|
+
if (!targetCart || !targetEntity) continue;
|
|
368
|
+
const target = entityGraphId(targetCart, targetEntity);
|
|
369
|
+
if (!nodeIds.has(source) || !nodeIds.has(target)) continue;
|
|
370
|
+
const targetNode = nodeById.get(target);
|
|
371
|
+
const idField = targetNode?.fields.find((candidate) => candidate.displayName.toLowerCase() === "id");
|
|
372
|
+
const targetField = idField?.displayName ?? "__entity";
|
|
373
|
+
const sourceGraphField = sourceNode?.fields.find(
|
|
374
|
+
(candidate) => candidate.name === field.name || candidate.displayName === field.name,
|
|
375
|
+
);
|
|
376
|
+
const sourceField = sourceGraphField?.displayName ?? field.fkField ?? field.name;
|
|
377
|
+
const edgeKey = `${source}:${sourceField}->${target}:${targetField}`;
|
|
378
|
+
if (edgeKeys.has(edgeKey)) continue;
|
|
379
|
+
edgeKeys.add(edgeKey);
|
|
380
|
+
if (!explicitRelation && targetNode) markRelationField(sourceNode, sourceField, targetNode);
|
|
381
|
+
edges.push({
|
|
382
|
+
id: `${source}:${field.name}->${target}`,
|
|
383
|
+
source,
|
|
384
|
+
target,
|
|
385
|
+
sourceField,
|
|
386
|
+
targetField,
|
|
387
|
+
label: field.name,
|
|
388
|
+
relationKind: field.relationKind ?? (!explicitRelation ? "inferred" : undefined),
|
|
389
|
+
fkField: field.fkField ?? (!explicitRelation ? field.name : undefined),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
}
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
recomputeNodeCounts(nodes);
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
nodes,
|
|
399
|
+
edges,
|
|
400
|
+
totals: makeTotals(nodes, edges),
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
// Filter to the visible set and re-run layout over exactly those nodes/edges, so
|
|
405
|
+
// toggling entities re-fits the graph rather than leaving stale full-graph gaps.
|
|
406
|
+
// Nodes are cloned before layout mutates their `position`, keeping the cached base
|
|
407
|
+
// model untouched.
|
|
408
|
+
export function filterEntityGraphModel(
|
|
409
|
+
model: EntityGraphModel,
|
|
410
|
+
visibleIds: Set<string>,
|
|
411
|
+
): EntityGraphModel {
|
|
412
|
+
const nodes = model.nodes
|
|
413
|
+
.filter((node) => visibleIds.has(node.id))
|
|
414
|
+
.map((node) => ({ ...node, position: { ...node.position } }));
|
|
415
|
+
const ids = new Set(nodes.map((node) => node.id));
|
|
416
|
+
const edges = model.edges.filter((edge) => ids.has(edge.source) && ids.has(edge.target));
|
|
417
|
+
layoutNodes(nodes, edges);
|
|
418
|
+
return {
|
|
419
|
+
nodes,
|
|
420
|
+
edges,
|
|
421
|
+
totals: makeTotals(nodes, edges),
|
|
422
|
+
};
|
|
423
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { Handle, Position, type NodeProps } from "@xyflow/react";
|
|
3
|
+
import { Database, FileText, KeyRound, Link2 } from "lucide-react";
|
|
4
|
+
import type { EntityGraphNodeData } from "./entity-graph-utils";
|
|
5
|
+
|
|
6
|
+
export type EntityNodeHighlight = {
|
|
7
|
+
focused: boolean;
|
|
8
|
+
connected: boolean;
|
|
9
|
+
dimmed: boolean;
|
|
10
|
+
fields: string[];
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type EntityNodeViewData = EntityGraphNodeData & {
|
|
14
|
+
highlight?: EntityNodeHighlight;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
// Dark entity card. Colors are pinned (not theme tokens) so the card looks the
|
|
18
|
+
// same regardless of where it mounts; it sits inside the dark devtools panel.
|
|
19
|
+
export function EntityNode({ data, selected }: NodeProps) {
|
|
20
|
+
const node = data as EntityNodeViewData;
|
|
21
|
+
const highlight = node.highlight;
|
|
22
|
+
const highlightedFields = new Set(highlight?.fields ?? []);
|
|
23
|
+
const hiddenHandleClass = "!h-1 !w-1 !border-0 !bg-transparent !opacity-0";
|
|
24
|
+
return (
|
|
25
|
+
<div
|
|
26
|
+
className={[
|
|
27
|
+
"relative w-[260px] overflow-visible rounded-xl border bg-zinc-900 font-mono text-[11px] text-zinc-100 shadow-lg shadow-black/40 transition-opacity",
|
|
28
|
+
highlight?.focused
|
|
29
|
+
? "border-blue-500 ring-2 ring-blue-500/30"
|
|
30
|
+
: highlight?.connected
|
|
31
|
+
? "border-blue-400/60 ring-2 ring-blue-500/10"
|
|
32
|
+
: "border-zinc-700",
|
|
33
|
+
highlight?.dimmed ? "opacity-35" : "",
|
|
34
|
+
selected && !highlight?.focused ? "ring-2 ring-zinc-100/10" : "",
|
|
35
|
+
].join(" ")}
|
|
36
|
+
>
|
|
37
|
+
<div className="rounded-t-xl border-b border-zinc-700 bg-zinc-800 px-3 py-2">
|
|
38
|
+
<div className="flex min-w-0 items-center gap-2">
|
|
39
|
+
<Database className="h-3.5 w-3.5 shrink-0 text-zinc-400" />
|
|
40
|
+
<span className="min-w-0 flex-1 truncate text-[13px] font-semibold leading-none text-zinc-50">
|
|
41
|
+
{node.entity}
|
|
42
|
+
</span>
|
|
43
|
+
</div>
|
|
44
|
+
<div className="mt-1 truncate text-[10px] text-zinc-500">{node.cartridge}</div>
|
|
45
|
+
</div>
|
|
46
|
+
<div className="bg-zinc-900 py-1">
|
|
47
|
+
{node.fields.length === 0 ? (
|
|
48
|
+
<div className="px-3 py-2 text-[10px] text-zinc-500">No fields</div>
|
|
49
|
+
) : (
|
|
50
|
+
node.fields.map((field) => {
|
|
51
|
+
const type = `${field.type}${field.array ? "[]" : ""}${field.optional ? "?" : ""}`;
|
|
52
|
+
const isId = field.displayName.toLowerCase() === "id";
|
|
53
|
+
const isRelation = field.kind === "relation";
|
|
54
|
+
const isDocument = field.kind === "document";
|
|
55
|
+
const fieldHighlighted = highlightedFields.has(field.displayName);
|
|
56
|
+
return (
|
|
57
|
+
<div
|
|
58
|
+
key={`${field.name}:${field.displayName}`}
|
|
59
|
+
className={[
|
|
60
|
+
"relative grid min-h-[26px] grid-cols-[minmax(0,1fr)_auto] items-center gap-3 px-3 py-1 text-zinc-300",
|
|
61
|
+
fieldHighlighted ? "bg-blue-500/15 text-blue-200" : "",
|
|
62
|
+
].join(" ")}
|
|
63
|
+
>
|
|
64
|
+
{isId ? (
|
|
65
|
+
<>
|
|
66
|
+
<Handle
|
|
67
|
+
id={`target-${field.displayName}-left`}
|
|
68
|
+
type="target"
|
|
69
|
+
position={Position.Left}
|
|
70
|
+
className={`!left-0 ${hiddenHandleClass}`}
|
|
71
|
+
style={{ top: "50%" }}
|
|
72
|
+
/>
|
|
73
|
+
<Handle
|
|
74
|
+
id={`target-${field.displayName}-right`}
|
|
75
|
+
type="target"
|
|
76
|
+
position={Position.Right}
|
|
77
|
+
className={`!right-0 ${hiddenHandleClass}`}
|
|
78
|
+
style={{ top: "50%" }}
|
|
79
|
+
/>
|
|
80
|
+
</>
|
|
81
|
+
) : null}
|
|
82
|
+
{isRelation ? (
|
|
83
|
+
<>
|
|
84
|
+
<Handle
|
|
85
|
+
id={`source-${field.displayName}-left`}
|
|
86
|
+
type="source"
|
|
87
|
+
position={Position.Left}
|
|
88
|
+
className={`!left-0 ${hiddenHandleClass}`}
|
|
89
|
+
style={{ top: "50%" }}
|
|
90
|
+
/>
|
|
91
|
+
<Handle
|
|
92
|
+
id={`source-${field.displayName}-right`}
|
|
93
|
+
type="source"
|
|
94
|
+
position={Position.Right}
|
|
95
|
+
className={`!right-0 ${hiddenHandleClass}`}
|
|
96
|
+
style={{ top: "50%" }}
|
|
97
|
+
/>
|
|
98
|
+
</>
|
|
99
|
+
) : null}
|
|
100
|
+
<span className="flex min-w-0 items-center gap-1.5">
|
|
101
|
+
{isId ? <KeyRound className="h-3 w-3 shrink-0 text-zinc-500" /> : null}
|
|
102
|
+
{isRelation ? (
|
|
103
|
+
<Link2 className={["h-3 w-3 shrink-0", fieldHighlighted ? "text-blue-300" : "text-blue-400"].join(" ")} />
|
|
104
|
+
) : null}
|
|
105
|
+
{isDocument ? <FileText className="h-3 w-3 shrink-0 text-zinc-500" /> : null}
|
|
106
|
+
<span className="truncate">{field.displayName}</span>
|
|
107
|
+
</span>
|
|
108
|
+
<span className="max-w-[96px] truncate text-right text-zinc-500">{type}</span>
|
|
109
|
+
</div>
|
|
110
|
+
);
|
|
111
|
+
})
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
<div className="flex items-center justify-between gap-2 rounded-b-xl border-t border-zinc-700 bg-zinc-800 px-3 py-1.5 text-[10px] text-zinc-500">
|
|
115
|
+
<span className="tabular-nums">{node.rowCount.toLocaleString()} rows</span>
|
|
116
|
+
<span className="tabular-nums">
|
|
117
|
+
{node.relationCount} rel · {node.scalarCount} scalar
|
|
118
|
+
</span>
|
|
119
|
+
</div>
|
|
120
|
+
</div>
|
|
121
|
+
);
|
|
122
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The diagram is exported as `EntityGraph` — `DiagramView` was its tab-internal
|
|
2
|
+
// name. Mount inside a <BulkStreamProvider> for live row counts (see core).
|
|
3
|
+
export { DiagramView as EntityGraph } from "./diagram";
|
|
4
|
+
export { EntityNode } from "./entity-node";
|
|
5
|
+
export type { EntityNodeHighlight } from "./entity-node";
|
|
6
|
+
export {
|
|
7
|
+
cartsFromContracts,
|
|
8
|
+
entityGraphId,
|
|
9
|
+
buildEntityGraphModel,
|
|
10
|
+
filterEntityGraphModel,
|
|
11
|
+
} from "./entity-graph-utils";
|
|
12
|
+
export type {
|
|
13
|
+
Contract,
|
|
14
|
+
EntityGraphField,
|
|
15
|
+
EntityGraphNodeData,
|
|
16
|
+
EntityGraphNode,
|
|
17
|
+
EntityGraphEdge,
|
|
18
|
+
EntityGraphModel,
|
|
19
|
+
} from "./entity-graph-utils";
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
// @nbt-dev/components — reusable NBT building blocks. The root re-exports
|
|
2
|
+
// everything; subpath imports (`@nbt-dev/components/editor` | `/graph` | `/table`)
|
|
3
|
+
// let apps pull just one primitive and tree-shake the rest.
|
|
4
|
+
export * from "./core";
|
|
5
|
+
export * from "./editor";
|
|
6
|
+
export * from "./graph";
|
|
7
|
+
export * from "./table";
|
package/src/styles.css
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/* Prebuilt, self-contained stylesheet for STANDALONE @nbt-dev/components use.
|
|
2
|
+
*
|
|
3
|
+
* Only Tailwind's theme + utilities layers are pulled in — NOT preflight — so
|
|
4
|
+
* dropping this into a host app never resets the host's own styles. Every token
|
|
5
|
+
* the primitives reference is mapped here with concrete (dark) values scoped
|
|
6
|
+
* under `.nbt-ui`, so a bare editor/table renders correctly even when the host
|
|
7
|
+
* has no Tailwind theme. Wrap your subtree: <div className="nbt-ui dark">…</div>.
|
|
8
|
+
*
|
|
9
|
+
* (The @nbt-dev/devtools panel ships its OWN copy of these tokens under
|
|
10
|
+
* `.nimbit-devtools` — apps mounting the full panel don't need this file.) */
|
|
11
|
+
|
|
12
|
+
@import "@xyflow/react/dist/style.css";
|
|
13
|
+
|
|
14
|
+
@layer theme, base, components, utilities;
|
|
15
|
+
@import "tailwindcss/theme.css" layer(theme);
|
|
16
|
+
@import "tailwindcss/utilities.css" layer(utilities);
|
|
17
|
+
@import "tw-animate-css";
|
|
18
|
+
|
|
19
|
+
@custom-variant dark (&:is(.dark *));
|
|
20
|
+
|
|
21
|
+
@theme inline {
|
|
22
|
+
--color-background: var(--background);
|
|
23
|
+
--color-foreground: var(--foreground);
|
|
24
|
+
--color-card: var(--card);
|
|
25
|
+
--color-card-foreground: var(--card-foreground);
|
|
26
|
+
--color-popover: var(--popover);
|
|
27
|
+
--color-popover-foreground: var(--popover-foreground);
|
|
28
|
+
--color-primary: var(--primary);
|
|
29
|
+
--color-primary-foreground: var(--primary-foreground);
|
|
30
|
+
--color-secondary: var(--secondary);
|
|
31
|
+
--color-secondary-foreground: var(--secondary-foreground);
|
|
32
|
+
--color-muted: var(--muted);
|
|
33
|
+
--color-muted-foreground: var(--muted-foreground);
|
|
34
|
+
--color-accent: var(--accent);
|
|
35
|
+
--color-accent-foreground: var(--accent-foreground);
|
|
36
|
+
--color-destructive: var(--destructive);
|
|
37
|
+
--color-destructive-foreground: var(--destructive-foreground);
|
|
38
|
+
--color-border: var(--border);
|
|
39
|
+
--color-input: var(--input);
|
|
40
|
+
--color-ring: var(--ring);
|
|
41
|
+
--radius-sm: calc(var(--radius) * 0.6);
|
|
42
|
+
--radius-md: calc(var(--radius) * 0.8);
|
|
43
|
+
--radius-lg: var(--radius);
|
|
44
|
+
--radius-xl: calc(var(--radius) * 1.4);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
.nbt-ui {
|
|
48
|
+
color-scheme: dark;
|
|
49
|
+
scrollbar-width: thin;
|
|
50
|
+
scrollbar-color: oklch(0.4 0 0) transparent;
|
|
51
|
+
--radius: 0.625rem;
|
|
52
|
+
--background: oklch(0.145 0 0);
|
|
53
|
+
--foreground: oklch(0.985 0 0);
|
|
54
|
+
--card: oklch(0.205 0 0);
|
|
55
|
+
--card-foreground: oklch(0.985 0 0);
|
|
56
|
+
--popover: oklch(0.205 0 0);
|
|
57
|
+
--popover-foreground: oklch(0.985 0 0);
|
|
58
|
+
--primary: oklch(0.922 0 0);
|
|
59
|
+
--primary-foreground: oklch(0.205 0 0);
|
|
60
|
+
--secondary: oklch(0.269 0 0);
|
|
61
|
+
--secondary-foreground: oklch(0.985 0 0);
|
|
62
|
+
--muted: oklch(0.269 0 0);
|
|
63
|
+
--muted-foreground: oklch(0.708 0 0);
|
|
64
|
+
--accent: oklch(0.269 0 0);
|
|
65
|
+
--accent-foreground: oklch(0.985 0 0);
|
|
66
|
+
--destructive: oklch(0.704 0.191 22.216);
|
|
67
|
+
--destructive-foreground: oklch(0.985 0 0);
|
|
68
|
+
--border: oklch(1 0 0 / 10%);
|
|
69
|
+
--input: oklch(1 0 0 / 15%);
|
|
70
|
+
--ring: oklch(0.556 0 0);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
.nbt-ui ::-webkit-scrollbar,
|
|
74
|
+
.nbt-ui::-webkit-scrollbar {
|
|
75
|
+
width: 10px;
|
|
76
|
+
height: 10px;
|
|
77
|
+
}
|
|
78
|
+
.nbt-ui ::-webkit-scrollbar-track,
|
|
79
|
+
.nbt-ui::-webkit-scrollbar-track,
|
|
80
|
+
.nbt-ui ::-webkit-scrollbar-corner,
|
|
81
|
+
.nbt-ui::-webkit-scrollbar-corner {
|
|
82
|
+
background: transparent;
|
|
83
|
+
}
|
|
84
|
+
.nbt-ui ::-webkit-scrollbar-thumb,
|
|
85
|
+
.nbt-ui::-webkit-scrollbar-thumb {
|
|
86
|
+
background: oklch(0.4 0 0);
|
|
87
|
+
border: 2px solid transparent;
|
|
88
|
+
background-clip: padding-box;
|
|
89
|
+
border-radius: 5px;
|
|
90
|
+
}
|
|
91
|
+
.nbt-ui ::-webkit-scrollbar-thumb:hover,
|
|
92
|
+
.nbt-ui::-webkit-scrollbar-thumb:hover {
|
|
93
|
+
background: oklch(0.5 0 0);
|
|
94
|
+
}
|