canvas_erd 0.1.0

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.
@@ -0,0 +1,473 @@
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === "object" && module.exports) module.exports = api;
4
+ else root.CanvasERDDocument = api;
5
+ })(typeof globalThis === "object" ? globalThis : this, function () {
6
+ "use strict";
7
+
8
+ const CARD_WIDTH = 300;
9
+ const HEADER_HEIGHT = 58;
10
+ const ROW_HEIGHT = 23;
11
+ const CARD_PADDING = 10;
12
+ const NOTE_PADDING = 12;
13
+ const CANVAS_FONT_FAMILY = "Arial";
14
+
15
+ function topLeft(options = {}) {
16
+ return {
17
+ originX: "left",
18
+ originY: "top",
19
+ ...options
20
+ };
21
+ }
22
+
23
+ function uncachedText(options = {}) {
24
+ return topLeft({ ...options, objectCaching: false });
25
+ }
26
+
27
+ function paddedBackgroundBounds(width, height, padding) {
28
+ return {
29
+ left: -width / 2 - padding,
30
+ top: -height / 2 - padding,
31
+ width: width + padding * 2,
32
+ height: height + padding * 2
33
+ };
34
+ }
35
+
36
+ function noteSelectionPadding(zoom) {
37
+ return NOTE_PADDING * zoom;
38
+ }
39
+
40
+ function noteCanvasGeometry(note) {
41
+ return {
42
+ left: note.x,
43
+ top: note.y,
44
+ width: note.width,
45
+ angle: note.angle ?? 0
46
+ };
47
+ }
48
+
49
+ function noteStateGeometry(object) {
50
+ return {
51
+ x: object.left,
52
+ y: object.top,
53
+ width: object.width * object.scaleX,
54
+ angle: object.angle,
55
+ text: object.text
56
+ };
57
+ }
58
+
59
+ function erdFilename(value) {
60
+ const basename = String(value).trim().replace(/(?:\.erd)?\.png$/i, "");
61
+ return `${basename || "diagram"}.erd.png`;
62
+ }
63
+
64
+ function diagramName(value) {
65
+ const name = String(value || "").trim().replace(/(?:\.erd)?\.png$/i, "");
66
+ return name || "Untitled ERD";
67
+ }
68
+
69
+ function confirmDiscardChanges(dirty, confirmDiscard) {
70
+ return !dirty || confirmDiscard("Discard unsaved changes to this diagram?");
71
+ }
72
+
73
+ function shortcutAction(event) {
74
+ const key = String(event.key).toLowerCase();
75
+ const command = event.metaKey || event.ctrlKey;
76
+ if (event.altKey) return null;
77
+
78
+ if (command) {
79
+ return { s: "save", o: "open", n: "new" }[key] || null;
80
+ }
81
+
82
+ return {
83
+ n: "addNote",
84
+ a: "arrow",
85
+ t: "toggleTables",
86
+ l: "layoutTables",
87
+ "+": "zoomIn",
88
+ "=": "zoomIn",
89
+ "-": "zoomOut",
90
+ "0": "fit",
91
+ "?": "help"
92
+ }[key] || null;
93
+ }
94
+
95
+ function isSaveShortcut(event) {
96
+ return shortcutAction(event) === "save";
97
+ }
98
+
99
+ function selectedDiagramFilename(filename, availableFilenames) {
100
+ return filename && availableFilenames.includes(filename) ? filename : "";
101
+ }
102
+
103
+ function notePositionAt(point) {
104
+ return { x: point.x - 110, y: point.y - 45 };
105
+ }
106
+
107
+ function snapPointToBounds(point, bounds) {
108
+ const right = bounds.left + bounds.width;
109
+ const bottom = bounds.top + bounds.height;
110
+ let x = Math.min(right, Math.max(bounds.left, point.x));
111
+ let y = Math.min(bottom, Math.max(bounds.top, point.y));
112
+ const edges = [
113
+ { distance: Math.abs(point.x - bounds.left), apply: () => { x = bounds.left; } },
114
+ { distance: Math.abs(point.x - right), apply: () => { x = right; } },
115
+ { distance: Math.abs(point.y - bounds.top), apply: () => { y = bounds.top; } },
116
+ { distance: Math.abs(point.y - bottom), apply: () => { y = bottom; } }
117
+ ];
118
+ edges.reduce((nearest, edge) => edge.distance < nearest.distance ? edge : nearest).apply();
119
+
120
+ return {
121
+ point: { x, y },
122
+ anchor: {
123
+ x: bounds.width === 0 ? 0 : (x - bounds.left) / bounds.width,
124
+ y: bounds.height === 0 ? 0 : (y - bounds.top) / bounds.height
125
+ }
126
+ };
127
+ }
128
+
129
+ function pointAtAnchor(bounds, anchor) {
130
+ return {
131
+ x: bounds.left + bounds.width * anchor.x,
132
+ y: bounds.top + bounds.height * anchor.y
133
+ };
134
+ }
135
+
136
+ function arrowAttachment(target) {
137
+ if (target?.canvasErdType === "entity") return { type: "entity", id: target.entityId };
138
+ if (target?.canvasErdType === "note") return { type: "note", id: target.noteId };
139
+ return null;
140
+ }
141
+
142
+ function canvasLayer(type) {
143
+ if (type === "relationship") return 0;
144
+ if (["arrow", "arrow-preview"].includes(type)) return 2;
145
+ if (type === "arrow-endpoint") return 3;
146
+ return 1;
147
+ }
148
+
149
+ function transformedArrowEndpoints(points, transform) {
150
+ const apply = (x, y) => ({
151
+ x: transform[0] * x + transform[2] * y + transform[4],
152
+ y: transform[1] * x + transform[3] * y + transform[5]
153
+ });
154
+ return {
155
+ start: apply(points.x1, points.y1),
156
+ end: apply(points.x2, points.y2)
157
+ };
158
+ }
159
+
160
+ function createChangeTracker(onChange = () => {}) {
161
+ let dirty = false;
162
+
163
+ return {
164
+ markDirty() {
165
+ if (dirty) return;
166
+ dirty = true;
167
+ onChange(dirty);
168
+ },
169
+ markClean() {
170
+ if (!dirty) return;
171
+ dirty = false;
172
+ onChange(dirty);
173
+ },
174
+ isDirty() {
175
+ return dirty;
176
+ }
177
+ };
178
+ }
179
+
180
+ function tableHeight(entity) {
181
+ const rows = Math.max(entity.attributes.length, 1);
182
+ return HEADER_HEIGHT + rows * ROW_HEIGHT + CARD_PADDING;
183
+ }
184
+
185
+ function layoutEntities(entities, options = {}) {
186
+ const margin = options.margin || 48;
187
+ const gapX = options.gapX || 90;
188
+ const gapY = options.gapY || 80;
189
+ const columns = options.columns || Math.max(1, Math.ceil(Math.sqrt(entities.length)));
190
+ const sorted = [...entities].sort((first, second) => first.id.localeCompare(second.id));
191
+ const positions = {};
192
+ let y = margin;
193
+
194
+ for (let index = 0; index < sorted.length; index += columns) {
195
+ const row = sorted.slice(index, index + columns);
196
+ const rowHeight = Math.max(...row.map(tableHeight));
197
+
198
+ row.forEach((entity, column) => {
199
+ positions[entity.id] = {
200
+ x: margin + column * (CARD_WIDTH + gapX),
201
+ y
202
+ };
203
+ });
204
+ y += rowHeight + gapY;
205
+ }
206
+
207
+ return positions;
208
+ }
209
+
210
+ function createState(schema, positions = layoutEntities(schema.entities)) {
211
+ return {
212
+ includedEntityIds: schema.entities.map((entity) => entity.id),
213
+ positions,
214
+ notes: [],
215
+ arrows: []
216
+ };
217
+ }
218
+
219
+ function recordsById(records) {
220
+ return new Map(records.map((record) => [record.id, record]));
221
+ }
222
+
223
+ function changedRecordIds(previousRecords, nextRecords) {
224
+ const previous = recordsById(previousRecords);
225
+ return nextRecords
226
+ .filter((record) => previous.has(record.id) && JSON.stringify(previous.get(record.id)) !== JSON.stringify(record))
227
+ .map((record) => record.id);
228
+ }
229
+
230
+ function recordChanges(previousRecords, nextRecords) {
231
+ const previousIds = new Set(previousRecords.map((record) => record.id));
232
+ const nextIds = new Set(nextRecords.map((record) => record.id));
233
+ return {
234
+ added: nextRecords.filter((record) => !previousIds.has(record.id)).map((record) => record.id),
235
+ removed: previousRecords.filter((record) => !nextIds.has(record.id)).map((record) => record.id),
236
+ updated: changedRecordIds(previousRecords, nextRecords)
237
+ };
238
+ }
239
+
240
+ function positionNewEntities(positions, entities) {
241
+ const unpositioned = entities.filter((entity) => !positions[entity.id]);
242
+ if (unpositioned.length === 0) return positions;
243
+
244
+ const positioned = entities.filter((entity) => positions[entity.id]);
245
+ const bottom = positioned.reduce((maximum, entity) => (
246
+ Math.max(maximum, positions[entity.id].y + tableHeight(entity))
247
+ ), 0);
248
+ const margin = 48;
249
+ const startY = positioned.length === 0 ? margin : bottom + 80;
250
+ const addedPositions = layoutEntities(unpositioned, { margin });
251
+
252
+ unpositioned.forEach((entity) => {
253
+ positions[entity.id] = {
254
+ x: addedPositions[entity.id].x,
255
+ y: addedPositions[entity.id].y - margin + startY
256
+ };
257
+ });
258
+ return positions;
259
+ }
260
+
261
+ function reconcileState(state, previousSchema, nextSchema) {
262
+ const previousEntityIds = new Set(previousSchema.entities.map((entity) => entity.id));
263
+ const nextEntityIds = new Set(nextSchema.entities.map((entity) => entity.id));
264
+ const positions = positionNewEntities(
265
+ Object.fromEntries(Object.entries(state.positions).map(([id, position]) => [id, { ...position }])),
266
+ nextSchema.entities
267
+ );
268
+
269
+ return {
270
+ state: {
271
+ ...state,
272
+ includedEntityIds: state.includedEntityIds.filter((id) => previousEntityIds.has(id) && nextEntityIds.has(id)),
273
+ positions,
274
+ notes: state.notes.map((note) => ({ ...note })),
275
+ arrows: (state.arrows || []).map((arrow) => ({
276
+ ...arrow,
277
+ start: { ...arrow.start, attachment: arrow.start.attachment ? { ...arrow.start.attachment } : null },
278
+ end: { ...arrow.end, attachment: arrow.end.attachment ? { ...arrow.end.attachment } : null }
279
+ }))
280
+ },
281
+ changes: {
282
+ entities: recordChanges(previousSchema.entities, nextSchema.entities),
283
+ relationships: recordChanges(previousSchema.relationships, nextSchema.relationships),
284
+ specializations: recordChanges(previousSchema.specializations, nextSchema.specializations)
285
+ }
286
+ };
287
+ }
288
+
289
+ function changeSummary(changes) {
290
+ const parts = [];
291
+ const entityChanges = changes.entities;
292
+ const relationshipChanges = changes.relationships;
293
+ const specializationChanges = changes.specializations;
294
+
295
+ if (entityChanges.added.length > 0) parts.push(`${entityChanges.added.length} added`);
296
+ if (entityChanges.updated.length > 0) parts.push(`${entityChanges.updated.length} updated`);
297
+ if (entityChanges.removed.length > 0) {
298
+ parts.push(`${entityChanges.removed.length} removed (${entityChanges.removed.join(", ")})`);
299
+ }
300
+
301
+ const connectionCount = [relationshipChanges, specializationChanges].reduce((total, records) => (
302
+ total + records.added.length + records.updated.length + records.removed.length
303
+ ), 0);
304
+ if (connectionCount > 0) {
305
+ parts.push(`${connectionCount} relationship ${connectionCount === 1 ? "change" : "changes"}`);
306
+ }
307
+
308
+ return parts.length === 0 ? "Schema refreshed: no changes." : `Schema refreshed: ${parts.join(", ")}.`;
309
+ }
310
+
311
+ function setEntityIncluded(state, entityId, included) {
312
+ const ids = new Set(state.includedEntityIds);
313
+ if (included) ids.add(entityId);
314
+ else ids.delete(entityId);
315
+
316
+ return {
317
+ ...state,
318
+ includedEntityIds: [...ids]
319
+ };
320
+ }
321
+
322
+ function selectedEntityIds(activeObject) {
323
+ if (!activeObject) return [];
324
+ const objects = activeObject.canvasErdType === "entity"
325
+ ? [activeObject]
326
+ : (typeof activeObject.getObjects === "function" ? activeObject.getObjects() : []);
327
+ return objects
328
+ .filter((object) => object.canvasErdType === "entity")
329
+ .map((object) => object.entityId);
330
+ }
331
+
332
+ function visibleRelationships(schema, includedEntityIds) {
333
+ const included = new Set(includedEntityIds);
334
+ return schema.relationships.filter((relationship) => (
335
+ included.has(relationship.source_id) && included.has(relationship.destination_id)
336
+ ));
337
+ }
338
+
339
+ function rangeLabel(range) {
340
+ if (range.maximum === null) return `${range.minimum}..*`;
341
+ if (range.minimum === range.maximum) return String(range.minimum);
342
+ return `${range.minimum}..${range.maximum}`;
343
+ }
344
+
345
+ function cardinalityLabel(relationship) {
346
+ return `${rangeLabel(relationship.cardinality.source)} — ${rangeLabel(relationship.cardinality.destination)}`;
347
+ }
348
+
349
+ function fitViewport(bounds, viewport, padding = 64, maximumZoom = 1) {
350
+ if (!bounds || bounds.width <= 0 || bounds.height <= 0) return [1, 0, 0, 1, 0, 0];
351
+
352
+ const availableWidth = Math.max(1, viewport.width - padding * 2);
353
+ const availableHeight = Math.max(1, viewport.height - padding * 2);
354
+ const zoom = Math.min(maximumZoom, availableWidth / bounds.width, availableHeight / bounds.height);
355
+ const centerX = bounds.left + bounds.width / 2;
356
+ const centerY = bounds.top + bounds.height / 2;
357
+
358
+ return [
359
+ zoom,
360
+ 0,
361
+ 0,
362
+ zoom,
363
+ viewport.width / 2 - centerX * zoom,
364
+ viewport.height / 2 - centerY * zoom
365
+ ];
366
+ }
367
+
368
+ function panViewport(transform, deltaX, deltaY) {
369
+ const nextTransform = [...transform];
370
+ nextTransform[4] -= deltaX;
371
+ nextTransform[5] -= deltaY;
372
+ return nextTransform;
373
+ }
374
+
375
+ function zoomForWheel(currentZoom, deltaY, minimumZoom = 0.15, maximumZoom = 2.5) {
376
+ return Math.min(maximumZoom, Math.max(minimumZoom, currentZoom * Math.exp(-deltaY * 0.01)));
377
+ }
378
+
379
+ function shouldCacheTable(zoom, retinaScaling, maximumEffectiveZoom = 3) {
380
+ return zoom * retinaScaling <= maximumEffectiveZoom;
381
+ }
382
+
383
+ function createPanScheduler(apply, schedule = globalThis.requestAnimationFrame) {
384
+ let frame = null;
385
+ let deltaX = 0;
386
+ let deltaY = 0;
387
+
388
+ return function schedulePan(nextDeltaX, nextDeltaY) {
389
+ deltaX += nextDeltaX;
390
+ deltaY += nextDeltaY;
391
+ if (frame !== null) return;
392
+
393
+ frame = schedule(() => {
394
+ const accumulatedX = deltaX;
395
+ const accumulatedY = deltaY;
396
+ frame = null;
397
+ deltaX = 0;
398
+ deltaY = 0;
399
+ apply(accumulatedX, accumulatedY);
400
+ });
401
+ };
402
+ }
403
+
404
+ function createPanQualityController(setRetina, options = {}) {
405
+ const schedule = options.schedule || globalThis.setTimeout;
406
+ const cancel = options.cancel || globalThis.clearTimeout;
407
+ const idleDelay = options.idleDelay || 150;
408
+ let restoreTimer = null;
409
+ let reduced = false;
410
+
411
+ function finish() {
412
+ if (restoreTimer !== null) cancel(restoreTimer);
413
+ restoreTimer = null;
414
+ if (!reduced) return;
415
+ reduced = false;
416
+ setRetina(true);
417
+ }
418
+
419
+ return {
420
+ begin() {
421
+ if (!reduced) {
422
+ reduced = true;
423
+ setRetina(false);
424
+ }
425
+ if (restoreTimer !== null) cancel(restoreTimer);
426
+ restoreTimer = schedule(finish, idleDelay);
427
+ },
428
+ finish
429
+ };
430
+ }
431
+
432
+ return {
433
+ CARD_WIDTH,
434
+ HEADER_HEIGHT,
435
+ ROW_HEIGHT,
436
+ NOTE_PADDING,
437
+ CANVAS_FONT_FAMILY,
438
+ topLeft,
439
+ uncachedText,
440
+ paddedBackgroundBounds,
441
+ noteSelectionPadding,
442
+ noteCanvasGeometry,
443
+ noteStateGeometry,
444
+ erdFilename,
445
+ diagramName,
446
+ confirmDiscardChanges,
447
+ shortcutAction,
448
+ isSaveShortcut,
449
+ selectedDiagramFilename,
450
+ notePositionAt,
451
+ snapPointToBounds,
452
+ pointAtAnchor,
453
+ arrowAttachment,
454
+ canvasLayer,
455
+ transformedArrowEndpoints,
456
+ createChangeTracker,
457
+ tableHeight,
458
+ layoutEntities,
459
+ createState,
460
+ reconcileState,
461
+ changeSummary,
462
+ setEntityIncluded,
463
+ selectedEntityIds,
464
+ visibleRelationships,
465
+ cardinalityLabel,
466
+ fitViewport,
467
+ panViewport,
468
+ zoomForWheel,
469
+ shouldCacheTable,
470
+ createPanScheduler,
471
+ createPanQualityController
472
+ };
473
+ });
@@ -0,0 +1,119 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>CanvasERD</title>
7
+ <link rel="stylesheet" href="/assets/styles.css">
8
+ </head>
9
+ <body>
10
+ <div class="app-shell">
11
+ <main class="workspace">
12
+ <section id="canvas-panel" class="canvas-panel" aria-label="Entity relationship diagram canvas">
13
+ <canvas id="erd-canvas"></canvas>
14
+
15
+ <div class="diagram-toolbar">
16
+ <div class="diagram-identity">
17
+ <div class="diagram-name-row">
18
+ <h1 id="diagram-name-display">Untitled ERD</h1>
19
+ <button id="edit-diagram-name" class="icon-button name-edit" type="button" aria-label="Edit diagram name" title="Edit diagram name">
20
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M3 21L12 21H21" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12.2218 5.82839L15.0503 2.99996L20 7.94971L17.1716 10.7781M12.2218 5.82839L6.61522 11.435C6.42769 11.6225 6.32233 11.8769 6.32233 12.1421L6.32233 16.6776L10.8579 16.6776C11.1231 16.6776 11.3774 16.5723 11.565 16.3847L17.1716 10.7781M12.2218 5.82839L17.1716 10.7781" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
21
+ </button>
22
+ <input id="diagram-name" type="text" value="Untitled ERD" aria-label="Diagram name" autocomplete="off" hidden>
23
+ </div>
24
+ <p id="status" aria-live="polite">Loading Rails ERD schema…</p>
25
+ </div>
26
+ <div class="toolbar-actions" aria-label="Diagram controls">
27
+ <button id="new-diagram" class="icon-button" type="button" aria-label="New diagram" title="New diagram (⌘/Ctrl-N)">
28
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M9 12H12M15 12H12M12 12V9M12 12V15" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M4 21.4V2.6C4 2.26863 4.26863 2 4.6 2H16.2515C16.4106 2 16.5632 2.06321 16.6757 2.17574L19.8243 5.32426C19.9368 5.43679 20 5.5894 20 5.74853V21.4C20 21.7314 19.7314 22 19.4 22H4.6C4.26863 22 4 21.7314 4 21.4Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M16 2V5.4C16 5.73137 16.2686 6 16.6 6H20" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
29
+ </button>
30
+ <button id="open-diagram" class="icon-button" type="button" aria-label="Open diagram" title="Open diagram (⌘/Ctrl-O)" aria-expanded="false">
31
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M2 11V4.6C2 4.26863 2.26863 4 2.6 4H8.77805C8.92127 4 9.05977 4.05124 9.16852 4.14445L12.3315 6.85555C12.4402 6.94876 12.5787 7 12.722 7H21.4C21.7314 7 22 7.26863 22 7.6V11M2 11V19.4C2 19.7314 2.26863 20 2.6 20H21.4C21.7314 20 22 19.7314 22 19.4V11M2 11H22" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg> </button>
32
+ <select id="saved-diagrams" aria-label="Saved diagrams" hidden>
33
+ <option value="" selected>Untitled ERD</option>
34
+ </select>
35
+ <button id="save-diagram" class="icon-button" type="button" aria-label="Save diagram" title="Save diagram (⌘/Ctrl-S)" disabled>
36
+ <!-- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"><path d="M5 3h12l3 3v15H4V4a1 1 0 0 1 1-1Z"/><path d="M8 3v6h8V3M8 21v-8h8v8"/></svg> -->
37
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M3 19V5C3 3.89543 3.89543 3 5 3H16.1716C16.702 3 17.2107 3.21071 17.5858 3.58579L20.4142 6.41421C20.7893 6.78929 21 7.29799 21 7.82843V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19Z" stroke="#000000" stroke-width="1.5"></path><path d="M8.6 9H15.4C15.7314 9 16 8.73137 16 8.4V3.6C16 3.26863 15.7314 3 15.4 3H8.6C8.26863 3 8 3.26863 8 3.6V8.4C8 8.73137 8.26863 9 8.6 9Z" stroke="#000000" stroke-width="1.5"></path><path d="M6 13.6V21H18V13.6C18 13.2686 17.7314 13 17.4 13H6.6C6.26863 13 6 13.2686 6 13.6Z" stroke="#000000" stroke-width="1.5"></path></svg>
38
+ </button>
39
+ </div>
40
+ </div>
41
+
42
+ <aside id="tables-sidebar" class="sidebar collapsed" aria-label="Diagram tables">
43
+ <div class="sidebar-heading">
44
+ <label class="search-label" for="table-search">Tables</label>
45
+ <div class="sidebar-actions">
46
+ <button id="refresh-schema" class="icon-button compact" type="button" aria-label="Refresh schema" title="Refresh schema">
47
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M21.1679 8C19.6247 4.46819 16.1006 2 11.9999 2C6.81459 2 2.55104 5.94668 2.04932 11" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M17 8H21.4C21.7314 8 22 7.73137 22 7.4V3" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M2.88146 16C4.42458 19.5318 7.94874 22 12.0494 22C17.2347 22 21.4983 18.0533 22 13" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M7.04932 16H2.64932C2.31795 16 2.04932 16.2686 2.04932 16.6V21" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
48
+ </button>
49
+ </div>
50
+ </div>
51
+ <div class="sidebar-content">
52
+ <input id="table-search" type="search" placeholder="Filter tables…" autocomplete="off">
53
+ <div class="selection-actions">
54
+ <button id="select-all" type="button">Select all</button>
55
+ <button id="select-none" type="button">Clear</button>
56
+ </div>
57
+ <div id="table-list" class="table-list"></div>
58
+ </div>
59
+ </aside>
60
+
61
+ <div id="notice" class="notice" aria-live="polite" hidden></div>
62
+ <div id="shortcut-panel" class="shortcut-panel" role="dialog" aria-label="Keyboard shortcuts" hidden>
63
+ <h2>Keyboard shortcuts</h2>
64
+ <dl>
65
+ <div><dt>Open</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>O</kbd></dd></div>
66
+ <div><dt>New</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>N</kbd></dd></div>
67
+ <div><dt>Save</dt><dd><kbd>⌘/Ctrl</kbd> <kbd>S</kbd></dd></div>
68
+ <div><dt>Add note at cursor</dt><dd><kbd>N</kbd></dd></div>
69
+ <div><dt>Arrow tool</dt><dd><kbd>A</kbd></dd></div>
70
+ <div><dt>Free arrow endpoint</dt><dd><kbd>Ctrl</kbd></dd></div>
71
+ <div><dt>Toggle tables</dt><dd><kbd>T</kbd></dd></div>
72
+ <div><dt>Layout tables</dt><dd><kbd>L</kbd></dd></div>
73
+ <div><dt>Zoom in</dt><dd><kbd>+</kbd></dd></div>
74
+ <div><dt>Zoom out</dt><dd><kbd>−</kbd></dd></div>
75
+ <div><dt>Fit diagram</dt><dd><kbd>0</kbd></dd></div>
76
+ <div><dt>Shortcut help</dt><dd><kbd>?</kbd></dd></div>
77
+ </dl>
78
+ </div>
79
+ <div class="canvas-tools" aria-label="Canvas tools">
80
+ <button id="toggle-tables" class="icon-button" type="button" aria-label="Show tables" title="Show tables (T)" aria-expanded="false">
81
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M3 20.4V3.6C3 3.26863 3.26863 3 3.6 3H20.4C20.7314 3 21 3.26863 21 3.6V20.4C21 20.7314 20.7314 21 20.4 21H3.6C3.26863 21 3 20.7314 3 20.4Z" stroke="#000000" stroke-width="1.5"></path><path d="M3 16.5H21" stroke="#000000" stroke-width="1.5"></path><path d="M3 12H21" stroke="#000000" stroke-width="1.5"></path><path d="M21 7.5H3" stroke="#000000" stroke-width="1.5"></path><path d="M12 21V3" stroke="#000000" stroke-width="1.5"></path></svg>
82
+ </button>
83
+ <button id="layout-tables" class="icon-button" type="button" aria-label="Layout tables" title="Layout tables (L)" aria-expanded="false">
84
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M21 3.6V12H12V3H20.4C20.7314 3 21 3.26863 21 3.6Z" stroke="#000000" stroke-width="1.5"></path><path d="M21 20.4V12H12V21H20.4C20.7314 21 21 20.7314 21 20.4Z" stroke="#000000" stroke-width="1.5"></path><path d="M3 12V3.6C3 3.26863 3.26863 3 3.6 3H12V12H3Z" stroke="#000000" stroke-width="1.5"></path><path d="M3 12V20.4C3 20.7314 3.26863 21 3.6 21H12V12H3Z" stroke="#000000" stroke-width="1.5"></path></svg>
85
+ </button>
86
+ <button id="add-note" class="icon-button" type="button" aria-label="Add note" title="Add note (N)">
87
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M12 8L12 16M12 8H8M12 8H16" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M21 13.5V19C21 20.1046 20.1046 21 19 21H5C3.89543 21 3 20.1046 3 19V13.5M21 10.5V5C21 3.89543 20.1046 3 19 3H5C3.89543 3 3 3.89543 3 5V10.5" stroke="#000000" stroke-width="1.5" stroke-linejoin="round"></path><path d="M19.5 13.5V10.5H22.5V13.5H19.5Z" stroke="#000000" stroke-width="1.5" stroke-linejoin="round"></path><path d="M1.5 13.5V10.5H4.5V13.5H1.5Z" stroke="#000000" stroke-width="1.5" stroke-linejoin="round"></path></svg>
88
+ </button>
89
+ <button id="add-arrow" class="icon-button" type="button" aria-label="Arrow tool" title="Arrow tool (A) · Double-click an arrow to edit endpoints · Hold Ctrl for a free endpoint" aria-pressed="false">
90
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M6.00005 19L19 5.99996M19 5.99996V18.48M19 5.99996H6.52005" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
91
+ </button>
92
+ </div>
93
+ <div class="canvas-footer">
94
+ <a class="canvas-brand" href="https://github.com/FASCINATION-works/CanvasERD" target="_blank" rel="noopener noreferrer">CanvasERD</a>
95
+ <div class="canvas-controls" aria-label="View controls">
96
+ <span class="button-group">
97
+ <button id="zoom-out" type="button" aria-label="Zoom out" title="Zoom out (−)">−</button>
98
+ <button id="zoom-in" type="button" aria-label="Zoom in" title="Zoom in (+)">+</button>
99
+ </span>
100
+ <button id="reset-view" class="icon-button" type="button" aria-label="Fit diagram" title="Fit diagram (0)">
101
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none" color="#000000" aria-hidden="true" focusable="false"><path d="M9 9L4 4M4 4V8M4 4H8" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M15 9L20 4M20 4V8M20 4H16" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9 15L4 20M4 20V16M4 20H8" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M15 15L20 20M20 20V16M20 20H16" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
102
+ </button>
103
+ <button id="shortcut-help" class="icon-button" type="button" aria-label="Keyboard shortcuts" title="Keyboard shortcuts (?)" aria-expanded="false">
104
+ <svg width="24px" height="24px" viewBox="0 0 24 24" fill="none"color="#000000" aria-hidden="true" focusable="false"><path d="M12 22C17.5228 22 22 17.5228 22 12C22 6.47715 17.5228 2 12 2C6.47715 2 2 6.47715 2 12C2 17.5228 6.47715 22 12 22Z" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M9 9C9 5.49997 14.5 5.5 14.5 9C14.5 11.5 12 10.9999 12 13.9999" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path><path d="M12 18.01L12.01 17.9989" stroke="#000000" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"></path></svg>
105
+ </button>
106
+ </div>
107
+ </div>
108
+ <div id="loading" class="loading">Building diagram…</div>
109
+ </section>
110
+ </main>
111
+ </div>
112
+ <script src="/assets/elk-api.js" defer></script>
113
+ <script src="/assets/layout.js" defer></script>
114
+ <script src="/assets/fabric.min.js" defer></script>
115
+ <script src="/assets/document.js" defer></script>
116
+ <script src="/assets/png.js" defer></script>
117
+ <script src="/assets/app.js" defer></script>
118
+ </body>
119
+ </html>
@@ -0,0 +1,74 @@
1
+ (function (root, factory) {
2
+ const api = factory();
3
+ if (typeof module === "object" && module.exports) module.exports = api;
4
+ else root.CanvasERDLayout = api;
5
+ })(typeof globalThis === "object" ? globalThis : this, function () {
6
+ "use strict";
7
+
8
+ const MARGIN = 48;
9
+ const LAYOUT_OPTIONS = Object.freeze({
10
+ "elk.algorithm": "layered",
11
+ "elk.direction": "DOWN",
12
+ "elk.padding": `[top=${MARGIN},left=${MARGIN},bottom=${MARGIN},right=${MARGIN}]`,
13
+ "spacing.baseValue": 40,
14
+ "elk.layered.nodePlacement.bk.fixedAlignment": "NONE",
15
+ "elk.layered.unnecessaryBendpoints": true,
16
+ "elk.layered.wrapping.multiEdge.improveCuts": true,
17
+ "elk.layered.wrapping.multiEdge.improveWrappedEdges": true,
18
+ "elk.layered.edgeRouting.selfLoopDistribution": "EQUALLY"
19
+ });
20
+
21
+ function graphFor(schema, dimensions) {
22
+ const entityIds = new Set(schema.entities.map((entity) => entity.id));
23
+ return {
24
+ id: "root",
25
+ layoutOptions: { ...LAYOUT_OPTIONS },
26
+ children: schema.entities.map((entity) => ({
27
+ id: entity.id,
28
+ width: dimensions.width(entity),
29
+ height: dimensions.height(entity)
30
+ })),
31
+ edges: schema.relationships
32
+ .filter((relationship) => (
33
+ entityIds.has(relationship.source_id) && entityIds.has(relationship.destination_id)
34
+ ))
35
+ .map((relationship, index) => ({
36
+ id: `relationship-${index}`,
37
+ sources: [relationship.source_id],
38
+ targets: [relationship.destination_id]
39
+ }))
40
+ };
41
+ }
42
+
43
+ function schemaForEntityIds(schema, entityIds) {
44
+ const included = new Set(entityIds);
45
+ return {
46
+ ...schema,
47
+ entities: schema.entities.filter((entity) => included.has(entity.id)),
48
+ relationships: schema.relationships.filter((relationship) => (
49
+ included.has(relationship.source_id) && included.has(relationship.destination_id)
50
+ ))
51
+ };
52
+ }
53
+
54
+ function positionsFrom(graph) {
55
+ const children = graph.children || [];
56
+ if (children.length === 0) return {};
57
+ if (children.some((child) => !Number.isFinite(child.x) || !Number.isFinite(child.y))) {
58
+ throw new Error("ELK returned a node without a position");
59
+ }
60
+
61
+ const minimumX = Math.min(...children.map((child) => child.x));
62
+ const minimumY = Math.min(...children.map((child) => child.y));
63
+ return Object.fromEntries(children.map((child) => [
64
+ child.id,
65
+ { x: child.x - minimumX + MARGIN, y: child.y - minimumY + MARGIN }
66
+ ]));
67
+ }
68
+
69
+ async function layoutEntities(schema, engine, dimensions) {
70
+ return positionsFrom(await engine.layout(graphFor(schema, dimensions)));
71
+ }
72
+
73
+ return { MARGIN, LAYOUT_OPTIONS, graphFor, schemaForEntityIds, positionsFrom, layoutEntities };
74
+ });