@erd-studio/renderer 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -8,7 +8,7 @@ Peer dependencies: `react` and `react-dom` 18.3+, `@xyflow/react` 12.4+.
8
8
 
9
9
  | Import | What it is for |
10
10
  |---|---|
11
- | `@erd-studio/renderer` | Showing a domain: the `ErdCanvas` viewer component (below), plus `transformDomain` (a `DisplayDomain` to React Flow nodes and edges), `pickHandleSides`, `repickHandleSides` / `nodeRect`, and the display, domain and graph types (`DisplayDomain`, `ModelFlowNode`, `FkFlowEdge`, …). |
11
+ | `@erd-studio/renderer` | Showing a domain: the `ErdCanvas` viewer component (below), plus `transformDomain` (a `DisplayDomain` to React Flow nodes and edges), `pickHandleSides`, `repickHandleSides` / `nodeRect`, the diagram exporters re-exported from `@erd-studio/core` (below), and the display, domain and graph types (`DisplayDomain`, `ModelFlowNode`, `FkFlowEdge`, …). |
12
12
  | `@erd-studio/renderer/editor` | Everything above plus what a host needs to build a full editor around the canvas, as the extension's webview does: the store (below), the host adapter, `useCanvasGraph`, `CanvasBackdrop`, `canvasNodeTypes` / `canvasEdgeTypes`, the components (`ModelNode`, `FkEdge`, `AnnotationNode`, `AnnotationEdge`, `DetailPanel`, `Legend`, `KeyBadge`, `KeyBadgeGroup`, `DataTypeSelect`, `ColumnRowEditor`), the canvas hooks and the pure helpers. |
13
13
  | `@erd-studio/renderer/store` | The canvas store on its own, with no React Flow runtime: `createCanvasSlice`, `createCanvasStore`, `CanvasStoreProvider`, `useEditorStore`, `useEditorStoreApi` and their types. |
14
14
  | `@erd-studio/renderer/sizing` | Model node size estimation (`NODE_WIDTH`, `estimateNodeWidth`, `estimateNodeHeight`, `countVisibleColumnRows`, `resolveNodeDimensions`), for layout code that needs node sizes before React Flow has measured them. No components. |
@@ -49,6 +49,24 @@ The ref exposes `resetLayout()`, which puts every node back at its position in t
49
49
 
50
50
  Each `ErdCanvas` has its own store, so several can share a page.
51
51
 
52
+ ## Exporting a diagram
53
+
54
+ The `.` entry re-exports `@erd-studio/core`'s one-way exporters, so a page that shows a diagram with `ErdCanvas` can offer it as text without depending on core itself: `toMermaid`, `toDbml`, `exportDiagram`, `diagramExportFileName`, `DIAGRAM_EXPORT_FORMATS`, `DIAGRAM_EXPORT_FILE_EXTENSIONS` and the `DiagramExportFormat` type. They are pure functions with no React or DOM use; the same `DisplayDomain` gives the same text everywhere ERD Studio exports it.
55
+
56
+ ```ts
57
+ import { exportDiagram, diagramExportFileName, type DisplayDomain } from '@erd-studio/renderer';
58
+
59
+ function downloadDbml(domain: DisplayDomain) {
60
+ const text = exportDiagram(domain, 'dbml'); // or 'mermaid'
61
+ const url = URL.createObjectURL(new Blob([text], { type: 'text/plain' }));
62
+ const link = Object.assign(document.createElement('a'), { href: url, download: diagramExportFileName(domain, 'dbml') });
63
+ link.click();
64
+ setTimeout(() => URL.revokeObjectURL(url), 1000);
65
+ }
66
+ ```
67
+
68
+ See the [`@erd-studio/core` README](https://github.com/liam-machine/erd-studio/blob/main/packages/core/README.md#exporting-a-diagram) for what each format carries.
69
+
52
70
  ## Editor: store and host
53
71
 
54
72
  The canvas components read their state from the store supplied by the nearest `CanvasStoreProvider`, and post the edits a user makes on the canvas (renaming a column, moving an annotation, changing a cardinality, …) as `CanvasEditMessage`s to the `CanvasHost` supplied by `CanvasEnvironmentProvider`.
@@ -66,6 +66,41 @@ function useIsViewer() {
66
66
  // src/canvas/useCanvasGraph.ts
67
67
  import { useCallback as useCallback2, useEffect, useRef } from "react";
68
68
 
69
+ // src/lib/relationshipDisplayKey.ts
70
+ function columnPairs(rel) {
71
+ return rel.pairs && rel.pairs.length > 0 ? rel.pairs.map((p) => ({ fromColumn: p.fromColumn, toColumn: p.toColumn })) : [{ fromColumn: rel.fromColumn, toColumn: rel.toColumn }];
72
+ }
73
+ function relationshipDisplayKey(rel) {
74
+ const pairs = columnPairs(rel);
75
+ return `${rel.fromModel}|${pairs.map((p) => p.fromColumn).join("+")}|${rel.toModel}|${pairs.map((p) => p.toColumn).join("+")}`;
76
+ }
77
+ function relationshipEdgeId(rel) {
78
+ const pairs = columnPairs(rel);
79
+ return `fk-${rel.fromModel}-${pairs.map((p) => p.fromColumn).join("+")}-${rel.toModel}-${pairs.map((p) => p.toColumn).join("+")}`;
80
+ }
81
+ function foldComposites(rels) {
82
+ const groups = /* @__PURE__ */ new Map();
83
+ const idOf = (r) => [r.fromModel, r.toModel, r.compositeKey].map((s) => s.toLowerCase()).join("\0");
84
+ for (const r of rels) {
85
+ if (!r.compositeKey) continue;
86
+ groups.set(idOf(r), [...groups.get(idOf(r)) ?? [], r]);
87
+ }
88
+ const out = [];
89
+ const placed = /* @__PURE__ */ new Set();
90
+ for (const r of rels) {
91
+ if (!r.compositeKey) {
92
+ out.push(r);
93
+ continue;
94
+ }
95
+ const id = idOf(r);
96
+ if (placed.has(id)) continue;
97
+ placed.add(id);
98
+ const members = groups.get(id);
99
+ out.push(members.length < 2 ? r : { ...members[0], pairs: members.map((m) => ({ fromColumn: m.fromColumn, toColumn: m.toColumn })) });
100
+ }
101
+ return out;
102
+ }
103
+
69
104
  // src/lib/modelLabels.ts
70
105
  function computeModelLabels(models) {
71
106
  const baseOf = (m) => m.alias?.trim() || m.name;
@@ -102,7 +137,8 @@ function mapColumns(model) {
102
137
  isNaturalKey: col.isNaturalKey,
103
138
  ...col.scdType != null ? { scdType: col.scdType } : {},
104
139
  ...col.additiveType ? { additiveType: col.additiveType } : {},
105
- ...hasMeta(col.meta) ? { meta: col.meta } : {}
140
+ ...hasMeta(col.meta) ? { meta: col.meta } : {},
141
+ ...col.dbtKey ? { dbtKey: col.dbtKey } : {}
106
142
  }));
107
143
  return mapped;
108
144
  }
