@zentto/report-designer 1.6.5 → 1.6.7

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.
@@ -1,5 +1,5 @@
1
1
  // @zentto/report-designer — Visual ER Diagram
2
- // Interactive SVG canvas showing tables as boxes with relation lines
2
+ // Interactive SVG canvas: tables, drag-to-link fields, relation arrows with cardinality
3
3
  var __decorate = (this && this.__decorate) || function (decorators, target, key, desc) {
4
4
  var c = arguments.length, r = c < 3 ? target : desc === null ? desc = Object.getOwnPropertyDescriptor(target, key) : desc, d;
5
5
  if (typeof Reflect === "object" && typeof Reflect.decorate === "function") r = Reflect.decorate(decorators, target, key, desc);
@@ -8,20 +8,24 @@ var __decorate = (this && this.__decorate) || function (decorators, target, key,
8
8
  };
9
9
  import { LitElement, html, css, svg, nothing } from 'lit';
10
10
  import { customElement, property, state } from 'lit/decorators.js';
11
- // ─── Layout Constants ─────────────────────────────────────────────
12
- const TABLE_WIDTH = 180;
13
- const TABLE_HEADER_HEIGHT = 28;
14
- const FIELD_ROW_HEIGHT = 20;
15
- const TABLE_PADDING = 12;
11
+ // ─── Constants ────────────────────────────────────────────────────
12
+ const TW = 190; // table width
13
+ const TH_HEADER = 30; // header height
14
+ const TH_FIELD = 18; // field row height
15
+ const TH_PAD = 8; // bottom padding
16
16
  const GRID_COLS = 3;
17
- const GRID_GAP_X = 220;
18
- const GRID_GAP_Y = 40;
17
+ const GAP_X = 230;
18
+ const GAP_Y = 40;
19
19
  const JOIN_COLORS = {
20
- inner: '#1976d2',
21
- left: '#2e7d32',
22
- right: '#e65100',
23
- full: '#7b1fa2',
20
+ inner: '#1976d2', left: '#2e7d32', right: '#e65100', full: '#7b1fa2',
24
21
  };
22
+ const CARDINALITY = {
23
+ inner: { start: '1', end: '1' },
24
+ left: { start: '1', end: '*' },
25
+ right: { start: '*', end: '1' },
26
+ full: { start: '*', end: '*' },
27
+ };
28
+ // ─── Component ────────────────────────────────────────────────────
25
29
  let ErDiagram = class ErDiagram extends LitElement {
26
30
  constructor() {
27
31
  super(...arguments);
@@ -29,145 +33,102 @@ let ErDiagram = class ErDiagram extends LitElement {
29
33
  this.relations = [];
30
34
  this._tables = [];
31
35
  this._dragging = null;
32
- this._hoveredRelation = null;
33
- this._svgWidth = 800;
34
- this._svgHeight = 600;
36
+ this._linkDrag = null;
37
+ this._hoveredRel = null;
38
+ this._selectedRel = null;
39
+ this._svgW = 800;
40
+ this._svgH = 600;
41
+ this._tool = 'select';
35
42
  }
36
43
  static { this.styles = css `
37
- :host {
38
- display: block;
39
- height: 100%;
40
- overflow: hidden;
41
- font-family: 'Segoe UI', Roboto, Arial, sans-serif;
42
- }
43
-
44
- .er-container {
45
- width: 100%;
46
- height: 100%;
47
- overflow: auto;
48
- background:
49
- linear-gradient(90deg, var(--zrd-grid-line, #f0f0f0) 1px, transparent 1px),
50
- linear-gradient(var(--zrd-grid-line, #f0f0f0) 1px, transparent 1px);
51
- background-size: 20px 20px;
52
- cursor: default;
53
- }
44
+ :host { display: flex; flex-direction: column; height: 100%; overflow: hidden; font-family: 'Segoe UI', Roboto, Arial, sans-serif; }
54
45
 
55
- svg {
56
- display: block;
46
+ /* ─── Toolbar ─────────────────────────────── */
47
+ .er-toolbar {
48
+ display: flex; align-items: center; gap: 4px;
49
+ padding: 6px 10px; background: #f5f5f5;
50
+ border-bottom: 1px solid #ddd; flex-shrink: 0;
57
51
  }
58
-
59
- /* ─── Table Box ───────────────────────────── */
60
- .table-box {
61
- cursor: move;
62
- }
63
- .table-box:hover .table-border {
64
- stroke: var(--zrd-primary, #1976d2);
65
- stroke-width: 2;
52
+ .tb-btn {
53
+ padding: 4px 10px; border: 1px solid #ccc; border-radius: 4px;
54
+ background: #fff; cursor: pointer; font-size: 11px; font-weight: 500;
55
+ display: flex; align-items: center; gap: 4px;
66
56
  }
57
+ .tb-btn:hover { background: #e3f2fd; border-color: #1976d2; }
58
+ .tb-btn.active { background: #1976d2; color: #fff; border-color: #1976d2; }
59
+ .tb-btn.danger { color: #c62828; }
60
+ .tb-btn.danger:hover { background: #ffebee; }
61
+ .tb-sep { width: 1px; height: 20px; background: #ddd; margin: 0 4px; }
62
+ .tb-spacer { flex: 1; }
63
+ .tb-info { font-size: 10px; color: #999; }
67
64
 
68
- .table-border {
69
- fill: var(--zrd-panel-bg, #fff);
70
- stroke: var(--zrd-border, #ccc);
71
- stroke-width: 1;
72
- rx: 6;
73
- ry: 6;
74
- filter: drop-shadow(0 2px 4px rgba(0,0,0,0.08));
75
- }
76
-
77
- .table-header-bg {
78
- rx: 6;
79
- ry: 6;
80
- }
81
- .table-header-mask {
82
- fill: var(--zrd-panel-bg, #fff);
65
+ /* ─── Canvas ──────────────────────────────── */
66
+ .er-canvas {
67
+ flex: 1; overflow: auto; cursor: default;
68
+ background:
69
+ linear-gradient(90deg, #f5f5f5 1px, transparent 1px),
70
+ linear-gradient(#f5f5f5 1px, transparent 1px);
71
+ background-size: 20px 20px; background-color: #fafafa;
83
72
  }
73
+ .er-canvas.link-mode { cursor: crosshair; }
74
+ svg { display: block; }
84
75
 
85
- .table-name {
86
- font-size: 11px;
87
- font-weight: 600;
88
- fill: #fff;
76
+ /* ─── Table ───────────────────────────────── */
77
+ .tbl { cursor: move; }
78
+ .tbl:hover .tbl-border { stroke: #1976d2; stroke-width: 2; }
79
+ .tbl-border {
80
+ fill: #fff; stroke: #ccc; stroke-width: 1; rx: 6; ry: 6;
81
+ filter: drop-shadow(0 2px 6px rgba(0,0,0,0.1));
89
82
  }
83
+ .tbl-hdr { rx: 6; ry: 6; }
84
+ .tbl-hdr-mask { fill: #fff; }
85
+ .tbl-name { font-size: 11px; font-weight: 600; fill: #fff; }
86
+ .tbl-count { font-size: 9px; fill: rgba(255,255,255,0.7); }
90
87
 
91
- .field-name {
92
- font-size: 10px;
93
- fill: var(--zrd-text, #333);
94
- }
95
- .field-type {
96
- font-size: 9px;
97
- fill: var(--zrd-text-muted, #999);
98
- }
99
- .field-pk {
100
- font-size: 9px;
101
- fill: #ff9800;
102
- }
88
+ .fld-row { cursor: default; }
89
+ .fld-row:hover rect { fill: rgba(25,118,210,0.06); }
90
+ .fld-name { font-size: 10px; fill: #333; }
91
+ .fld-type { font-size: 8.5px; fill: #999; }
92
+ .fld-pk { font-size: 9px; fill: #ff9800; }
93
+ .fld-sep { stroke: #eee; stroke-width: 0.5; }
103
94
 
104
- .field-row:hover {
105
- opacity: 0.8;
106
- }
107
-
108
- .field-separator {
109
- stroke: var(--zrd-border, #eee);
110
- stroke-width: 0.5;
95
+ /* Link handle (dot on field) */
96
+ .fld-link-dot {
97
+ fill: #1976d2; opacity: 0; cursor: crosshair; transition: opacity 0.15s;
111
98
  }
99
+ .tbl:hover .fld-link-dot, .er-canvas.link-mode .fld-link-dot { opacity: 0.6; }
100
+ .fld-link-dot:hover { opacity: 1 !important; r: 5; }
112
101
 
113
102
  /* ─── Relation Lines ──────────────────────── */
114
- .relation-line {
115
- fill: none;
116
- stroke-width: 1.5;
117
- cursor: pointer;
118
- transition: stroke-width 0.15s;
119
- }
120
- .relation-line:hover,
121
- .relation-line.hovered {
122
- stroke-width: 3;
123
- }
103
+ .rel-path { fill: none; stroke-width: 1.8; transition: stroke-width 0.15s; }
104
+ .rel-path.hovered, .rel-path.selected { stroke-width: 3; }
105
+ .rel-hit { fill: none; stroke: transparent; stroke-width: 14; cursor: pointer; }
106
+ .rel-label { font-size: 9px; font-weight: 600; pointer-events: none; }
107
+ .rel-label-bg { rx: 3; ry: 3; }
108
+ .rel-card { font-size: 10px; font-weight: 700; pointer-events: none; }
124
109
 
125
- .relation-label {
126
- font-size: 9px;
127
- font-weight: 600;
128
- pointer-events: none;
129
- }
130
- .relation-label-bg {
131
- fill: var(--zrd-panel-bg, #fff);
132
- rx: 3;
133
- ry: 3;
134
- }
110
+ /* Link drag preview line */
111
+ .link-preview { stroke: #1976d2; stroke-width: 2; stroke-dasharray: 6 3; fill: none; }
135
112
 
136
- /* ─── Empty State ─────────────────────────── */
137
- .empty-state {
138
- display: flex;
139
- align-items: center;
140
- justify-content: center;
141
- height: 100%;
142
- color: var(--zrd-text-muted, #999);
143
- font-size: 12px;
144
- text-align: center;
145
- padding: 20px;
146
- }
113
+ .empty { display: flex; align-items: center; justify-content: center; height: 100%; color: #999; font-size: 12px; text-align: center; padding: 20px; }
147
114
  `; }
148
115
  // ─── Lifecycle ────────────────────────────────────────────────
149
116
  updated(changed) {
150
- if (changed.has('dataSources')) {
117
+ if (changed.has('dataSources'))
151
118
  this._layoutTables();
152
- }
153
119
  }
154
- // ─── Auto Layout ──────────────────────────────────────────────
155
120
  _layoutTables() {
156
121
  const tables = [];
157
- let col = 0;
158
- let row = 0;
122
+ let col = 0, row = 0;
159
123
  for (const ds of this.dataSources) {
160
- const fieldCount = ds.fields?.length || 0;
161
- const height = TABLE_HEADER_HEIGHT + fieldCount * FIELD_ROW_HEIGHT + TABLE_PADDING;
162
- // Check if existing position is stored (preserve user moves)
124
+ const fc = ds.fields?.length || 0;
125
+ const h = TH_HEADER + fc * TH_FIELD + TH_PAD;
163
126
  const existing = this._tables.find(t => t.dsId === ds.id);
164
127
  tables.push({
165
128
  dsId: ds.id,
166
- x: existing?.x ?? (TABLE_PADDING + col * GRID_GAP_X),
167
- y: existing?.y ?? (TABLE_PADDING + row * (Math.max(height, 120) + GRID_GAP_Y)),
168
- width: TABLE_WIDTH,
169
- height,
170
- ds,
129
+ x: existing?.x ?? (12 + col * GAP_X),
130
+ y: existing?.y ?? (12 + row * (Math.max(h, 100) + GAP_Y)),
131
+ width: TW, height: h, ds,
171
132
  });
172
133
  col++;
173
134
  if (col >= GRID_COLS) {
@@ -176,224 +137,284 @@ let ErDiagram = class ErDiagram extends LitElement {
176
137
  }
177
138
  }
178
139
  this._tables = tables;
179
- this._updateSvgSize();
140
+ this._updateSize();
180
141
  }
181
- _updateSvgSize() {
182
- let maxX = 400;
183
- let maxY = 300;
142
+ _updateSize() {
143
+ let mx = 600, my = 400;
184
144
  for (const t of this._tables) {
185
- maxX = Math.max(maxX, t.x + t.width + 40);
186
- maxY = Math.max(maxY, t.y + t.height + 40);
145
+ mx = Math.max(mx, t.x + t.width + 60);
146
+ my = Math.max(my, t.y + t.height + 60);
187
147
  }
188
- this._svgWidth = maxX;
189
- this._svgHeight = maxY;
148
+ this._svgW = mx;
149
+ this._svgH = my;
190
150
  }
191
- // ─── Drag Handling ────────────────────────────────────────────
192
- _onMouseDown(e, dsId) {
193
- const table = this._tables.find(t => t.dsId === dsId);
194
- if (!table)
151
+ // ─── Table Drag ───────────────────────────────────────────────
152
+ _onTableDown(e, dsId) {
153
+ if (this._tool === 'link')
154
+ return; // in link mode, don't drag tables
155
+ const t = this._tables.find(t => t.dsId === dsId);
156
+ if (!t)
195
157
  return;
196
158
  e.preventDefault();
197
- const svgEl = this.renderRoot.querySelector('svg');
198
- if (!svgEl)
199
- return;
200
- const rect = svgEl.getBoundingClientRect();
201
- this._dragging = {
202
- dsId,
203
- offsetX: e.clientX - rect.left - table.x,
204
- offsetY: e.clientY - rect.top - table.y,
205
- };
159
+ e.stopPropagation();
160
+ const svgRect = this.renderRoot.querySelector('svg').getBoundingClientRect();
161
+ this._dragging = { dsId, ox: e.clientX - svgRect.left - t.x, oy: e.clientY - svgRect.top - t.y };
206
162
  const onMove = (ev) => {
207
163
  if (!this._dragging)
208
164
  return;
209
- const newX = Math.max(0, ev.clientX - rect.left - this._dragging.offsetX);
210
- const newY = Math.max(0, ev.clientY - rect.top - this._dragging.offsetY);
211
- this._tables = this._tables.map(t => t.dsId === this._dragging.dsId ? { ...t, x: newX, y: newY } : t);
212
- this._updateSvgSize();
165
+ const nx = Math.max(0, ev.clientX - svgRect.left - this._dragging.ox);
166
+ const ny = Math.max(0, ev.clientY - svgRect.top - this._dragging.oy);
167
+ this._tables = this._tables.map(tb => tb.dsId === this._dragging.dsId ? { ...tb, x: nx, y: ny } : tb);
168
+ this._updateSize();
169
+ };
170
+ const onUp = () => { this._dragging = null; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); };
171
+ document.addEventListener('mousemove', onMove);
172
+ document.addEventListener('mouseup', onUp);
173
+ }
174
+ // ─── Link Drag (create relation by dragging field to field) ──
175
+ _onFieldLinkStart(e, dsId, fieldName) {
176
+ e.preventDefault();
177
+ e.stopPropagation();
178
+ const svgRect = this.renderRoot.querySelector('svg').getBoundingClientRect();
179
+ const x = e.clientX - svgRect.left;
180
+ const y = e.clientY - svgRect.top;
181
+ this._linkDrag = { fromDsId: dsId, fromField: fieldName, fromX: x, fromY: y, currentX: x, currentY: y };
182
+ const onMove = (ev) => {
183
+ if (!this._linkDrag)
184
+ return;
185
+ this._linkDrag = { ...this._linkDrag, currentX: ev.clientX - svgRect.left, currentY: ev.clientY - svgRect.top };
213
186
  };
214
- const onUp = () => {
215
- this._dragging = null;
187
+ const onUp = (ev) => {
216
188
  document.removeEventListener('mousemove', onMove);
217
189
  document.removeEventListener('mouseup', onUp);
190
+ if (!this._linkDrag)
191
+ return;
192
+ // Find drop target field
193
+ const target = this._findFieldAt(ev.clientX - svgRect.left, ev.clientY - svgRect.top);
194
+ if (target && target.dsId !== this._linkDrag.fromDsId) {
195
+ this._createRelation(this._linkDrag.fromDsId, this._linkDrag.fromField, target.dsId, target.fieldName);
196
+ }
197
+ this._linkDrag = null;
218
198
  };
219
199
  document.addEventListener('mousemove', onMove);
220
200
  document.addEventListener('mouseup', onUp);
221
201
  }
222
- _onRelationClick(rel) {
223
- this.dispatchEvent(new CustomEvent('relation-edit', {
202
+ _findFieldAt(x, y) {
203
+ for (const t of this._tables) {
204
+ if (x < t.x || x > t.x + t.width)
205
+ continue;
206
+ const fields = t.ds.fields || [];
207
+ for (let i = 0; i < fields.length; i++) {
208
+ const fy = t.y + TH_HEADER + i * TH_FIELD;
209
+ if (y >= fy && y <= fy + TH_FIELD) {
210
+ return { dsId: t.dsId, fieldName: fields[i].name };
211
+ }
212
+ }
213
+ }
214
+ return null;
215
+ }
216
+ _createRelation(parentDs, parentField, childDs, childField) {
217
+ const rel = {
218
+ id: `rel_${Date.now()}`,
219
+ parentSource: parentDs,
220
+ childSource: childDs,
221
+ joins: [{ parentField, childField }],
222
+ joinType: 'inner',
223
+ };
224
+ this.dispatchEvent(new CustomEvent('relation-create', {
224
225
  detail: { relation: rel },
225
226
  bubbles: true, composed: true,
226
227
  }));
227
228
  }
228
- _onTableDoubleClick(dsId) {
229
- this.dispatchEvent(new CustomEvent('table-focus', {
230
- detail: { dataSourceId: dsId },
231
- bubbles: true, composed: true,
232
- }));
229
+ // ─── Relation Actions ─────────────────────────────────────────
230
+ _onRelClick(rel) {
231
+ this._selectedRel = this._selectedRel === rel.id ? null : rel.id;
232
+ }
233
+ _editRelation() {
234
+ const rel = (this.relations || []).find(r => r.id === this._selectedRel);
235
+ if (rel) {
236
+ this.dispatchEvent(new CustomEvent('relation-edit', { detail: { relation: rel }, bubbles: true, composed: true }));
237
+ }
238
+ }
239
+ _deleteRelation() {
240
+ if (this._selectedRel) {
241
+ this.dispatchEvent(new CustomEvent('relation-delete', { detail: { relationId: this._selectedRel }, bubbles: true, composed: true }));
242
+ this._selectedRel = null;
243
+ }
244
+ }
245
+ _emit(action) {
246
+ this.dispatchEvent(new CustomEvent(action, { bubbles: true, composed: true }));
247
+ }
248
+ // ─── Connection Points (field-level) ──────────────────────────
249
+ _getFieldY(dsId, fieldName) {
250
+ const t = this._tables.find(tb => tb.dsId === dsId);
251
+ if (!t)
252
+ return null;
253
+ const fields = t.ds.fields || [];
254
+ const idx = fields.findIndex(f => f.name === fieldName);
255
+ if (idx < 0)
256
+ return t.y + t.height / 2; // fallback to center
257
+ return t.y + TH_HEADER + idx * TH_FIELD + TH_FIELD / 2;
233
258
  }
234
- // ─── Relation Line Geometry ───────────────────────────────────
235
- _getConnectionPoints(rel) {
236
- const parent = this._tables.find(t => t.dsId === rel.parentSource);
237
- const child = this._tables.find(t => t.dsId === rel.childSource);
238
- if (!parent || !child)
259
+ _getRelPoints(rel) {
260
+ const pt = this._tables.find(t => t.dsId === rel.parentSource);
261
+ const ct = this._tables.find(t => t.dsId === rel.childSource);
262
+ if (!pt || !ct)
239
263
  return null;
240
- // Connect from right edge of parent to left edge of child (or vice versa)
241
- const parentCenterY = parent.y + parent.height / 2;
242
- const childCenterY = child.y + child.height / 2;
264
+ const pField = rel.joins[0]?.parentField;
265
+ const cField = rel.joins[0]?.childField;
266
+ const py = this._getFieldY(rel.parentSource, pField) ?? (pt.y + pt.height / 2);
267
+ const cy = this._getFieldY(rel.childSource, cField) ?? (ct.y + ct.height / 2);
268
+ // Determine connection sides
243
269
  let x1, x2;
244
- if (parent.x + parent.width < child.x) {
245
- // Parent is to the left
246
- x1 = parent.x + parent.width;
247
- x2 = child.x;
270
+ if (pt.x + pt.width + 20 < ct.x) {
271
+ x1 = pt.x + pt.width;
272
+ x2 = ct.x;
248
273
  }
249
- else if (child.x + child.width < parent.x) {
250
- // Child is to the left
251
- x1 = parent.x;
252
- x2 = child.x + child.width;
274
+ else if (ct.x + ct.width + 20 < pt.x) {
275
+ x1 = pt.x;
276
+ x2 = ct.x + ct.width;
253
277
  }
254
278
  else {
255
- // Overlapping horizontally connect from bottom/top
256
- x1 = parent.x + parent.width / 2;
257
- x2 = child.x + child.width / 2;
279
+ x1 = pt.x + pt.width;
280
+ x2 = ct.x + ct.width;
258
281
  }
259
- return { x1, y1: parentCenterY, x2, y2: childCenterY };
282
+ return { x1, y1: py, x2, y2: cy };
260
283
  }
261
- // ─── Rendering ────────────────────────────────────────────────
262
- _renderTableBox(table) {
263
- const fields = table.ds.fields || [];
264
- const headerColor = '#1976d2';
265
- return svg `
266
- <g class="table-box"
267
- @mousedown=${(e) => this._onMouseDown(e, table.dsId)}
268
- @dblclick=${() => this._onTableDoubleClick(table.dsId)}>
269
- <!-- Shadow/border -->
270
- <rect class="table-border"
271
- x=${table.x} y=${table.y}
272
- width=${table.width} height=${table.height} />
273
-
274
- <!-- Header background -->
275
- <rect class="table-header-bg"
276
- x=${table.x} y=${table.y}
277
- width=${table.width} height=${TABLE_HEADER_HEIGHT}
278
- fill=${headerColor} />
279
- <!-- Mask bottom corners of header -->
280
- <rect class="table-header-mask"
281
- x=${table.x} y=${table.y + TABLE_HEADER_HEIGHT - 6}
282
- width=${table.width} height="6" />
284
+ // ─── Render ───────────────────────────────────────────────────
285
+ render() {
286
+ const hasDS = this.dataSources.length > 0;
287
+ return html `
288
+ <!-- Toolbar -->
289
+ <div class="er-toolbar">
290
+ <button class="tb-btn ${this._tool === 'select' ? 'active' : ''}"
291
+ @click=${() => { this._tool = 'select'; }}>\u{1F5B1} Select</button>
292
+ <button class="tb-btn ${this._tool === 'link' ? 'active' : ''}"
293
+ @click=${() => { this._tool = 'link'; }}>\u{1F517} Link</button>
294
+ <span class="tb-sep"></span>
295
+ <button class="tb-btn" @click=${() => this._emit('add-datasource')}>\u2795 Table</button>
296
+ <button class="tb-btn" @click=${() => this._emit('add-zentto')}>\u{1F310} Zentto</button>
297
+ <button class="tb-btn" @click=${() => this._emit('add-rest')}>\u{1F517} REST</button>
298
+ <span class="tb-sep"></span>
299
+ ${this._selectedRel ? html `
300
+ <button class="tb-btn" @click=${this._editRelation}>\u270E Edit Link</button>
301
+ <button class="tb-btn danger" @click=${this._deleteRelation}>\u{1F5D1} Delete Link</button>
302
+ ` : nothing}
303
+ <span class="tb-spacer"></span>
304
+ <button class="tb-btn" @click=${() => { this._tables = []; this._layoutTables(); }}>\u{2B73} Auto Layout</button>
305
+ <span class="tb-info">${this.dataSources.length} tables \u2022 ${(this.relations || []).length} relations</span>
306
+ </div>
283
307
 
284
- <!-- Table name -->
285
- <text class="table-name"
286
- x=${table.x + 10} y=${table.y + 18}>
287
- ${table.ds.table || table.ds.name}
288
- </text>
308
+ ${!hasDS ? html `
309
+ <div class="empty">No data sources. Use the toolbar to add tables, Zentto modules, or REST endpoints.</div>
310
+ ` : html `
311
+ <div class="er-canvas ${this._tool === 'link' ? 'link-mode' : ''}">
312
+ <svg width=${this._svgW} height=${this._svgH} xmlns="http://www.w3.org/2000/svg">
313
+ ${this._renderDefs()}
314
+ ${(this.relations || []).map(r => this._renderRelLine(r))}
315
+ ${this._tables.map(t => this._renderTable(t))}
316
+ ${this._linkDrag ? this._renderLinkPreview() : nothing}
317
+ </svg>
318
+ </div>
319
+ `}
320
+ `;
321
+ }
322
+ _renderDefs() {
323
+ return svg `<defs>
324
+ ${Object.entries(JOIN_COLORS).map(([type, color]) => svg `
325
+ <marker id="arr-${type}" viewBox="0 0 12 8" refX="11" refY="4"
326
+ markerWidth="10" markerHeight="8" orient="auto-start-reverse">
327
+ <path d="M 0 0 L 12 4 L 0 8 z" fill=${color} />
328
+ </marker>
329
+ `)}
330
+ <marker id="arr-preview" viewBox="0 0 12 8" refX="11" refY="4"
331
+ markerWidth="10" markerHeight="8" orient="auto-start-reverse">
332
+ <path d="M 0 0 L 12 4 L 0 8 z" fill="#1976d2" opacity="0.5" />
333
+ </marker>
334
+ </defs>`;
335
+ }
336
+ // ─── Table Box ────────────────────────────────────────────────
337
+ _renderTable(t) {
338
+ const fields = t.ds.fields || [];
339
+ const hdrColor = '#1976d2';
340
+ return svg `
341
+ <g class="tbl" @mousedown=${(e) => this._onTableDown(e, t.dsId)}>
342
+ <rect class="tbl-border" x=${t.x} y=${t.y} width=${t.width} height=${t.height} />
343
+ <rect class="tbl-hdr" x=${t.x} y=${t.y} width=${t.width} height=${TH_HEADER} fill=${hdrColor} />
344
+ <rect class="tbl-hdr-mask" x=${t.x} y=${t.y + TH_HEADER - 6} width=${t.width} height="6" />
345
+ <text class="tbl-name" x=${t.x + 8} y=${t.y + 19}>${t.ds.table || t.ds.name}</text>
346
+ <text class="tbl-count" x=${t.x + t.width - 8} y=${t.y + 19} text-anchor="end">${fields.length}</text>
289
347
 
290
- <!-- Fields -->
291
348
  ${fields.map((f, i) => {
292
- const fy = table.y + TABLE_HEADER_HEIGHT + i * FIELD_ROW_HEIGHT;
349
+ const fy = t.y + TH_HEADER + i * TH_FIELD;
293
350
  return svg `
294
- <g class="field-row">
295
- ${i > 0 ? svg `
296
- <line class="field-separator"
297
- x1=${table.x + 4} y1=${fy}
298
- x2=${table.x + table.width - 4} y2=${fy} />
299
- ` : nothing}
300
- ${f.isPrimaryKey ? svg `
301
- <text class="field-pk"
302
- x=${table.x + 6} y=${fy + 14}>\u{1F511}</text>
303
- ` : nothing}
304
- <text class="field-name"
305
- x=${table.x + (f.isPrimaryKey ? 20 : 8)}
306
- y=${fy + 14}>
307
- ${f.label || f.name}
308
- </text>
309
- <text class="field-type"
310
- x=${table.x + table.width - 8}
311
- y=${fy + 14}
312
- text-anchor="end">
313
- ${f.nativeType || f.type}
314
- </text>
351
+ <g class="fld-row">
352
+ ${i > 0 ? svg `<line class="fld-sep" x1=${t.x + 4} y1=${fy} x2=${t.x + t.width - 4} y2=${fy} />` : nothing}
353
+ <!-- Hover highlight bg -->
354
+ <rect x=${t.x + 1} y=${fy} width=${t.width - 2} height=${TH_FIELD} fill="transparent" />
355
+ ${f.isPrimaryKey ? svg `<text class="fld-pk" x=${t.x + 6} y=${fy + 13}>\u{1F511}</text>` : nothing}
356
+ <text class="fld-name" x=${t.x + (f.isPrimaryKey ? 20 : 8)} y=${fy + 13}>${f.label || f.name}</text>
357
+ <text class="fld-type" x=${t.x + t.width - 16} y=${fy + 13} text-anchor="end">${f.nativeType || f.type}</text>
358
+ <!-- Link dot (right edge) -->
359
+ <circle class="fld-link-dot" cx=${t.x + t.width - 4} cy=${fy + TH_FIELD / 2} r="4"
360
+ @mousedown=${(e) => this._onFieldLinkStart(e, t.dsId, f.name)} />
361
+ <!-- Link dot (left edge) -->
362
+ <circle class="fld-link-dot" cx=${t.x + 4} cy=${fy + TH_FIELD / 2} r="4"
363
+ @mousedown=${(e) => this._onFieldLinkStart(e, t.dsId, f.name)} />
315
364
  </g>
316
365
  `;
317
366
  })}
318
367
  </g>
319
368
  `;
320
369
  }
321
- _renderRelationLine(rel) {
322
- const pts = this._getConnectionPoints(rel);
370
+ // ─── Relation Line ────────────────────────────────────────────
371
+ _renderRelLine(rel) {
372
+ const pts = this._getRelPoints(rel);
323
373
  if (!pts)
324
374
  return nothing;
325
375
  const { x1, y1, x2, y2 } = pts;
326
376
  const color = JOIN_COLORS[rel.joinType] || '#666';
377
+ const card = CARDINALITY[rel.joinType];
378
+ const isHovered = this._hoveredRel === rel.id;
379
+ const isSelected = this._selectedRel === rel.id;
380
+ const cx1 = x1 + (x2 - x1) * 0.35;
381
+ const cx2 = x1 + (x2 - x1) * 0.65;
382
+ const d = `M ${x1} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${x2} ${y2}`;
327
383
  const midX = (x1 + x2) / 2;
328
384
  const midY = (y1 + y2) / 2;
329
- // Bezier control points for smooth curve
330
- const cx1 = x1 + (x2 - x1) * 0.4;
331
- const cx2 = x1 + (x2 - x1) * 0.6;
332
- const isHovered = this._hoveredRelation === rel.id;
333
- const label = rel.joinType.toUpperCase();
385
+ const joinLabel = rel.joins.map(j => `${j.parentField} = ${j.childField}`).join(', ');
334
386
  return svg `
335
387
  <g>
336
- <!-- Clickable wider path for easier selection -->
337
- <path
338
- d="M ${x1} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${x2} ${y2}"
339
- stroke="transparent" fill="none" stroke-width="12"
340
- style="cursor:pointer"
341
- @click=${() => this._onRelationClick(rel)}
342
- @mouseenter=${() => { this._hoveredRelation = rel.id; }}
343
- @mouseleave=${() => { this._hoveredRelation = null; }}
344
- />
345
- <!-- Visible line -->
346
- <path
347
- class="relation-line ${isHovered ? 'hovered' : ''}"
348
- d="M ${x1} ${y1} C ${cx1} ${y1}, ${cx2} ${y2}, ${x2} ${y2}"
349
- stroke=${color}
350
- marker-end="url(#arrow-${rel.joinType})"
351
- />
352
- <!-- Label -->
353
- <rect class="relation-label-bg"
354
- x=${midX - 18} y=${midY - 8}
355
- width="36" height="16"
356
- stroke=${color} stroke-width="0.5" />
357
- <text class="relation-label"
358
- x=${midX} y=${midY + 4}
359
- text-anchor="middle"
360
- fill=${color}>
361
- ${label}
388
+ <path class="rel-hit" d=${d}
389
+ @click=${() => this._onRelClick(rel)}
390
+ @mouseenter=${() => { this._hoveredRel = rel.id; }}
391
+ @mouseleave=${() => { this._hoveredRel = null; }} />
392
+ <path class="rel-path ${isHovered ? 'hovered' : ''} ${isSelected ? 'selected' : ''}"
393
+ d=${d} stroke=${color}
394
+ marker-start="url(#arr-${rel.joinType})"
395
+ marker-end="url(#arr-${rel.joinType})" />
396
+
397
+ <!-- Cardinality labels near endpoints -->
398
+ <text class="rel-card" x=${x1 + (x1 < x2 ? 14 : -14)} y=${y1 - 6} fill=${color} text-anchor=${x1 < x2 ? 'start' : 'end'}>${card.start}</text>
399
+ <text class="rel-card" x=${x2 + (x2 > x1 ? -14 : 14)} y=${y2 - 6} fill=${color} text-anchor=${x2 > x1 ? 'end' : 'start'}>${card.end}</text>
400
+
401
+ <!-- Center label -->
402
+ <rect class="rel-label-bg" x=${midX - 40} y=${midY - 9} width="80" height="18"
403
+ fill="#fff" stroke=${color} stroke-width="0.5" />
404
+ <text class="rel-label" x=${midX} y=${midY + 3} text-anchor="middle" fill=${color}>
405
+ ${rel.joinType.toUpperCase()} ${joinLabel.length < 20 ? joinLabel : ''}
362
406
  </text>
363
407
  </g>
364
408
  `;
365
409
  }
366
- _renderArrowDefs() {
410
+ // ─── Link Drag Preview ────────────────────────────────────────
411
+ _renderLinkPreview() {
412
+ if (!this._linkDrag)
413
+ return nothing;
414
+ const { fromX, fromY, currentX, currentY } = this._linkDrag;
367
415
  return svg `
368
- <defs>
369
- ${Object.entries(JOIN_COLORS).map(([type, color]) => svg `
370
- <marker id="arrow-${type}" viewBox="0 0 10 6"
371
- refX="9" refY="3"
372
- markerWidth="8" markerHeight="6"
373
- orient="auto-start-reverse">
374
- <path d="M 0 0 L 10 3 L 0 6 z" fill=${color} />
375
- </marker>
376
- `)}
377
- </defs>
378
- `;
379
- }
380
- render() {
381
- if (this.dataSources.length === 0) {
382
- return html `<div class="empty-state">No data sources to display.<br/>Add tables to see the ER diagram.</div>`;
383
- }
384
- return html `
385
- <div class="er-container">
386
- <svg width=${this._svgWidth} height=${this._svgHeight}
387
- xmlns="http://www.w3.org/2000/svg">
388
- ${this._renderArrowDefs()}
389
-
390
- <!-- Relation lines (behind tables) -->
391
- ${(this.relations || []).map(rel => this._renderRelationLine(rel))}
392
-
393
- <!-- Table boxes -->
394
- ${this._tables.map(table => this._renderTableBox(table))}
395
- </svg>
396
- </div>
416
+ <line class="link-preview" x1=${fromX} y1=${fromY} x2=${currentX} y2=${currentY}
417
+ marker-end="url(#arr-preview)" />
397
418
  `;
398
419
  }
399
420
  };
@@ -411,13 +432,22 @@ __decorate([
411
432
  ], ErDiagram.prototype, "_dragging", void 0);
412
433
  __decorate([
413
434
  state()
414
- ], ErDiagram.prototype, "_hoveredRelation", void 0);
435
+ ], ErDiagram.prototype, "_linkDrag", void 0);
436
+ __decorate([
437
+ state()
438
+ ], ErDiagram.prototype, "_hoveredRel", void 0);
439
+ __decorate([
440
+ state()
441
+ ], ErDiagram.prototype, "_selectedRel", void 0);
442
+ __decorate([
443
+ state()
444
+ ], ErDiagram.prototype, "_svgW", void 0);
415
445
  __decorate([
416
446
  state()
417
- ], ErDiagram.prototype, "_svgWidth", void 0);
447
+ ], ErDiagram.prototype, "_svgH", void 0);
418
448
  __decorate([
419
449
  state()
420
- ], ErDiagram.prototype, "_svgHeight", void 0);
450
+ ], ErDiagram.prototype, "_tool", void 0);
421
451
  ErDiagram = __decorate([
422
452
  customElement('zrd-er-diagram')
423
453
  ], ErDiagram);