@erd-studio/renderer 0.1.2 → 0.1.3

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
  }
@@ -218,20 +254,16 @@ function transformDomain(domain, options) {
218
254
  const relDiscrepancyMap = /* @__PURE__ */ new Map();
219
255
  if (options?.discrepancyReport) {
220
256
  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
- }
257
+ if (rd.status !== "matched") relDiscrepancyMap.set(relationshipDisplayKey(rd), rd.status);
225
258
  }
226
259
  }
227
260
  const allNodeNames = new Set(positionMap.keys());
228
- const edges = relationships.filter((rel) => allNodeNames.has(rel.fromModel) && allNodeNames.has(rel.toModel)).map((rel) => {
261
+ const edges = foldComposites(relationships).filter((rel) => allNodeNames.has(rel.fromModel) && allNodeNames.has(rel.toModel)).map((rel) => {
229
262
  const isSelfLoop = rel.fromModel === rel.toModel;
230
263
  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);
264
+ const discStatus = relDiscrepancyMap.get(relationshipDisplayKey(rel));
233
265
  return {
234
- id: `fk-${rel.fromModel}-${rel.fromColumn}-${rel.toModel}-${rel.toColumn}`,
266
+ id: relationshipEdgeId(rel),
235
267
  type: "fk",
236
268
  source: rel.fromModel,
237
269
  target: rel.toModel,
@@ -243,6 +275,8 @@ function transformDomain(domain, options) {
243
275
  toModel: rel.toModel,
244
276
  toColumn: rel.toColumn,
245
277
  cardinality: rel.cardinality,
278
+ ...rel.role ? { role: rel.role } : {},
279
+ ...rel.pairs ? { pairs: rel.pairs, compositeKey: rel.compositeKey } : {},
246
280
  stage,
247
281
  ...readOnly ? { readOnly: true } : {},
248
282
  ...discStatus ? { discrepancyStatus: discStatus } : {},
@@ -256,8 +290,10 @@ function transformDomain(domain, options) {
256
290
  if (!positionMap.has(rd.fromModel) || !positionMap.has(rd.toModel)) continue;
257
291
  const isSelfLoop = rd.fromModel === rd.toModel;
258
292
  const { sourceSide, targetSide } = isSelfLoop ? { sourceSide: "top", targetSide: "right" } : pickHandleSides(rectOf(rd.fromModel), rectOf(rd.toModel));
293
+ const pairs = columnPairs(rd);
294
+ const composite = pairs.length > 1 ? { pairs, ...rd.compositeKey ? { compositeKey: rd.compositeKey } : {} } : {};
259
295
  edges.push({
260
- id: `ghost-fk-${rd.fromModel}-${rd.fromColumn}-${rd.toModel}-${rd.toColumn}`,
296
+ id: `ghost-${relationshipEdgeId({ ...rd, pairs })}`,
261
297
  type: "fk",
262
298
  source: rd.fromModel,
263
299
  target: rd.toModel,
@@ -269,6 +305,7 @@ function transformDomain(domain, options) {
269
305
  toModel: rd.toModel,
270
306
  toColumn: rd.toColumn,
271
307
  cardinality: rd.sourceCardinality ?? rd.targetCardinality ?? "many-to-one",
308
+ ...composite,
272
309
  stage,
273
310
  discrepancyStatus: "missing",
274
311
  ...isSelfLoop ? { isSelfLoop: true } : {}
@@ -277,6 +314,16 @@ function transformDomain(domain, options) {
277
314
  }
278
315
  }
279
316
  }
317
+ const loopsByNode = /* @__PURE__ */ new Map();
318
+ for (const edge of edges) {
319
+ if (edge.type !== "fk" || !edge.data?.isSelfLoop) continue;
320
+ loopsByNode.set(edge.source, [...loopsByNode.get(edge.source) ?? [], edge]);
321
+ }
322
+ for (const loops of loopsByNode.values()) {
323
+ [...loops].sort((a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0).forEach((edge, loopIndex) => {
324
+ edge.data = { ...edge.data, loopIndex };
325
+ });
326
+ }
280
327
  const annotations = domain.viewConfig.annotations ?? [];
281
328
  for (const ann of annotations) {
282
329
  const annNodeId = `annotation-${ann.id}`;
@@ -499,8 +546,10 @@ function useCanvasGraph({
499
546
  if (edge.type !== "fk") return;
500
547
  if (edge.data) {
501
548
  const cols = /* @__PURE__ */ new Set();
502
- cols.add(`${edge.data.fromModel}:${edge.data.fromColumn}`);
503
- cols.add(`${edge.data.toModel}:${edge.data.toColumn}`);
549
+ for (const pair of columnPairs(edge.data)) {
550
+ cols.add(`${edge.data.fromModel}:${pair.fromColumn}`);
551
+ cols.add(`${edge.data.toModel}:${pair.toColumn}`);
552
+ }
504
553
  setHighlightedColumns(cols);
505
554
  selectNode(null);
506
555
  setDetailPanelOpen(false);
@@ -662,6 +711,19 @@ function useLongPressDrag(options = {}) {
662
711
  };
663
712
  }
664
713
 
714
+ // src/lib/dbtKeyTitle.ts
715
+ function dbtKeyTitle(hint) {
716
+ switch (hint.because) {
717
+ case "unique-test":
718
+ case "unique-combination":
719
+ return "dbt: tested as unique";
720
+ case "part-of-unique-combination":
721
+ return "dbt: part of a unique combination";
722
+ case "relationships-test":
723
+ return "dbt: has a relationships test";
724
+ }
725
+ }
726
+
665
727
  // src/hooks/useColumnReorder.ts
666
728
  import { useCallback as useCallback4, useEffect as useEffect3, useRef as useRef3, useState as useState2 } from "react";
667
729
  function useColumnReorder({
@@ -1249,9 +1311,12 @@ var DISCREPANCY_PHRASE = {
1249
1311
  "cardinality-mismatch": "cardinality differs between stages"
1250
1312
  };
1251
1313
  function edgeHoverText(edge) {
1314
+ const composite = edge.pairs && edge.pairs.length > 1;
1315
+ 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
1316
  return [
1253
- `${edge.fromModel}.${edge.fromColumn} \u2192 ${edge.toModel}.${edge.toColumn}`,
1317
+ ends,
1254
1318
  edge.cardinality.replace(/-/g, " "),
1319
+ edge.role ? `role: ${edge.role}` : "",
1255
1320
  edge.discrepancyStatus ? DISCREPANCY_PHRASE[edge.discrepancyStatus] : ""
1256
1321
  ].filter(Boolean).join(" \xB7 ");
1257
1322
  }
@@ -1454,21 +1519,21 @@ function ColumnRow({ column, modelName, readOnly, existingColumnNames, discrepan
1454
1519
  if (columnRow && modelNode) {
1455
1520
  const targetColumnName = columnRow.dataset.columnName;
1456
1521
  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
- );
1522
+ if (targetColumnName && targetModelName) {
1523
+ if (targetModelName === modelName && targetColumnName.toLowerCase() === column.name.toLowerCase()) {
1524
+ window.dispatchEvent(new CustomEvent("column-relationship-self-drop"));
1525
+ } else {
1526
+ window.dispatchEvent(
1527
+ new CustomEvent("column-relationship-drop", {
1528
+ detail: {
1529
+ fromModel: modelName,
1530
+ fromColumn: column.name,
1531
+ toModel: targetModelName,
1532
+ toColumn: targetColumnName
1533
+ }
1534
+ })
1535
+ );
1536
+ }
1472
1537
  }
1473
1538
  }
1474
1539
  endDrag();
@@ -1567,7 +1632,8 @@ function ColumnRow({ column, modelName, readOnly, existingColumnNames, discrepan
1567
1632
  "span",
1568
1633
  {
1569
1634
  className: `model-node__col-name${!readOnly ? " model-node__col-name--editable" : ""}`,
1570
- title: column.name,
1635
+ title: !viewer && column.dbtKey ? `${column.name}
1636
+ ${dbtKeyTitle(column.dbtKey)}` : column.name,
1571
1637
  onDoubleClick: !readOnly ? handleDoubleClickName : void 0,
1572
1638
  children: column.name
1573
1639
  }
@@ -1672,6 +1738,9 @@ function describeLoadError(error) {
1672
1738
  if (error.kind === "read") {
1673
1739
  return "ERD Studio can't read this file.";
1674
1740
  }
1741
+ if (error.mergeConflict) {
1742
+ 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.";
1743
+ }
1675
1744
  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
1745
  }
1677
1746
  function ModelNodeComponent({ data, selected }) {
@@ -2017,6 +2086,7 @@ import { Fragment as Fragment3, jsx as jsx8, jsxs as jsxs6 } from "react/jsx-run
2017
2086
  var PATH_GAP = 20;
2018
2087
  var LABEL_OFFSET = 8;
2019
2088
  var SELF_LOOP_RADIUS = 55;
2089
+ var SELF_LOOP_STEP = 18;
2020
2090
  var NO_SIBLINGS = Object.freeze([]);
2021
2091
  function offsetPoint(x, y, position, distance) {
2022
2092
  switch (position) {
@@ -2089,7 +2159,8 @@ function FkEdgeComponent({
2089
2159
  [host, data]
2090
2160
  );
2091
2161
  if (!data) return null;
2092
- const { cardinality, stage, discrepancyStatus, dimmed, readOnly, isSelfLoop, fromColumn, toColumn } = data;
2162
+ const { cardinality, role, stage, discrepancyStatus, dimmed, readOnly, isSelfLoop, fromColumn, toColumn } = data;
2163
+ const loopRadius = SELF_LOOP_RADIUS + SELF_LOOP_STEP * (data.loopIndex ?? 0);
2093
2164
  let adjustedSourceX = sourceX;
2094
2165
  let adjustedSourceY = sourceY;
2095
2166
  let adjustedTargetX = targetX;
@@ -2122,7 +2193,7 @@ function FkEdgeComponent({
2122
2193
  }
2123
2194
  const src = offsetPoint(adjustedSourceX, adjustedSourceY, sourcePosition, PATH_GAP);
2124
2195
  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({
2196
+ 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
2197
  sourceX: src.x,
2127
2198
  sourceY: src.y,
2128
2199
  targetX: tgt.x,
@@ -2138,8 +2209,11 @@ function FkEdgeComponent({
2138
2209
  toModel,
2139
2210
  toColumn,
2140
2211
  cardinality,
2141
- discrepancyStatus
2212
+ role,
2213
+ discrepancyStatus,
2214
+ pairs: data.pairs
2142
2215
  });
2216
+ const compositeSize = data.pairs && data.pairs.length > 1 ? data.pairs.length : 0;
2143
2217
  let sourceLabel;
2144
2218
  let targetLabel;
2145
2219
  switch (cardinality) {
@@ -2168,8 +2242,8 @@ function FkEdgeComponent({
2168
2242
  const labelColorClass = discrepancyStatus ? `fk-edge__label--discrepancy-${discrepancyStatus}` : `fk-edge__label--${stage ?? "logical"}`;
2169
2243
  const srcLabel = offsetPoint(adjustedSourceX, adjustedSourceY, sourcePosition, LABEL_OFFSET);
2170
2244
  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;
2245
+ const midX = isSelfLoop ? (src.x + tgt.x) / 2 + loopRadius * 0.75 : (adjustedSourceX + adjustedTargetX) / 2;
2246
+ const midY = isSelfLoop ? (src.y + tgt.y) / 2 - loopRadius * 0.75 : (adjustedSourceY + adjustedTargetY) / 2;
2173
2247
  return /* @__PURE__ */ jsxs6(Fragment3, { children: [
2174
2248
  /* @__PURE__ */ jsx8(
2175
2249
  "path",
@@ -2211,6 +2285,30 @@ function FkEdgeComponent({
2211
2285
  children: targetLabel
2212
2286
  }
2213
2287
  ),
2288
+ role && /* @__PURE__ */ jsx8(
2289
+ "span",
2290
+ {
2291
+ className: `fk-edge__role ${labelColorClass}${dimmed ? " fk-edge__label--dimmed" : ""}`,
2292
+ style: {
2293
+ transform: `translate(-50%, calc(-100% - 10px)) translate(${midX}px, ${midY}px)`
2294
+ },
2295
+ children: role
2296
+ }
2297
+ ),
2298
+ compositeSize > 0 && /* @__PURE__ */ jsxs6(
2299
+ "span",
2300
+ {
2301
+ className: `fk-edge__composite ${labelColorClass}${dimmed ? " fk-edge__label--dimmed" : ""}`,
2302
+ style: {
2303
+ transform: `translate(-50%, calc(100% + 6px)) translate(${midX}px, ${midY}px)`
2304
+ },
2305
+ title: `Composite foreign key: ${compositeSize} column pairs`,
2306
+ children: [
2307
+ "\u29C9 ",
2308
+ compositeSize
2309
+ ]
2310
+ }
2311
+ ),
2214
2312
  discrepancyStatus === "cardinality-mismatch" && /* @__PURE__ */ jsx8(
2215
2313
  "span",
2216
2314
  {
@@ -4538,6 +4636,10 @@ function ColumnEditor({ modelName, columns, readOnly, modelRole }) {
4538
4636
 
4539
4637
  // src/components/DetailPanel/DetailPanel.tsx
4540
4638
  import { Fragment as Fragment4, jsx as jsx22, jsxs as jsxs19 } from "react/jsx-runtime";
4639
+ function columnsText(rel, end) {
4640
+ const cols = columnPairs(rel).map((p) => end === "from" ? p.fromColumn : p.toColumn);
4641
+ return cols.length === 1 ? cols[0] : `(${cols.join(", ")})`;
4642
+ }
4541
4643
  var SOURCE_NAME = {
4542
4644
  catalog: "warehouse catalog",
4543
4645
  yml: "your dbt .yml",
@@ -4659,6 +4761,7 @@ function DetailPanel() {
4659
4761
  handleClose();
4660
4762
  }, [selectedNode, host, handleClose]);
4661
4763
  const handleDeleteRelationship = useCallback22(
4764
+ // A composite sends its first pair; the host removes the whole group (#133 L2).
4662
4765
  (rel) => {
4663
4766
  host.postMessage({
4664
4767
  type: "removeRelationship",
@@ -4679,7 +4782,10 @@ function DetailPanel() {
4679
4782
  fromColumn: rel.fromColumn,
4680
4783
  toModel: rel.toModel,
4681
4784
  toColumn: rel.toColumn,
4682
- cardinality: rel.cardinality
4785
+ cardinality: rel.cardinality,
4786
+ // Carried so Edit opens with it — the dialog saves what it shows.
4787
+ ...rel.role ? { role: rel.role } : {},
4788
+ ...rel.pairs ? { pairs: rel.pairs, ...rel.compositeKey ? { compositeKey: rel.compositeKey } : {} } : {}
4683
4789
  };
4684
4790
  openEdgeContextMenu(x, y, edgeData);
4685
4791
  },
@@ -4693,8 +4799,9 @@ function DetailPanel() {
4693
4799
  if (!domain || !selectedNode) {
4694
4800
  return { outgoing: [], incoming: [] };
4695
4801
  }
4696
- const outgoing2 = domain.relationships.filter((r) => r.fromModel === selectedNode);
4697
- const incoming2 = domain.relationships.filter((r) => r.toModel === selectedNode);
4802
+ const rels = foldComposites(domain.relationships);
4803
+ const outgoing2 = rels.filter((r) => r.fromModel === selectedNode);
4804
+ const incoming2 = rels.filter((r) => r.toModel === selectedNode && r.fromModel !== selectedNode);
4698
4805
  return { outgoing: outgoing2, incoming: incoming2 };
4699
4806
  }, [domain, selectedNode]);
4700
4807
  const isReadOnly = domain?.readOnly ?? false;
@@ -4862,14 +4969,14 @@ function DetailPanel() {
4862
4969
  },
4863
4970
  title: viewer ? void 0 : "Click to edit cardinality",
4864
4971
  children: [
4865
- /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-direction", title: "Outgoing FK", children: "\u2192" }),
4972
+ /* @__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
4973
  /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-columns", children: [
4867
- /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-local", title: rel.fromColumn, children: rel.fromColumn }),
4974
+ /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-local", title: columnsText(rel, "from"), children: columnsText(rel, "from") }),
4868
4975
  /* @__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: [
4976
+ /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-target", title: `${rel.toModel}.${columnsText(rel, "to")}`, children: [
4870
4977
  rel.toModel,
4871
4978
  ".",
4872
- rel.toColumn
4979
+ columnsText(rel, "to")
4873
4980
  ] })
4874
4981
  ] }),
4875
4982
  /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-cardinality", children: rel.cardinality }),
@@ -4888,7 +4995,7 @@ function DetailPanel() {
4888
4995
  )
4889
4996
  ]
4890
4997
  },
4891
- `out-${rel.fromColumn}-${rel.toModel}-${rel.toColumn}`
4998
+ `out-${relationshipEdgeId(rel)}`
4892
4999
  )),
4893
5000
  incoming.map((rel) => /* @__PURE__ */ jsxs19(
4894
5001
  "div",
@@ -4908,13 +5015,13 @@ function DetailPanel() {
4908
5015
  children: [
4909
5016
  /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-direction", title: "Incoming FK", children: "\u2190" }),
4910
5017
  /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-columns", children: [
4911
- /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-target", title: `${rel.fromModel}.${rel.fromColumn}`, children: [
5018
+ /* @__PURE__ */ jsxs19("span", { className: "detail-panel__rel-target", title: `${rel.fromModel}.${columnsText(rel, "from")}`, children: [
4912
5019
  rel.fromModel,
4913
5020
  ".",
4914
- rel.fromColumn
5021
+ columnsText(rel, "from")
4915
5022
  ] }),
4916
5023
  /* @__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 })
5024
+ /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-local", title: columnsText(rel, "to"), children: columnsText(rel, "to") })
4918
5025
  ] }),
4919
5026
  /* @__PURE__ */ jsx22("span", { className: "detail-panel__rel-cardinality", children: rel.cardinality }),
4920
5027
  !isReadOnly && /* @__PURE__ */ jsx22(
@@ -4932,7 +5039,7 @@ function DetailPanel() {
4932
5039
  )
4933
5040
  ]
4934
5041
  },
4935
- `in-${rel.fromModel}-${rel.fromColumn}-${rel.toColumn}`
5042
+ `in-${relationshipEdgeId(rel)}`
4936
5043
  ))
4937
5044
  ] })
4938
5045
  ] })
@@ -5379,12 +5486,26 @@ var ErdCanvas = forwardRef(function ErdCanvas2(props, ref) {
5379
5486
  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
5487
  });
5381
5488
 
5489
+ // src/index.ts
5490
+ import {
5491
+ toMermaid,
5492
+ toDbml,
5493
+ exportDiagram,
5494
+ diagramExportFileName,
5495
+ DIAGRAM_EXPORT_FORMATS,
5496
+ DIAGRAM_EXPORT_FILE_EXTENSIONS
5497
+ } from "@erd-studio/core";
5498
+
5382
5499
  export {
5383
5500
  NOOP_CANVAS_HOST,
5384
5501
  CanvasEnvironmentProvider,
5385
5502
  useCanvasHost,
5386
5503
  useSend,
5387
5504
  useIsViewer,
5505
+ columnPairs,
5506
+ relationshipDisplayKey,
5507
+ relationshipEdgeId,
5508
+ foldComposites,
5388
5509
  computeModelLabels,
5389
5510
  matchesModelSearch,
5390
5511
  pickHandleSides,
@@ -5425,5 +5546,11 @@ export {
5425
5546
  ColumnRowEditor,
5426
5547
  DetailPanel,
5427
5548
  Legend,
5428
- ErdCanvas
5549
+ ErdCanvas,
5550
+ toMermaid,
5551
+ toDbml,
5552
+ exportDiagram,
5553
+ diagramExportFileName,
5554
+ DIAGRAM_EXPORT_FORMATS,
5555
+ DIAGRAM_EXPORT_FILE_EXTENSIONS
5429
5556
  };
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-H2ACJRPZ.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-H2ACJRPZ.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
@@ -818,6 +818,29 @@
818
818
  .fk-edge__label--discrepancy-cardinality-mismatch {
819
819
  color: var(--discrepancy-mismatch);
820
820
  }
821
+ .fk-edge__role {
822
+ position: absolute;
823
+ pointer-events: none;
824
+ padding: 1px 6px;
825
+ border-radius: 8px;
826
+ background: var(--panel-bg);
827
+ font-size: 11px;
828
+ font-weight: 600;
829
+ line-height: 1.4;
830
+ white-space: nowrap;
831
+ }
832
+ .fk-edge__composite {
833
+ position: absolute;
834
+ pointer-events: none;
835
+ padding: 0 5px;
836
+ border: 1px solid currentColor;
837
+ border-radius: 8px;
838
+ background: var(--panel-bg);
839
+ font-size: 10px;
840
+ font-weight: 600;
841
+ line-height: 1.4;
842
+ white-space: nowrap;
843
+ }
821
844
  .fk-edge__mismatch-badge {
822
845
  position: absolute;
823
846
  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, 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 = {
@@ -119,6 +121,15 @@ export type FkEdgeData = {
119
121
  toModel: string;
120
122
  toColumn: string;
121
123
  cardinality: Cardinality;
124
+ /** Optional label for the link, e.g. `ship date`, drawn at the edge's midpoint. */
125
+ role?: string;
126
+ /**
127
+ * A composite foreign key's column pairs (two or more; #133 L2). Absent for
128
+ * a single-column link; `fromColumn` / `toColumn` are always the first pair.
129
+ */
130
+ pairs?: ColumnPair[];
131
+ /** The composite's `compositeKey` name, alongside `pairs`. */
132
+ compositeKey?: string;
122
133
  /** Stage of the owning domain canvas — drives CSS colour class. */
123
134
  stage?: Stage;
124
135
  /** Discrepancy status for ghost/extra/mismatch edges. */
@@ -129,6 +140,11 @@ export type FkEdgeData = {
129
140
  readOnly?: boolean;
130
141
  /** True when source === target — renders a loop arc over the top-right corner. */
131
142
  isSelfLoop?: boolean;
143
+ /**
144
+ * For a self-loop: its place among the node's self-loops, by edge id (#133
145
+ * L3). Each one is drawn wider than the last, so loops nest, never cross.
146
+ */
147
+ loopIndex?: number;
132
148
  /** Index signature required by React Flow's Edge generic. */
133
149
  [key: string]: unknown;
134
150
  };
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.3",
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.3",
50
50
  "zustand": "^5.0.0"
51
51
  },
52
52
  "peerDependencies": {