@@ -171,6 +207,7 @@ function transformDomain(domain, options) {
171
207
  // untouched. Never derive it inside an overlay pass.
172
208
  ...model.provenance ? { provenance: model.provenance } : {},
173
209
  ...model.loadError ? { loadError: model.loadError } : {},
210
+ ...model.loadWarning ? { loadWarning: model.loadWarning } : {},
174
211
  ...disc ? { discrepancy: disc } : {},
175
212
  ...disc && options?.discrepancyReport ? {
176
213
  discrepancySourceStage: options.discrepancyReport.sourceStage,
@@ -218,20 +255,16 @@ function transformDomain(domain, options) {
218
255
  const relDiscrepancyMap = /* @__PURE__ */ new Map();
219
256
  if (options?.discrepancyReport) {
220
257
  for (const rd of options.discrepancyReport.relationships) {
221
- if (rd.status !== "matched") {
222
- const key = `${rd.fromModel}|${rd.fromColumn}|${rd.toModel}|${rd.toColumn}`;
223
- relDiscrepancyMap.set(key, rd.status);
224
- }
258
+ if (rd.status !== "matched") relDiscrepancyMap.set(relationshipDisplayKey(rd), rd.status);
225
259
  }
226
260
  }
227
261
  const allNodeNames = new Set(positionMap.keys());
228
- const edges = relationships.filter((rel) => allNodeNames.has(rel.fromModel) && allNodeNames.has(rel.toModel)).map((rel) => {
262
+ const edges = foldComposites(relationships).filter((rel) => allNodeNames.has(rel.fromModel) && allNodeNames.has(rel.toModel)).map((rel) => {
229
263
  const isSelfLoop = rel.fromModel === rel.toModel;
230
264
  const { sourceSide, targetSide } = isSelfLoop ? { sourceSide: "top", targetSide: "right" } : pickHandleSides(rectOf(rel.fromModel), rectOf(rel.toModel));
231
- const relKey = `${rel.fromModel}|${rel.fromColumn}|${rel.toModel}|${rel.toColumn}`;
232
- const discStatus = relDiscrepancyMap.get(relKey);
265
+ const discStatus = relDiscrepancyMap.get(relationshipDisplayKey(rel));
233
266
  return {
234
- id: `fk-${rel.fromModel}-${rel.fromColumn}-${rel.toModel}-${rel.toColumn}`,
267
+ id: relationshipEdgeId(rel),
235
268
  type: "fk",
236
269
  source: rel.fromModel,
237
270
  target: rel.toModel,
@@ -243,6 +276,8 @@ function transformDomain(domain, options) {
243
276
  toModel: rel.toModel,
244
277
  toColumn: rel.toColumn,
245
278
  cardinality: rel.cardinality,
279
+ ...rel.role ? { role: rel.role } : {},
280
+ ...rel.pairs ? { pairs: rel.pairs, compositeKey: rel.compositeKey } : {},
246
281
  stage,
247
282
  ...readOnly ? { readOnly: true } : {},
248
283
  ...discStatus ? { discrepancyStatus: discStatus } : {},
@@ -256,8 +291,10 @@ function transformDomain(domain, options) {
256
291
  if (!positionMap.has(rd.fromModel) || !positionMap.has(rd.toModel)) continue;
257
292
  const isSelfLoop = rd.fromModel === rd.toModel;
258
293
  const { sourceSide, targetSide } = isSelfLoop ? { sourceSide: "top", targetSide: "right" } : pickHandleSides(rectOf(rd.fromModel), rectOf(rd.toModel));
294
+ const pairs = columnPairs(rd);
295
+ const composite = pairs.length > 1 ? { pairs, ...rd.compositeKey ? { compositeKey: rd.compositeKey } : {} } : {};
259
296
  edges.push({
260
- id: `ghost-fk-${rd.fromModel}-${rd.fromColumn}-${rd.toModel}-${rd.toColumn}`,
297
+ id: `ghost-${relationshipEdgeId({ ...rd, pairs })}`,
261
298
  type: "fk",
262
299
  source: rd.fromModel,
263
300
  target: rd.toModel,
@@ -269,6 +306,7 @@ function transformDomain(domain, options) {
269
306
  toModel: rd.toModel,
270
307
  toColumn: rd.toColumn,
271
308
  cardinality: rd.sourceCardinality ?? rd.targetCardinality ?? "many-to-one",
309
+ ...composite,
272
310
  stage,
273
311
  discrepancyStatus: "missing",
274
312
  ...isSelfLoop ? { isSelfLoop: true } : {}
@@ -277,6 +315,16 @@ function transformDomain(domain, options) {
277
315
  }
278
316
  }
279
317
  }
318
+ const loopsByNode = /* @__PURE__ */ new Map();
319
+ for (const edge of edges) {
320
+ if (edge.type !== "fk" || !edge.data?.isSelfLoop) continue;
321
+ loopsByNode.set(edge.source, [...loopsByNode.get(edge.source) ?? [], edge]);
322
+ }
323
+ for (const loops of loopsByNode.values()) {
324
+ [...loops].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).forEach((edge, loopIndex) => {
325
+ edge.data = { ...edge.data, loopIndex };
326
+ });
327
+ }
280
328
  const annotations = domain.viewConfig.annotations ?? [];
281
329
  for (const ann of annotations) {
282
330
  const annNodeId = `annotation-${ann.id}`;
@@ -499,8 +547,10 @@ function useCanvasGraph({
499
547
  if (edge.type !== "fk") return;
500
548
  if (edge.data) {
501
549
  const cols = /* @__PURE__ */ new Set();
502
- cols.add(`${edge.data.fromModel}:${edge.data.fromColumn}`);
503
- cols.add(`${edge.data.toModel}:${edge.data.toColumn}`);
550
+ for (const pair of columnPairs(edge.data)) {
551
+ cols.add(`${edge.data.fromModel}:${pair.fromColumn}`);
552
+ cols.add(`${edge.data.toModel}:${pair.toColumn}`);
553
+ }
504
554
  setHighlightedColumns(cols);
505
555
  selectNode(null);
506
556
  setDetailPanelOpen(false);
@@ -662,6 +712,19 @@ function useLongPressDrag(options = {}) {
662
712
  };
663
713
  }
664
714
 
715
+ // src/lib/dbtKeyTitle.ts
716
+ function dbtKeyTitle(hint) {
717
+ switch (hint.because) {
718
+ case "unique-test":
719
+ case "unique-combination":
720
+ return "dbt: tested as unique";
721
+ case "part-of-unique-combination":
722
+ return "dbt: part of a unique combination";
723
+ case "relationships-test":
724
+ return "dbt: has a relationships test";
725
+ }
726
+ }
727
+
665
728
  // src/hooks/useColumnReorder.ts
666
729
  import { useCallback as useCallback4, useEffect as useEffect3, useRef as useRef3, useState as useState2 } from "react";
667
730
  function useColumnReorder({
@@ -1249,9 +1312,12 @@ var DISCREPANCY_PHRASE = {
1249
1312
  "cardinality-mismatch": "cardinality differs between stages"
1250
1313
  };
1251
1314
  function edgeHoverText(edge) {
1315
+ const composite = edge.pairs && edge.pairs.length > 1;
1316
+ const ends = composite ? `${edge.fromModel} (${edge.pairs.map((p) => p.fromColumn).join(", ")}) \u2192 ${edge.toModel} (${edge.pairs.map((p) => p.toColumn).join(", ")})` : `${edge.fromModel}.${edge.fromColumn} \u2192 ${edge.toModel}.${edge.toColumn}`;
1252
1317
  return [
1253
- `${edge.fromModel}.${edge.fromColumn} \u2192 ${edge.toModel}.${edge.toColumn}`,
1318
+ ends,
1254
1319
  edge.cardinality.replace(/-/g, " "),
1320
+ edge.role ? `role: ${edge.role}` : "",
1255
1321
  edge.discrepancyStatus ? DISCREPANCY_PHRASE[edge.discrepancyStatus] : ""
1256
1322
  ].filter(Boolean).join(" \xB7 ");
1257
1323
  }
@@ -1454,21 +1520,21 @@ function ColumnRow({ column, modelName, readOnly, existingColumnNames, discrepan
1454
1520
  if (columnRow && modelNode) {
1455
1521
  const targetColumnName = columnRow.dataset.columnName;
1456
1522
  const targetModelName = modelNode.dataset.modelName;
1457
- if (targetColumnName && targetModelName && targetModelName !== modelName) {
1458
- window.dispatchEvent(
1459
- new CustomEvent("column-relationship-drop", {
1460
- detail: {
1461
- fromModel: modelName,
1462
- fromColumn: column.name,
1463
- toModel: targetModelName,
1464
- toColumn: targetColumnName
1465
- }
1466
- })
1467
- );
1468
- } else if (targetModelName === modelName) {
1469
- window.dispatchEvent(
1470
- new CustomEvent("column-relationship-self-drop")
1471
- );
1523
+ if (targetColumnName && targetModelName) {
1524
+ if (targetModelName === modelName && targetColumnName.toLowerCase() === column.name.toLowerCase()) {
1525
+ window.dispatchEvent(new CustomEvent("column-relationship-self-drop"));
1526
+ } else {
1527
+ window.dispatchEvent(
1528
+ new CustomEvent("column-relationship-drop", {
1529
+ detail: {
1530
+ fromModel: modelName,
1531
+ fromColumn: column.name,
1532
+ toModel: targetModelName,
1533
+ toColumn: targetColumnName
1534
+ }
1535
+ })
1536
+ );
1537
+ }
1472
1538
  }
1473
1539
  }
1474
1540
  endDrag();
@@ -1567,7 +1633,8 @@ function ColumnRow({ column, modelName, readOnly, existingColumnNames, discrepan
1567
1633
  "span",
1568
1634
  {
1569
1635
  className: `model-node__col-name${!readOnly ? " model-node__col-name--editable" : ""}`,
1570
- title: column.name,
1636
+ title: !viewer && column.dbtKey ? `${column.name}
1637
+ ${dbtKeyTitle(column.dbtKey)}` : column.name,
1571
1638
  onDoubleClick: !readOnly ? handleDoubleClickName : void 0,
1572
1639
  children: column.name
1573
1640
  }
@@ -1672,10 +1739,17 @@ function describeLoadError(error) {
1672
1739
  if (error.kind === "read") {
1673
1740
  return "ERD Studio can't read this file.";
1674
1741
  }
1742
+ if (error.mergeConflict) {
1743
+ return error.line !== void 0 ? `Unresolved git merge conflict on line ${error.line} \u2014 ERD Studio can't read this file.` : "Unresolved git merge conflict \u2014 ERD Studio can't read this file.";
1744
+ }
1675
1745
  return error.line !== void 0 ? `YAML error on line ${error.line} \u2014 ERD Studio can't read this file.` : "YAML error \u2014 ERD Studio can't read this file.";
1676
1746
  }
1747
+ function describeLoadWarning(warning) {
1748
+ const key = warning.key ? `"${warning.key}"` : "A key";
1749
+ return `${key} is written twice (line ${warning.line}) \u2014 the last one is shown. Remove the other copy to edit this model.`;
1750
+ }
1677
1751
  function ModelNodeComponent({ data, selected }) {
1678
- const { modelName, label, stage, layer, layerConfig, schema, columns, grain, meta, dimmed, readOnly, isGhost, ghostReason, provenance, loadError, isStub, isExpanded = false, onToggleExpansion, discrepancy, discrepancySourceStage, discrepancyTargetStage } = data;
1752
+ const { modelName, label, stage, layer, layerConfig, schema, columns, grain, meta, dimmed, readOnly, isGhost, ghostReason, provenance, loadError, loadWarning, isStub, isExpanded = false, onToggleExpansion, discrepancy, discrepancySourceStage, discrepancyTargetStage } = data;
1679
1753
  const openNodeContextMenu = useEditorStore((s) => s.openNodeContextMenu);
1680
1754
  const send = useSend();
1681
1755
  const viewer = useIsViewer();
@@ -1784,6 +1858,21 @@ function ModelNodeComponent({ data, selected }) {
1784
1858
  badgeTip.tip
1785
1859
  ] }),
1786
1860
  grain && /* @__PURE__ */ jsx7("div", { className: "model-node__grain", title: grain, children: grain }),
1861
+ loadWarning && /* @__PURE__ */ jsxs5("div", { className: "model-node__load-warning", role: "status", children: [
1862
+ /* @__PURE__ */ jsx7("span", { className: "model-node__load-warning-text", children: describeLoadWarning(loadWarning) }),
1863
+ viewer ? null : /* @__PURE__ */ jsx7(
1864
+ "button",
1865
+ {
1866
+ type: "button",
1867
+ className: "model-node__load-error-open nodrag",
1868
+ onClick: (e) => {
1869
+ e.stopPropagation();
1870
+ send({ type: "openModelFile", payload: { modelName } });
1871
+ },
1872
+ children: "Open file"
1873
+ }
1874
+ )
1875
+ ] }),
1787
1876
  /* @__PURE__ */ jsxs5("div", { className: "model-node__columns", children: [
1788
1877
  displayColumns.map((col, idx) => /* @__PURE__ */ jsx7(
1789
1878
  ColumnRow,
@@ -2017,6 +2106,7 @@ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-run
2017
2106
  var PATH_GAP = 20;
2018
2107
  var LABEL_OFFSET = 8;
2019
2108
  var SELF_LOOP_RADIUS = 55;
2109
+ var SELF_LOOP_STEP = 18;
2020
2110
  var NO_SIBLINGS = Object.freeze([]);
2021
2111
  function offsetPoint(x, y, position, distance) {
2022
2112
  switch (position) {
@@ -2089,7 +2179,8 @@ function FkEdgeComponent({
2089
2179
  [host, data]
2090
2180
  );
2091
2181
  if (!data) return null;
2092
- const { cardinality, stage, discrepancyStatus, dimmed, readOnly, isSelfLoop, fromColumn, toColumn } = data;
2182
+ const { cardinality, role, stage, discrepancyStatus, dimmed, readOnly, isSelfLoop, fromColumn, toColumn } = data;
2183
+ const loopRadius = SELF_LOOP_RADIUS + SELF_LOOP_STEP * (data.loopIndex ?? 0);
2093
2184
  let adjustedSourceX = sourceX;
2094
2185
  let adjustedSourceY = sourceY;
2095
2186
  let adjustedTargetX = targetX;
@@ -2122,7 +2213,7 @@ function FkEdgeComponent({
2122
2213
  }
2123
2214
  const src = offsetPoint(adjustedSourceX, adjustedSourceY, sourcePosition, PATH_GAP);
2124
2215
  const tgt = offsetPoint(adjustedTargetX, adjustedTargetY, targetPosition, PATH_GAP);
2125
- const edgePath = isSelfLoop ? `M ${src.x},${src.y} C ${src.x + SELF_LOOP_RADIUS},${src.y - SELF_LOOP_RADIUS} ${tgt.x + SELF_LOOP_RADIUS},${tgt.y - SELF_LOOP_RADIUS} ${tgt.x},${tgt.y}` : getSmoothStepPath({
2216
+ const edgePath = isSelfLoop ? `M ${src.x},${src.y} C ${src.x + loopRadius},${src.y - loopRadius} ${tgt.x + loopRadius},${tgt.y - loopRadius} ${tgt.x},${tgt.y}` : getSmoothStepPath({
2126
2217
  sourceX: src.x,
2127
2218
  sourceY: src.y,
2128
2219
  targetX: tgt.x,
@@ -2138,8 +2229,11 @@ function FkEdgeComponent({
2138
2229
  toModel,
2139
2230
  toColumn,
2140
2231
  cardinality,
2141
- discrepancyStatus
2232
+ role,
2233
+ discrepancyStatus,
2234
+ pairs: data.pairs
2142
2235
  });
2236
+ const compositeSize = data.pairs && data.pairs.length > 1 ? data.pairs.length : 0;
2143
2237
  let sourceLabel;
2144
2238
  let targetLabel;
2145
2239
  switch (cardinality) {
@@ -2168,8 +2262,8 @@ function FkEdgeComponent({
2168
2262
  const labelColorClass = discrepancyStatus ? `fk-edge__label--discrepancy-${discrepancyStatus}` : `fk-edge__label--${stage ?? "logical"}`;
2169
2263
  const srcLabel = offsetPoint(adjustedSourceX, adjustedSourceY, sourcePosition, LABEL_OFFSET);
2170
2264
  const tgtLabel = offsetPoint(adjustedTargetX, adjustedTargetY, targetPosition, LABEL_OFFSET);
2171
- const midX = isSelfLoop ? (src.x + tgt.x) / 2 + SELF_LOOP_RADIUS * 0.75 : (adjustedSourceX + adjustedTargetX) / 2;
2172
- const midY = isSelfLoop ? (src.y + tgt.y) / 2 - SELF_LOOP_RADIUS * 0.75 : (adjustedSourceY + adjustedTargetY) / 2;
2265
+ const midX = isSelfLoop ? (src.x + tgt.x) / 2 + loopRadius * 0.75 : (adjustedSourceX + adjustedTargetX) / 2;
2266
+ const midY = isSelfLoop ? (src.y + tgt.y) / 2 - loopRadius * 0.75 : (adjustedSourceY + adjustedTargetY) / 2;
2173
2267
  return /* @__PURE__ */ jsxs6(Fragment3, { children: [
2174
2268
  /* @__PURE__ */ jsx8(
2175
2269
  "path",
@@ -2211,6 +2305,30 @@ function FkEdgeComponent({
2211
2305
  children: targetLabel
2212
2306
  }
2213
2307
  ),
2308
+ role && /* @__PURE__ */ jsx8(
2309
+ "span",
2310
+ {
2311
+ className: `fk-edge__role ${labelColorClass}${dimmed ? " fk-edge__label--dimmed" : ""}`,
2312
+ style: {
2313
+ transform: `translate(-50%, calc(-100% - 10px)) translate(${midX}px, ${midY}px)`
2314
+ },
2315
+ children: role
2316
+ }
2317
+ ),
2318
+ compositeSize > 0 && /* @__PURE__ */ jsxs6(
2319
+ "span",
2320
+ {
2321
+ className: `fk-edge__composite ${labelColorClass}${dimmed ? " fk-edge__label--dimmed" : ""}`,
2322
+ style: {
2323
+ transform: `translate(-50%, calc(100% + 6px)) translate(${midX}px, ${midY}px)`
2324
+ },
2325
+ title: `Composite foreign key: ${compositeSize} column pairs`,
2326
+ children: [
2327
+ "\u29C9 ",
2328
+ compositeSize
2329
+ ]
2330
+ }
2331
+ ),
2214
2332
  discrepancyStatus === "cardinality-mismatch" && /* @__PURE__ */ jsx8(
2215
2333
  "span",
2216
2334
  {
@@ -4538,6 +4656,10 @@ function ColumnEditor({ modelName, columns, readOnly, modelRole }) {
4538
4656
 
4539
4657
  // src/components/DetailPanel/DetailPanel.tsx
4540
4658
  import { Fragment as Fragment4, jsx as jsx22, jsxs as jsxs19 } from "react/jsx-runtime";
4659
+ function columnsText(rel, end) {
4660
+ const cols = columnPairs(rel).map((p) => end === "from" ? p.fromColumn : p.toColumn);
4661
+ return cols.length === 1 ? cols[0] : `(${cols.join(", ")})`;
4662
+ }
4541
4663
  var SOURCE_NAME = {
4542
4664
  catalog: "warehouse catalog",
4543
4665
  yml: "your dbt .yml",
@@ -4659,6 +4781,7 @@ function DetailPanel() {
4659
4781
  handleClose();
4660
4782
  }, [selectedNode, host, handleClose]);
4661
4783
  const handleDeleteRelationship = useCallback22(
4784
+ // A composite sends its first pair; the host removes the whole group (#133 L2).
4662
4785
  (rel) => {
4663
4786
  host.postMessage({
4664
4787
  type: "removeRelationship",
@@ -4679,7 +4802,10 @@ function DetailPanel() {
4679
4802
  fromColumn: rel.fromColumn,
4680
4803
  toModel: rel.toModel,
4681
4804
  toColumn: rel.toColumn,
4682
- cardinality: rel.cardinality
4805
+ cardinality: rel.cardinality,
4806
+ // Carried so Edit opens with it — the dialog saves what it shows.
4807
+ ...rel.role ? { role: rel.role } : {},
4808
+ ...rel.pairs ? { pairs: rel.pairs, ...rel.compositeKey ? { compositeKey: rel.compositeKey } : {} } : {}
4683
4809
  };
4684
4810
  openEdgeContextMenu(x, y, edgeData);
4685
4811
  },
@@ -4693,8 +4819,9 @@ function DetailPanel() {
4693
4819
  if (!domain || !selectedNode) {
4694
4820
  return { outgoing: [], incoming: [] };
4695
4821
  }
4696
- const outgoing2 = domain.relationships.filter((r) => r.fromModel === selectedNode);
4697
- const incoming2 = domain.relationships.filter((r) => r.toModel === selectedNode);
4822
+ const rels = foldComposites(domain.relationships);
4823
+ const outgoing2 = rels.filter((r) => r.fromModel === selectedNode);
4824
+ const incoming2 = rels.filter((r) => r.toModel === selectedNode && r.fromModel !== selectedNode);
4698
4825
  return { outgoing: outgoing2, incoming: incoming2 };
4699
4826
  }, [domain, selectedNode]);
4700
4827
  const isReadOnly = domain?.readOnly ?? false;
@@ -4862,14 +4989,14 @@ function DetailPanel() {
4862
4989
  },
4863
4990
  title: viewer ? void 0 : "Click to edit cardinality",
4864
4991
  children: [
4865
- /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-direction", title: "Outgoing FK", children: "\u2192" }),
4992
+ /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-direction", title: rel.toModel === rel.fromModel ? "Self-reference" : "Outgoing FK", children: rel.toModel === rel.fromModel ? "\u21BB" : "\u2192" }),
4866
4993
  /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-columns", children: [
4867
- /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-local", title: rel.fromColumn, children: rel.fromColumn }),
4994
+ /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-local", title: columnsText(rel, "from"), children: columnsText(rel, "from") }),
4868
4995
  /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-arrow", children: "\u2192" }),
4869
- /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-target", title: `${rel.toModel}.${rel.toColumn}`, children: [
4996
+ /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-target", title: `${rel.toModel}.${columnsText(rel, "to")}`, children: [
4870
4997
  rel.toModel,
4871
4998
  ".",
4872
- rel.toColumn
4999
+ columnsText(rel, "to")
4873
5000
  ] })
4874
5001
  ] }),
4875
5002
  /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-cardinality", children: rel.cardinality }),
@@ -4888,7 +5015,7 @@ function DetailPanel() {
4888
5015
  )
4889
5016
  ]
4890
5017
  },
4891
- `out-${rel.fromColumn}-${rel.toModel}-${rel.toColumn}`
5018
+ `out-${relationshipEdgeId(rel)}`
4892
5019
  )),
4893
5020
  incoming.map((rel) => /* @__PURE__ */ jsxs19(
4894
5021
  "div",
@@ -4908,13 +5035,13 @@ function DetailPanel() {
4908
5035
  children: [
4909
5036
  /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-direction", title: "Incoming FK", children: "\u2190" }),
4910
5037
  /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-columns", children: [
4911
- /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-target", title: `${rel.fromModel}.${rel.fromColumn}`, children: [
5038
+ /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-target", title: `${rel.fromModel}.${columnsText(rel, "from")}`, children: [
4912
5039
  rel.fromModel,
4913
5040
  ".",
4914
- rel.fromColumn
5041
+ columnsText(rel, "from")
4915
5042
  ] }),
4916
5043
  /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-arrow", children: "\u2192" }),
4917
- /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-local", title: rel.toColumn, children: rel.toColumn })
5044
+ /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-local", title: columnsText(rel, "to"), children: columnsText(rel, "to") })
4918
5045
  ] }),
4919
5046
  /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-cardinality", children: rel.cardinality }),
4920
5047
  !isReadOnly && /* @__PURE__ */ jsx22(
@@ -4932,7 +5059,7 @@ function DetailPanel() {
4932
5059
  )
4933
5060
  ]
4934
5061
  },
4935
- `in-${rel.fromModel}-${rel.fromColumn}-${rel.toColumn}`
5062
+ `in-${relationshipEdgeId(rel)}`
4936
5063
  ))
4937
5064
  ] })
4938
5065
  ] })
@@ -5379,12 +5506,26 @@ var ErdCanvas = forwardRef(function ErdCanvas2(props, ref) {
5379
5506
  return /* @__PURE__ */ jsx24(CanvasStoreProvider, { store, children: /* @__PURE__ */ jsx24(CanvasEnvironmentProvider, { host: NOOP_CANVAS_HOST, viewer: true, children: /* @__PURE__ */ jsx24(ReactFlowProvider, { children: /* @__PURE__ */ jsx24(ErdCanvasInner, { ...props, domain: renderable, handleRef: ref }) }) }) });
5380
5507
  });
5381
5508
 
5509
+ // src/index.ts
5510
+ import {
5511
+ toMermaid,
5512
+ toDbml,
5513
+ exportDiagram,
5514
+ diagramExportFileName,
5515
+ DIAGRAM_EXPORT_FORMATS,
5516
+ DIAGRAM_EXPORT_FILE_EXTENSIONS
5517
+ } from "@erd-studio/core";
5518
+
5382
5519
  export {
5383
5520
  NOOP_CANVAS_HOST,
5384
5521
  CanvasEnvironmentProvider,
5385
5522
  useCanvasHost,
5386
5523
  useSend,
5387
5524
  useIsViewer,
5525
+ columnPairs,
5526
+ relationshipDisplayKey,
5527
+ relationshipEdgeId,
5528
+ foldComposites,
5388
5529
  computeModelLabels,
5389
5530
  matchesModelSearch,
5390
5531
  pickHandleSides,
@@ -5425,5 +5566,11 @@ export {
5425
5566
  ColumnRowEditor,
5426
5567
  DetailPanel,
5427
5568
  Legend,
5428
- ErdCanvas
5569
+ ErdCanvas,
5570
+ toMermaid,
5571
+ toDbml,
5572
+ exportDiagram,
5573
+ diagramExportFileName,
5574
+ DIAGRAM_EXPORT_FORMATS,
5575
+ DIAGRAM_EXPORT_FILE_EXTENSIONS
5429
5576
  };
package/dist/editor.js CHANGED
@@ -8,6 +8,8 @@ import {
8
8
  CanvasBackdrop,
9
9
  CanvasEnvironmentProvider,
10
10
  ColumnRowEditor,
11
+ DIAGRAM_EXPORT_FILE_EXTENSIONS,
12
+ DIAGRAM_EXPORT_FORMATS,
11
13
  DISCREPANCY_PHRASE,
12
14
  DataTypeSelect,
13
15
  DetailPanel,
@@ -28,15 +30,23 @@ import {
28
30
  applyNodeOverlays,
29
31
  canvasEdgeTypes,
30
32
  canvasNodeTypes,
33
+ columnPairs,
31
34
  computeModelLabels,
35
+ diagramExportFileName,
32
36
  edgeHoverText,
37
+ exportDiagram,
38
+ foldComposites,
33
39
  getDataTypeColor,
34
40
  matchesModelSearch,
35
41
  nodeRect,
36
42
  pickHandleSides,
43
+ relationshipDisplayKey,
44
+ relationshipEdgeId,
37
45
  repickHandleSides,
38
46
  stageNodeColor,
39
47
  swapCardinality,
48
+ toDbml,
49
+ toMermaid,
40
50
  transformDomain,
41
51
  useCanvasGraph,
42
52
  useCanvasHost,
@@ -45,7 +55,7 @@ import {
45
55
  useIsViewer,
46
56
  useLongPressDrag,
47
57
  useSend
48
- } from "./chunk-5OQLRX7R.js";
58
+ } from "./chunk-IAFK7ZZV.js";
49
59
  import "./chunk-QZGU5NZH.js";
50
60
  import "./chunk-6XQXDZ4R.js";
51
61
  import {
@@ -77,6 +87,8 @@ export {
77
87
  CanvasEnvironmentProvider,
78
88
  CanvasStoreProvider,
79
89
  ColumnRowEditor,
90
+ DIAGRAM_EXPORT_FILE_EXTENSIONS,
91
+ DIAGRAM_EXPORT_FORMATS,
80
92
  DISCREPANCY_PHRASE,
81
93
  DataTypeSelect,
82
94
  DetailPanel,
@@ -99,21 +111,29 @@ export {
99
111
  applyNodeOverlays,
100
112
  canvasEdgeTypes,
101
113
  canvasNodeTypes,
114
+ columnPairs,
102
115
  computeModelLabels,
103
116
  countVisibleColumnRows,
104
117
  createCanvasSlice,
105
118
  createCanvasStore,
119
+ diagramExportFileName,
106
120
  edgeHoverText,
107
121
  estimateNodeHeight,
108
122
  estimateNodeWidth,
123
+ exportDiagram,
124
+ foldComposites,
109
125
  getDataTypeColor,
110
126
  matchesModelSearch,
111
127
  nodeRect,
112
128
  pickHandleSides,
129
+ relationshipDisplayKey,
130
+ relationshipEdgeId,
113
131
  repickHandleSides,
114
132
  resolveNodeDimensions,
115
133
  stageNodeColor,
116
134
  swapCardinality,
135
+ toDbml,
136
+ toMermaid,
117
137
  transformDomain,
118
138
  useCanvasGraph,
119
139
  useCanvasHost,
package/dist/index.js CHANGED
@@ -1,16 +1,28 @@
1
1
  import {
2
+ DIAGRAM_EXPORT_FILE_EXTENSIONS,
3
+ DIAGRAM_EXPORT_FORMATS,
2
4
  ErdCanvas,
5
+ diagramExportFileName,
6
+ exportDiagram,
3
7
  nodeRect,
4
8
  pickHandleSides,
5
9
  repickHandleSides,
10
+ toDbml,
11
+ toMermaid,
6
12
  transformDomain
7
- } from "./chunk-5OQLRX7R.js";
13
+ } from "./chunk-IAFK7ZZV.js";
8
14
  import "./chunk-DUQ2TTQH.js";
9
15
  import "./chunk-WOTSQZCQ.js";
10
16
  export {
17
+ DIAGRAM_EXPORT_FILE_EXTENSIONS,
18
+ DIAGRAM_EXPORT_FORMATS,
11
19
  ErdCanvas,
20
+ diagramExportFileName,
21
+ exportDiagram,
12
22
  nodeRect,
13
23
  pickHandleSides,
14
24
  repickHandleSides,
25
+ toDbml,
26
+ toMermaid,
15
27
  transformDomain
16
28
  };
package/dist/styles.css CHANGED
@@ -693,6 +693,20 @@
693
693
  color: var(--warning-fg);
694
694
  text-align: center;
695
695
  }
696
+ .model-node__load-warning {
697
+ display: flex;
698
+ align-items: center;
699
+ gap: 8px;
700
+ padding: 4px 10px;
701
+ background: var(--warning-bg);
702
+ color: var(--warning-fg);
703
+ border-bottom: 1px solid var(--warning-fg);
704
+ }
705
+ .model-node__load-warning-text {
706
+ flex: 1;
707
+ font-size: 11px;
708
+ line-height: 1.4;
709
+ }
696
710
  .model-node__load-error-text {
697
711
  font-size: 11px;
698
712
  line-height: 1.4;
@@ -818,6 +832,29 @@
818
832
  .fk-edge__label--discrepancy-cardinality-mismatch {
819
833
  color: var(--discrepancy-mismatch);
820
834
  }
835
+ .fk-edge__role {
836
+ position: absolute;
837
+ pointer-events: none;
838
+ padding: 1px 6px;
839
+ border-radius: 8px;
840
+ background: var(--panel-bg);
841
+ font-size: 11px;
842
+ font-weight: 600;
843
+ line-height: 1.4;
844
+ white-space: nowrap;
845
+ }
846
+ .fk-edge__composite {
847
+ position: absolute;
848
+ pointer-events: none;
849
+ padding: 0 5px;
850
+ border: 1px solid currentColor;
851
+ border-radius: 8px;
852
+ background: var(--panel-bg);
853
+ font-size: 10px;
854
+ font-weight: 600;
855
+ line-height: 1.4;
856
+ white-space: nowrap;
857
+ }
821
858
  .fk-edge__mismatch-badge {
822
859
  position: absolute;
823
860
  display: flex;
@@ -24,5 +24,6 @@ export * from './lib/stageColors.js';
24
24
  export * from './lib/dataTypeColors.js';
25
25
  export * from './lib/badgeLabels.js';
26
26
  export { swapCardinality } from './lib/cardinalityUtils.js';
27
+ export { columnPairs, foldComposites, relationshipDisplayKey, relationshipEdgeId } from './lib/relationshipDisplayKey.js';
27
28
  export { computeModelLabels, matchesModelSearch, type LabelledModel } from './lib/modelLabels.js';
28
29
  export { ANNOTATION_COLORS } from './lib/annotationColors.js';
@@ -2,4 +2,5 @@ export { ErdCanvas, type ErdCanvasProps, type ErdCanvasHandle } from './ErdCanva
2
2
  export { transformDomain, pickHandleSides, type TransformResult, type TransformOptions, type NodeRect, type NodeDimensions, } from './lib/graphTransformer.js';
3
3
  export { repickHandleSides, nodeRect } from './lib/dragHandleSides.js';
4
4
  export type { DisplayDomain, DisplayModel, DisplayColumn, DisplayRelationship, PhysicalColumnSource, PhysicalProvenance, Annotation, AnnotationColor, Cardinality, Layer, ModelRole, NodePosition, Rationale, Stage, ViewConfig, LayerConfig, } from '@erd-studio/core';
5
+ export { toMermaid, toDbml, exportDiagram, diagramExportFileName, DIAGRAM_EXPORT_FORMATS, DIAGRAM_EXPORT_FILE_EXTENSIONS, type DiagramExportFormat, } from '@erd-studio/core';
5
6
  export type { ModelFlowNode, ModelNodeData, FkFlowEdge, FkEdgeData, AnnotationFlowNode, AnnotationFlowEdge, ColumnDisplay, } from './types/graph.js';
@@ -52,7 +52,13 @@ export interface EdgeHoverInput {
52
52
  toModel: string;
53
53
  toColumn: string;
54
54
  cardinality: string;
55
+ role?: string;
55
56
  discrepancyStatus?: 'extra' | 'missing' | 'cardinality-mismatch';
57
+ /** A composite foreign key's column pairs (#133 L2). */
58
+ pairs?: ReadonlyArray<{
59
+ fromColumn: string;
60
+ toColumn: string;
61
+ }>;
56
62
  }
57
63
  /**
58
64
  * Hover text for a relationship line: which columns it joins, its cardinality,
@@ -0,0 +1,7 @@
1
+ import type { DbtKeyHint } from '@erd-studio/core';
2
+ /**
3
+ * The line a column's hover text gains for what dbt's tests say about it
4
+ * (#133 L1). It informs a new relationship's direction only, so it is never
5
+ * a badge: the key flags are the user's design decision.
6
+ */
7
+ export declare function dbtKeyTitle(hint: DbtKeyHint): string;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * One line per relationship, a composite foreign key included (#133 L2).
3
+ *
4
+ * The members of a composite (entries sharing a `compositeKey`) are folded
5
+ * into one relationship carrying all its column pairs: edges attach to node
6
+ * sides, not column rows, so N member lines between two nodes would only
7
+ * crowd the side. `fromColumn` / `toColumn` stay the first pair, which is what
8
+ * a delete or ⇄ sends — the host expands it to the group.
9
+ */
10
+ import type { ColumnPair } from '@erd-studio/core';
11
+ type Ends = {
12
+ fromModel: string;
13
+ fromColumn: string;
14
+ toModel: string;
15
+ toColumn: string;
16
+ };
17
+ type WithPairs = Ends & {
18
+ pairs?: readonly ColumnPair[];
19
+ };
20
+ /** The column pairs a (possibly folded) relationship joins: its `pairs`, else its one pair. */
21
+ export declare function columnPairs(rel: WithPairs): ColumnPair[];
22
+ /** `from|a+b|to|c+d`: how an edge and its discrepancy entry find each other. */
23
+ export declare function relationshipDisplayKey(rel: WithPairs): string;
24
+ /** The React Flow edge id graphTransformer gives a relationship. */
25
+ export declare function relationshipEdgeId(rel: WithPairs): string;
26
+ /**
27
+ * Fold the members of each composite foreign key into one relationship with
28
+ * `pairs` (two or more), placed where its first member falls; everything else
29
+ * passes through unchanged. Members are grouped by from-model, to-model and
30
+ * `compositeKey`, without case.
31
+ */
32
+ export declare function foldComposites<T extends Ends & {
33
+ compositeKey?: string;
34
+ }>(rels: readonly T[]): Array<T & {
35
+ pairs?: ColumnPair[];
36
+ }>;
37
+ export {};
@@ -6,7 +6,7 @@
6
6
  * these shapes.
7
7
  */
8
8
  import type { Node, Edge } from '@xyflow/react';
9
- import type { AnnotationColor, Cardinality, Layer, Meta, ModelLoadError, ModelRole, Stage } from '@erd-studio/core';
9
+ import type { AnnotationColor, Cardinality, ColumnPair, DbtKeyHint, Layer, Meta, ModelLoadError, ModelLoadWarning, ModelRole, Stage } from '@erd-studio/core';
10
10
  import type { LayerConfig } from '@erd-studio/core';
11
11
  import type { ModelDiscrepancy } from '@erd-studio/core';
12
12
  import type { PhysicalProvenance } from '@erd-studio/core';
@@ -27,6 +27,8 @@ export interface ColumnDisplay {
27
27
  additiveType?: 'additive' | 'semi-additive' | 'non-additive';
28
28
  /** Structured metadata (`meta:`) — shown in the tooltip. Absent when empty. */
29
29
  meta?: Meta;
30
+ /** What dbt's tests say about the column (editable logical canvas only) — one line of its hover text. */
31
+ dbtKey?: DbtKeyHint;
30
32
  }
31
33
  /** Data payload for a ModelNode React Flow node. */
32
34
  export type ModelNodeData = {
@@ -92,6 +94,11 @@ export type ModelNodeData = {
92
94
  * node shows the error and an "Open file" button instead of "No columns".
93
95
  */
94
96
  loadError?: ModelLoadError;
97
+ /**
98
+ * The model's file was read but needs fixing (logical stage only): a key
99
+ * written twice, whose last value is shown (#164). Absent from an older core.
100
+ */
101
+ loadWarning?: ModelLoadWarning;
95
102
  /** Whether this model is in stub display mode (PK/NK columns only). */
96
103
  isStub: boolean;
97
104
  /** Per-model discrepancy data when a cross-stage comparison report is active. */
@@ -119,6 +126,15 @@ export type FkEdgeData = {
119
126
  toModel: string;
120
127
  toColumn: string;
121
128
  cardinality: Cardinality;
129
+ /** Optional label for the link, e.g. `ship date`, drawn at the edge's midpoint. */
130
+ role?: string;
131
+ /**
132
+ * A composite foreign key's column pairs (two or more; #133 L2). Absent for
133
+ * a single-column link; `fromColumn` / `toColumn` are always the first pair.
134
+ */
135
+ pairs?: ColumnPair[];
136
+ /** The composite's `compositeKey` name, alongside `pairs`. */
137
+ compositeKey?: string;
122
138
  /** Stage of the owning domain canvas — drives CSS colour class. */
123
139
  stage?: Stage;
124
140
  /** Discrepancy status for ghost/extra/mismatch edges. */
@@ -129,6 +145,11 @@ export type FkEdgeData = {
129
145
  readOnly?: boolean;
130
146
  /** True when source === target — renders a loop arc over the top-right corner. */
131
147
  isSelfLoop?: boolean;
148
+ /**
149
+ * For a self-loop: its place among the node's self-loops, by edge id (#133
150
+ * L3). Each one is drawn wider than the last, so loops nest, never cross.
151
+ */
152
+ loopIndex?: number;
132
153
  /** Index signature required by React Flow's Edge generic. */
133
154
  [key: string]: unknown;
134
155
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@erd-studio/renderer",
3
- "version": "0.1.2",
3
+ "version": "0.1.4",
4
4
  "description": "ERD Studio's React Flow canvas: the entity-relationship diagram renderer shared by the VS Code extension, with a read-only viewer mode.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "repository": {
@@ -46,7 +46,7 @@
46
46
  "prepack": "npm run build"
47
47
  },
48
48
  "dependencies": {
49
- "@erd-studio/core": "0.1.2",
49
+ "@erd-studio/core": "0.1.4",
50
50
  "zustand": "^5.0.0"
51
51
  },
52
52
  "peerDependencies": {