-
-
- {{ loadingText }}
-
-
- {{ t("diagram.selectTarget") }}
-
-
- {{ t("diagram.empty") }}
-
-
- {{ t("diagram.noMatches") }}
-
-
-
-
+
+
+
+ {{ loadingText }}
+
+
+ {{ t("diagram.selectTarget") }}
+
+
+ {{ t("diagram.noMatches") }}
+
+
+
+
{{ t("diagram.emptyDesignHint") }}
+
+
{{ t("diagram.createTableNotSupported") }}
+
+
-
-
-
-
-
-
-
{{ table.name }}
-
{{ table.columns.length }}
-
-
-
-
-
-
-
- {{ column.name }}
- {{ column.data_type }}
-
-
- {{ t("diagram.moreColumns", { count: hiddenColumnCount(table) }) }}
-
-
-
-
-
-
-
+
+
+
+
+
-
-
- {{ attribute.label }}
-
-
-
-
-
{{ relationship.label }}
-
-
-
- {{ entity.name }}
-
-
+
+
+
+
+
+ {{ attribute.label }}
+
+
+
+
{{ relationship.label }}
+
+
+ {{ entity.name }}
+
+
+
+
+
+
+
+
+
{{ t("diagram.modelRelationships") }}
+
+
+
+
+
+
+
+ {{ t("diagram.noInferred") }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ relationship.sourceTable }}.{{ relationship.sourceColumn }} {{ relationship.sourceCardinality }}:{{ relationship.targetCardinality }} {{ relationship.targetTable }}.{{ relationship.targetColumn }}
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/diagram/TableNode.vue b/apps/desktop/src/components/diagram/TableNode.vue
new file mode 100644
index 000000000..a29a6e322
--- /dev/null
+++ b/apps/desktop/src/components/diagram/TableNode.vue
@@ -0,0 +1,94 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ truncateLabel(data.table.name, TABLE_NAME_MAX_CHARS) }}
+
+
Draft
+
{{ visibleColumns(data.table).length }}
+
+
+
+
+
+
+
+
+ {{ truncateLabel(column.name, COLUMN_NAME_MAX_CHARS) }}
+
+
+ {{ truncateLabel(column.data_type, COLUMN_TYPE_MAX_CHARS) }}
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/diagram/ZoomControls.vue b/apps/desktop/src/components/diagram/ZoomControls.vue
new file mode 100644
index 000000000..0a2fe4399
--- /dev/null
+++ b/apps/desktop/src/components/diagram/ZoomControls.vue
@@ -0,0 +1,62 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/desktop/src/components/diagram/__tests__/DiagramSyncDialog.spec.ts b/apps/desktop/src/components/diagram/__tests__/DiagramSyncDialog.spec.ts
new file mode 100644
index 000000000..0817517c5
--- /dev/null
+++ b/apps/desktop/src/components/diagram/__tests__/DiagramSyncDialog.spec.ts
@@ -0,0 +1,118 @@
+// @vitest-environment happy-dom
+
+import { createApp, defineComponent, h, nextTick, ref, type App } from "vue";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import type { DiagramTable } from "@/lib/diagram/erDiagram";
+
+const mocks = vi.hoisted(() => ({
+ buildDropTableSql: vi.fn(async (options: { schema?: string; tableName: string; cascade?: boolean }) => {
+ const qualifiedName = options.schema ? `"${options.schema}"."${options.tableName}"` : `"${options.tableName}"`;
+ return `DROP TABLE ${qualifiedName}${options.cascade ? " CASCADE" : ""};`;
+ }),
+}));
+
+vi.mock("vue-i18n", () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}));
+
+vi.mock("@/components/ui/dialog", async () => {
+ const { defineComponent, h } = await import("vue");
+ const passthrough = defineComponent({
+ setup(_props, { slots }) {
+ return () => h("div", slots.default?.());
+ },
+ });
+ return {
+ Dialog: passthrough,
+ DialogContent: passthrough,
+ DialogFooter: passthrough,
+ DialogHeader: passthrough,
+ DialogTitle: passthrough,
+ };
+});
+
+vi.mock("@/components/ui/button", async () => {
+ const { defineComponent, h } = await import("vue");
+ return {
+ Button: defineComponent({
+ inheritAttrs: false,
+ setup(_props, { attrs, slots }) {
+ return () => h("button", attrs, slots.default?.());
+ },
+ }),
+ };
+});
+
+vi.mock("@/lib/backend/api", () => ({
+ buildCreateTableSql: vi.fn(),
+ buildTableStructureChangeSql: vi.fn(),
+ executeBatch: vi.fn(),
+}));
+
+vi.mock("@/lib/database/dbAdminSql", () => ({
+ buildDropTableSql: mocks.buildDropTableSql,
+ supportsDropTableCascade: vi.fn(() => true),
+}));
+
+import DiagramSyncDialog from "../DiagramSyncDialog.vue";
+
+const mountedApps: Array<{ app: App; host: HTMLElement }> = [];
+
+afterEach(() => {
+ for (const { app, host } of mountedApps.splice(0)) {
+ app.unmount();
+ host.remove();
+ }
+ mocks.buildDropTableSql.mockClear();
+});
+
+async function flushAsyncUpdates() {
+ await nextTick();
+ await Promise.resolve();
+ await nextTick();
+}
+
+describe("DiagramSyncDialog", () => {
+ it("previews live table deletion without silently enabling CASCADE", async () => {
+ const liveTable: DiagramTable = {
+ name: "orders",
+ columns: [],
+ foreignKeys: [],
+ origin: "live",
+ pendingDrop: true,
+ };
+ const open = ref(false);
+ const host = document.createElement("div");
+ document.body.append(host);
+ const app = createApp(
+ defineComponent({
+ setup() {
+ return () =>
+ h(DiagramSyncDialog, {
+ open: open.value,
+ tables: [liveTable],
+ connectionId: "connection-1",
+ database: "app",
+ schema: "public",
+ databaseType: "postgres",
+ "onUpdate:open": (value: boolean) => (open.value = value),
+ });
+ },
+ }),
+ );
+ app.mount(host);
+ mountedApps.push({ app, host });
+
+ open.value = true;
+ await flushAsyncUpdates();
+
+ expect(mocks.buildDropTableSql).toHaveBeenCalledWith({
+ databaseType: "postgres",
+ schema: "public",
+ tableName: "orders",
+ cascade: false,
+ });
+ expect(host.textContent).toContain('DROP TABLE "public"."orders";');
+ expect(host.textContent).not.toContain("CASCADE");
+ });
+});
diff --git a/apps/desktop/src/components/diagram/__tests__/ZoomControls.spec.ts b/apps/desktop/src/components/diagram/__tests__/ZoomControls.spec.ts
new file mode 100644
index 000000000..6b717d209
--- /dev/null
+++ b/apps/desktop/src/components/diagram/__tests__/ZoomControls.spec.ts
@@ -0,0 +1,73 @@
+// @vitest-environment happy-dom
+
+import { createApp, nextTick, type App } from "vue";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import ZoomControls from "@/components/diagram/ZoomControls.vue";
+
+const vueFlowMock = vi.hoisted(() => ({
+ zoomIn: vi.fn(),
+ zoomOut: vi.fn(),
+ fitView: vi.fn(async () => {}),
+}));
+
+vi.mock("@vue-flow/core", () => ({
+ useVueFlow: () => vueFlowMock,
+}));
+
+vi.mock("vue-i18n", () => ({
+ useI18n: () => ({ t: (key: string) => key }),
+}));
+
+const mountedApps: App[] = [];
+
+function mountZoom(props: { canUndo?: boolean; canRedo?: boolean } = {}) {
+ const emits: string[] = [];
+ const container = document.createElement("div");
+ document.body.append(container);
+ const app = createApp(ZoomControls, {
+ canUndo: props.canUndo ?? true,
+ canRedo: props.canRedo ?? true,
+ onUndo: () => emits.push("undo"),
+ onRedo: () => emits.push("redo"),
+ });
+ mountedApps.push(app);
+ app.mount(container);
+ return { container, emits };
+}
+
+afterEach(() => {
+ for (const app of mountedApps.splice(0)) app.unmount();
+ document.body.innerHTML = "";
+ vueFlowMock.zoomIn.mockClear();
+ vueFlowMock.zoomOut.mockClear();
+ vueFlowMock.fitView.mockClear();
+});
+
+describe("ZoomControls", () => {
+ it("emits undo/redo and calls vue-flow zoom helpers", async () => {
+ const { container, emits } = mountZoom();
+ await nextTick();
+ const buttons = container.querySelectorAll("button");
+ expect(buttons.length).toBeGreaterThanOrEqual(6);
+
+ buttons[0].dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ buttons[1].dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ buttons[2].dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ buttons[3].dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ buttons[4].dispatchEvent(new MouseEvent("click", { bubbles: true }));
+ buttons[5].dispatchEvent(new MouseEvent("click", { bubbles: true }));
+
+ expect(emits).toEqual(["undo", "redo"]);
+ expect(vueFlowMock.zoomIn).toHaveBeenCalled();
+ expect(vueFlowMock.zoomOut).toHaveBeenCalled();
+ expect(vueFlowMock.fitView).toHaveBeenCalledTimes(2);
+ });
+
+ it("disables undo/redo when props are false", async () => {
+ const { container } = mountZoom({ canUndo: false, canRedo: false });
+ await nextTick();
+ const buttons = container.querySelectorAll("button");
+ expect((buttons[0] as HTMLButtonElement).disabled).toBe(true);
+ expect((buttons[1] as HTMLButtonElement).disabled).toBe(true);
+ });
+});
diff --git a/apps/desktop/src/i18n/locales/en.ts b/apps/desktop/src/i18n/locales/en.ts
index 69731b744..36794615f 100644
--- a/apps/desktop/src/i18n/locales/en.ts
+++ b/apps/desktop/src/i18n/locales/en.ts
@@ -2878,6 +2878,7 @@ export default {
selectSchema: "Select schema",
searchTables: "Search tables, columns, keys...",
refresh: "Refresh diagram",
+ refreshConfirm: "Refreshing reloads table metadata and resets the canvas layout and undo history. Saved modeled relationships are kept. Continue?",
loading: "Reading relationships...",
loadingProgress: "Reading relationships... {loaded}/{total}",
partialError: "Skipped metadata for {count} tables that failed to load",
@@ -2889,6 +2890,10 @@ export default {
tableMode: "Table View",
engineeringMode: "Engineering ER",
modelRelationships: "Model Relationships",
+ layers: "Layers",
+ autoMatch: "Auto Match",
+ autoLayout: "Auto Layout",
+ matchRelationshipsCount: "{count} matches",
copyJoinSql: "Copy SQL",
customRelationshipsCount: "{count} modeled",
relationshipName: "Name",
@@ -2898,10 +2903,15 @@ export default {
targetTable: "Target table",
targetColumn: "Target column",
cardinality: "Cardinality",
- cardinalityOneToMany: "1:N",
- cardinalityManyToOne: "N:1",
- cardinalityManyToMany: "N:N",
- cardinalityOneToOne: "1:1",
+ cardinalityOneToMany: "Source 1 · Target N",
+ cardinalityManyToOne: "Source N · Target 1",
+ cardinalityOneToOne: "Source 1 · Target 1",
+ cardinalityManyToMany: "Source N · Target N",
+ cardinalityDirected: "Source {source} · Target {target}",
+ matchPickCardinality: "Confirm a match after choosing cardinality (1:1 / 1:N / N:1 / N:N).",
+ manualAddRelationship: "Manual add",
+ confirmMatch: "Confirm",
+ ignoreMatch: "Ignore",
addRelationship: "Add relationship",
removeRelationship: "Remove relationship",
relationshipIncomplete: "Select both source and target columns",
@@ -2913,11 +2923,125 @@ export default {
relatedTables: "Related Tables",
moreColumns: "+ {count} columns",
exportSvg: "Export SVG",
+ exportPng: "Export PNG",
+ exportJson: "Export JSON",
+ exportDbml: "Export DBML",
+ exportMermaid: "Export Mermaid",
+ export: "Export",
exportedSvg: "SVG exported",
+ exportedFormat: "{format} exported",
exportSvgFailed: "Failed to export SVG: {message}",
+ exportFailed: "Failed to export {format}: {message}",
zoomIn: "Zoom in",
zoomOut: "Zoom out",
+ fitView: "Fit view",
+ resetZoom: "Reset zoom",
+ undo: "Undo",
+ redo: "Redo",
resetLayout: "Reset layout",
+ fullscreen: "Fullscreen",
+ exitFullscreen: "Exit fullscreen",
+ addLayer: "Add Layer",
+ noLayersYet: "No layers yet. Click + to create one.",
+ layoutFree: "Free",
+ layoutAuto: "Auto",
+ layerLock: "Lock layer (skip auto-layout inside)",
+ layerUnlock: "Unlock layer (allow auto-layout inside)",
+ layerLocked: "Locked",
+ layerUnlocked: "Unlocked",
+ hideLayer: "Hide layer",
+ showLayer: "Show layer",
+ deleteLayer: "Delete layer",
+ layerHiddenCannotFocus: "Layer is hidden — show it to focus on the canvas",
+ createTable: "New table",
+ createTableNotSupported: "Creating tables is not supported for this database",
+ create: "Create",
+ syncToDatabase: "Sync to database",
+ structureSyncNotSupported: "Table structure sync is not supported for this database",
+ syncDraftCount: "{count} change(s) to sync (create / alter / drop)",
+ executeSync: "Execute",
+ syncing: "Syncing…",
+ buildingSql: "Building SQL…",
+ noSqlYet: "No SQL yet",
+ copySql: "Copy SQL",
+ tableName: "Table name",
+ tableNameRequired: "Table name is required",
+ tableNameExists: "A table with this name already exists",
+ assignLayer: "Layer",
+ noLayer: "No layer",
+ withDefaultIdPk: "Include default id primary key",
+ inspectorTable: "Table",
+ inspectorRelationship: "Relationship",
+ fieldComment: "Comment",
+ fields: "Fields",
+ tabFields: "Fields",
+ tabIndexes: "Indexes",
+ addIndex: "Add index",
+ indexName: "Index name",
+ indexUnique: "UNIQUE",
+ indexColumns: "Columns",
+ noIndexesYet: "No indexes yet",
+ indexesNotSupported: "Creating indexes is not supported for this database from the diagram editor",
+ addColumnNotSupported: "Adding columns is not supported for this database",
+ dropColumnNotSupported: "Dropping columns is not supported for this database",
+ dropTableNotSupported: "Dropping tables is not supported for this database from the diagram editor",
+ addField: "Add field",
+ deleteField: "Delete field",
+ undoDropField: "Undo drop field",
+ noFieldsYet: "No fields yet — add one to sync",
+ deleteDraftTable: "Delete draft table",
+ deleteLiveTable: "Delete table",
+ deleteLiveTableHint: "Marks the table for DROP TABLE on the next sync. Undo via history if needed.",
+ deleteLiveTableConfirm: "Mark this table for DROP TABLE when you sync to the database?",
+ liveTableReadOnly: "Live table — edit structure in the table editor",
+ liveTableAddColumnsHint: "Add columns (ALTER ADD) or mark existing ones for drop (ALTER DROP), then sync. Editing existing column types remains in the table structure editor.",
+ liveTableIndexesReadOnly: "Edit indexes for live tables in the table structure editor",
+ pendingColumnsBadge: "Pending changes",
+ pendingDropColumnBadge: "Drop",
+ pendingDropTableBadge: "Drop table",
+ source: "Source",
+ target: "Target",
+ emptyDesignHint: "No tables yet. Create a layer and tables, edit fields in the inspector, then sync to the database.",
+ removeFromLayer: "Remove from layer",
+ addTables: "Add table(s)",
+ tabSelectExisting: "Select existing",
+ tabCreateTable: "New table",
+ multiSelect: "Multi select",
+ closeMultiSelect: "Close",
+ selectAll: "Select all",
+ clearSelection: "Clear",
+ tablesInOtherLayers: "Tables in other layers",
+ filterTables: "Filter tables...",
+ noMatchingTables: "No matching tables",
+ addSelectedCount: "Add {count} selected",
+ layerNameTooLong: "Layer name must be less than 50 characters",
+ layerNameInvalid: "Layer name may include letters, numbers, Chinese, spaces, underscores, hyphens, and periods",
+ layerNameExists: "Layer name already exists",
+ total: "total",
+ confirmed: "confirmed",
+ pending: "pending",
+ conflicts: "conflicts",
+ conflict: "Conflict",
+ ignored: "ignored",
+ confirmAll: "Confirm all",
+ ignoreAll: "Ignore all",
+ clearAll: "Clear all",
+ confidenceHigh: "High",
+ confidenceMedium: "Medium",
+ confidenceLow: "Low",
+ noInferred: "No inferred relationships",
+ editRelationship: "Edit",
+ saveRelationship: "Save",
+ deleteRelationship: "Delete",
+ confirmDeleteRelationship: "Delete this relationship?",
+ confirmDeleteRelationshipAction: "Delete",
+ confirmRelationship: "Confirm",
+ ignoreRelationship: "Ignore",
+ relationshipKindFk: "Foreign key",
+ relationshipKindCustom: "Custom",
+ relationshipKindInferred: "Inferred",
+ relationshipReadOnlyFk: "This relationship comes from a database foreign key and cannot be edited here.",
+ relationshipUpdated: "Relationship updated",
},
etcd: {
prefixPlaceholder: "Search Key path, e.g. /app/ or service",
diff --git a/apps/desktop/src/i18n/locales/es.ts b/apps/desktop/src/i18n/locales/es.ts
index 8c1531c86..fc8b6986f 100644
--- a/apps/desktop/src/i18n/locales/es.ts
+++ b/apps/desktop/src/i18n/locales/es.ts
@@ -2758,10 +2758,10 @@ export default withEnglishFallback({
targetTable: "Tabla de destino",
targetColumn: "Columna de destino",
cardinality: "Cardinalidad",
- cardinalityOneToMany: "1:N",
- cardinalityManyToOne: "N:1",
- cardinalityManyToMany: "N:N",
- cardinalityOneToOne: "1:1",
+ cardinalityOneToMany: "Origen 1 · Destino N",
+ cardinalityManyToOne: "Origen N · Destino 1",
+ cardinalityManyToMany: "Origen N · Destino N",
+ cardinalityOneToOne: "Origen 1 · Destino 1",
addRelationship: "Agregar relación",
removeRelationship: "Eliminar relación",
relationshipIncomplete: "Selecciona ambas columnas de origen y destino",
@@ -2769,6 +2769,130 @@ export default withEnglishFallback({
relationshipExists: "Esta relación ya existe",
relationshipAdded: "Relación agregada",
noJoinSql: "No hay SQL de relación para copiar",
+ refreshConfirm: "Al actualizar se recargará la estructura de las tablas y se restablecerá el diseño del lienzo y el historial de deshacer. Las relaciones de modelado guardadas localmente no se verán afectadas. ¿Desea continuar?",
+ layers: "Capas",
+ autoMatch: "Coincidencia automática",
+ autoLayout: "Diseño automático",
+ matchRelationshipsCount: "{count} coincidencias",
+ cardinalityDirected: "Origen {source} · Destino {target}",
+ matchPickCardinality: "Antes de confirmar la coincidencia, seleccione la cardinalidad (1:1 / 1:N / N:1 / N:N).",
+ manualAddRelationship: "Añadir manualmente",
+ confirmMatch: "Confirmar",
+ ignoreMatch: "Ignorar",
+ exportPng: "Exportar PNG",
+ exportJson: "Exportar JSON",
+ exportDbml: "Exportar DBML",
+ exportMermaid: "Exportar Mermaid",
+ export: "Exportar",
+ exportedFormat: "{format} exportado",
+ exportFailed: "Error al exportar {format}: {message}",
+ fitView: "Ajustar al lienzo",
+ resetZoom: "Restablecer zoom",
+ undo: "Deshacer",
+ redo: "Rehacer",
+ fullscreen: "Pantalla completa",
+ exitFullscreen: "Salir de pantalla completa",
+ addLayer: "Añadir capa",
+ noLayersYet: "Aún no hay capas, haga clic en + para crear",
+ layoutFree: "Libre",
+ layoutAuto: "Automático",
+ layerLock: "Bloquear capa (sin diseño automático dentro de la capa)",
+ layerUnlock: "Desbloquear capa (permitir diseño automático dentro de la capa)",
+ layerLocked: "Bloqueada",
+ layerUnlocked: "Desbloqueada",
+ hideLayer: "Ocultar capa",
+ showLayer: "Mostrar capa",
+ deleteLayer: "Eliminar capa",
+ layerHiddenCannotFocus: "La capa está oculta. Muéstrela primero para ubicarla en el lienzo.",
+ createTable: "Crear tabla",
+ create: "Crear",
+ syncToDatabase: "Sincronizar con la base de datos",
+ syncDraftCount: "Se sincronizarán {count} cambios (crear/modificar/eliminar tablas)",
+ executeSync: "Ejecutar",
+ syncing: "Sincronizando…",
+ buildingSql: "Generando SQL…",
+ noSqlYet: "Aún no hay SQL",
+ copySql: "Copiar SQL",
+ tableName: "Nombre de la tabla",
+ tableNameRequired: "Por favor, ingrese el nombre de la tabla",
+ tableNameExists: "Ya existe una tabla con el mismo nombre",
+ assignLayer: "Capa asignada",
+ noLayer: "Sin capa",
+ withDefaultIdPk: "Incluir clave primaria id predeterminada",
+ inspectorTable: "Tabla",
+ inspectorRelationship: "Relación",
+ fieldComment: "Comentario",
+ fields: "Campos",
+ tabFields: "Campos",
+ tabIndexes: "Índices",
+ addIndex: "Agregar índice",
+ indexName: "Nombre del índice",
+ indexUnique: "UNIQUE",
+ indexColumns: "Columnas",
+ noIndexesYet: "Aún no hay índices",
+ indexesNotSupported: "La base de datos actual no admite la creación de índices en el editor de diagramas",
+ addField: "Agregar campo",
+ deleteField: "Eliminar campo",
+ undoDropField: "Deshacer eliminación de campo",
+ noFieldsYet: "Aún no hay campos. Agregue uno antes de sincronizar",
+ deleteDraftTable: "Eliminar tabla de borrador",
+ deleteLiveTable: "Eliminar tabla",
+ deleteLiveTableHint: "Marcar como pendiente de eliminación. La próxima vez que se sincronice con la base de datos se ejecutará DROP TABLE. Puede deshacer para restaurar.",
+ deleteLiveTableConfirm: "¿Confirmar que desea marcar esta tabla para eliminación? Al sincronizar se ejecutará DROP TABLE.",
+ liveTableReadOnly: "Tabla en la base de datos — Modifíquela en el editor de estructura de tabla",
+ liveTableAddColumnsHint: "Puede agregar campos (ALTER ADD) o marcar campos existentes para eliminar (ALTER DROP) y luego sincronizar. Para cambios como tipos, use el editor de estructura de tabla.",
+ liveTableIndexesReadOnly: "Los índices de tablas en la base de datos deben modificarse en el editor de estructura de tabla",
+ pendingColumnsBadge: "Cambios pendientes",
+ pendingDropColumnBadge: "Pendiente de eliminar",
+ pendingDropTableBadge: "Tabla pendiente de eliminar",
+ source: "Origen",
+ target: "Destino",
+ emptyDesignHint: "Aún no hay tablas. Puede crear capas y tablas, editar campos en el panel de propiedades y sincronizar con la base de datos al finalizar.",
+ removeFromLayer: "Quitar de la capa",
+ addTables: "Agregar tablas",
+ tabSelectExisting: "Seleccionar existente",
+ tabCreateTable: "Crear tabla",
+ multiSelect: "Selección múltiple",
+ closeMultiSelect: "Cerrar",
+ selectAll: "Seleccionar todo",
+ clearSelection: "Limpiar",
+ tablesInOtherLayers: "Tablas en otras capas",
+ filterTables: "Filtrar tablas...",
+ noMatchingTables: "No hay tablas coincidentes",
+ addSelectedCount: "Agregar {count} elementos seleccionados",
+ layerNameTooLong: "El nombre de la capa no puede superar los 50 caracteres",
+ layerNameInvalid: "El nombre de la capa puede contener caracteres chinos, letras, números, espacios, guiones bajos, guiones y puntos",
+ layerNameExists: "El nombre de la capa ya existe",
+ total: "Total",
+ confirmed: "Confirmado",
+ pending: "Pendiente",
+ conflicts: "Conflictos",
+ conflict: "Conflicto",
+ ignored: "Ignorado",
+ confirmAll: "Confirmar todo",
+ ignoreAll: "Ignorar todo",
+ clearAll: "Limpiar todo",
+ confidenceHigh: "Alta",
+ confidenceMedium: "Media",
+ confidenceLow: "Baja",
+ noInferred: "No hay relaciones inferidas",
+ editRelationship: "Editar",
+ saveRelationship: "Guardar",
+ deleteRelationship: "Eliminar",
+ confirmDeleteRelationship: "¿Confirmar eliminación de esta relación?",
+ confirmDeleteRelationshipAction: "Confirmar eliminación",
+ confirmRelationship: "Confirmar",
+ ignoreRelationship: "Ignorar",
+ relationshipKindFk: "Clave externa",
+ relationshipKindCustom: "Personalizada",
+ relationshipKindInferred: "Inferida",
+ relationshipReadOnlyFk: "Esta relación proviene de una restricción de clave externa de la base de datos y no se puede modificar aquí.",
+ relationshipUpdated: "Relación actualizada",
+ createTableNotSupported: "La base de datos actual no admite la creación de tablas",
+ structureSyncNotSupported: "La base de datos actual no admite sincronizar la estructura de tablas desde el diagrama",
+ addColumnNotSupported: "La base de datos actual no admite agregar columnas",
+ dropColumnNotSupported: "La base de datos actual no admite eliminar columnas",
+ dropTableNotSupported: "La base de datos actual no admite eliminar tablas desde el diagrama",
},
etcd: {
prefixPlaceholder: "Buscar ruta de Key, p. ej. /app/ o servicio",
diff --git a/apps/desktop/src/i18n/locales/it.ts b/apps/desktop/src/i18n/locales/it.ts
index aad9b4cee..73ee77146 100644
--- a/apps/desktop/src/i18n/locales/it.ts
+++ b/apps/desktop/src/i18n/locales/it.ts
@@ -2747,10 +2747,10 @@ export default withEnglishFallback({
targetTable: "Tabella destinazione",
targetColumn: "Colonna destinazione",
cardinality: "Cardinalita",
- cardinalityOneToMany: "1:N",
- cardinalityManyToOne: "N:1",
- cardinalityManyToMany: "N:N",
- cardinalityOneToOne: "1:1",
+ cardinalityOneToMany: "Origine 1 · Destinazione N",
+ cardinalityManyToOne: "Origine N · Destinazione 1",
+ cardinalityManyToMany: "Origine N · Destinazione N",
+ cardinalityOneToOne: "Origine 1 · Destinazione 1",
addRelationship: "Aggiungi relazione",
removeRelationship: "Rimuovi relazione",
relationshipIncomplete: "Seleziona sia la colonna sorgente che quella di destinazione",
@@ -2767,6 +2767,130 @@ export default withEnglishFallback({
zoomIn: "Ingrandisci",
zoomOut: "Rimpicciolisci",
resetLayout: "Reimposta layout",
+ refreshConfirm: "L'aggiornamento ricaricherà la struttura delle tabelle e reimposterà il layout della tela e la cronologia delle azioni annullate; le relazioni di modellazione salvate localmente non saranno influenzate. Continuare?",
+ layers: "Livelli",
+ autoMatch: "Corrispondenza automatica",
+ autoLayout: "Layout automatico",
+ matchRelationshipsCount: "{count} corrispondenze",
+ cardinalityDirected: "Origine {source} · Destinazione {target}",
+ matchPickCardinality: "Prima di confermare la corrispondenza, selezionare la cardinalità (1:1 / 1:N / N:1 / N:N).",
+ manualAddRelationship: "Aggiunta manuale",
+ confirmMatch: "Conferma",
+ ignoreMatch: "Ignora",
+ exportPng: "Esporta PNG",
+ exportJson: "Esporta JSON",
+ exportDbml: "Esporta DBML",
+ exportMermaid: "Esporta Mermaid",
+ export: "Esporta",
+ exportedFormat: "{format} esportato",
+ exportFailed: "Esportazione di {format} non riuscita: {message}",
+ fitView: "Adatta alla tela",
+ resetZoom: "Reimposta zoom",
+ undo: "Annulla",
+ redo: "Ripeti",
+ fullscreen: "Schermo intero",
+ exitFullscreen: "Esci da schermo intero",
+ addLayer: "Aggiungi livello",
+ noLayersYet: "Nessun livello ancora, fare clic su + per crearne uno",
+ layoutFree: "Libera",
+ layoutAuto: "Auto",
+ layerLock: "Blocca livello (nessun layout automatico all'interno del livello)",
+ layerUnlock: "Sblocca livello (consenti layout automatico all'interno del livello)",
+ layerLocked: "Bloccato",
+ layerUnlocked: "Sbloccato",
+ hideLayer: "Nascondi livello",
+ showLayer: "Mostra livello",
+ deleteLayer: "Elimina livello",
+ layerHiddenCannotFocus: "Il livello è nascosto; mostrarlo prima di centrarlo sulla tela.",
+ createTable: "Crea tabella",
+ create: "Crea",
+ syncToDatabase: "Sincronizza con il database",
+ syncDraftCount: "Verranno sincronizzate {count} modifiche (nuove / modifica tabella / eliminazione tabella)",
+ executeSync: "Esegui",
+ syncing: "Sincronizzazione in corso…",
+ buildingSql: "Generazione SQL in corso…",
+ noSqlYet: "Nessun SQL al momento",
+ copySql: "Copia SQL",
+ tableName: "Nome tabella",
+ tableNameRequired: "Inserire il nome della tabella",
+ tableNameExists: "Esiste già una tabella con lo stesso nome",
+ assignLayer: "Livello di appartenenza",
+ noLayer: "Nessun livello",
+ withDefaultIdPk: "Includi chiave primaria id predefinita",
+ inspectorTable: "Tabella",
+ inspectorRelationship: "Relazione",
+ fieldComment: "Commento",
+ fields: "Campi",
+ tabFields: "Campi",
+ tabIndexes: "Indici",
+ addIndex: "Aggiungi indice",
+ indexName: "Nome indice",
+ indexUnique: "UNIQUE",
+ indexColumns: "Colonne",
+ noIndexesYet: "Nessun indice ancora",
+ indexesNotSupported: "Il database corrente non supporta la creazione di indici nell'editor di diagrammi",
+ addField: "Aggiungi campo",
+ deleteField: "Elimina campo",
+ undoDropField: "Annulla eliminazione campo",
+ noFieldsYet: "Nessun campo ancora; aggiungere campi per sincronizzare",
+ deleteDraftTable: "Elimina tabella bozza",
+ deleteLiveTable: "Elimina tabella",
+ deleteLiveTableHint: "Marcata per l'eliminazione; DROP TABLE verrà eseguito alla prossima 'Sincronizzazione con il database'. È possibile annullare l'operazione.",
+ deleteLiveTableConfirm: "Confermi di marcare questa tabella per l'eliminazione? Durante la sincronizzazione verrà eseguito DROP TABLE.",
+ liveTableReadOnly: "Tabella già nel database — modificare nell'editor della struttura della tabella",
+ liveTableAddColumnsHint: "È possibile aggiungere campi (ALTER ADD) o marcare per l'eliminazione campi esistenti (ALTER DROP), quindi sincronizzare. Per modifiche come i tipi, utilizzare l'editor della struttura della tabella.",
+ liveTableIndexesReadOnly: "Per gli indici di tabelle già nel database, modificarli nell'editor della struttura della tabella.",
+ pendingColumnsBadge: "Modifiche in attesa di sincronizzazione",
+ pendingDropColumnBadge: "Da eliminare",
+ pendingDropTableBadge: "Tabella da eliminare",
+ source: "Origine",
+ target: "Destinazione",
+ emptyDesignHint: "Nessuna tabella ancora. È possibile creare livelli e tabelle, modificare i campi nel pannello delle proprietà e quindi sincronizzare con il database.",
+ removeFromLayer: "Rimuovi dal livello",
+ addTables: "Aggiungi tabelle",
+ tabSelectExisting: "Seleziona esistente",
+ tabCreateTable: "Crea tabella",
+ multiSelect: "Selezione multipla",
+ closeMultiSelect: "Chiudi",
+ selectAll: "Seleziona tutto",
+ clearSelection: "Cancella selezione",
+ tablesInOtherLayers: "Tabelle in altri livelli",
+ filterTables: "Filtra tabelle...",
+ noMatchingTables: "Nessuna tabella corrispondente",
+ addSelectedCount: "Aggiungi {count} elementi selezionati",
+ layerNameTooLong: "Il nome del livello non può superare 50 caratteri",
+ layerNameInvalid: "Il nome del livello può contenere caratteri cinesi, lettere, numeri, spazi, trattini bassi, trattini e punti",
+ layerNameExists: "Il nome del livello esiste già",
+ total: "Totale",
+ confirmed: "Confermato",
+ pending: "In attesa di conferma",
+ conflicts: "Conflitti",
+ conflict: "Conflitto",
+ ignored: "Ignorato",
+ confirmAll: "Conferma tutto",
+ ignoreAll: "Ignora tutto",
+ clearAll: "Cancella tutto",
+ confidenceHigh: "Alta",
+ confidenceMedium: "Media",
+ confidenceLow: "Bassa",
+ noInferred: "Nessuna relazione inferita",
+ editRelationship: "Modifica",
+ saveRelationship: "Salva",
+ deleteRelationship: "Elimina",
+ confirmDeleteRelationship: "Confermi l'eliminazione di questa relazione?",
+ confirmDeleteRelationshipAction: "Conferma eliminazione",
+ confirmRelationship: "Conferma",
+ ignoreRelationship: "Ignora",
+ relationshipKindFk: "Chiave esterna",
+ relationshipKindCustom: "Personalizzata",
+ relationshipKindInferred: "Inferita",
+ relationshipReadOnlyFk: "Questa relazione deriva da un vincolo di chiave esterna del database; non può essere modificata qui.",
+ relationshipUpdated: "Relazione aggiornata",
+ createTableNotSupported: "Il database corrente non supporta la creazione di nuove tabelle",
+ structureSyncNotSupported: "Il database corrente non supporta la sincronizzazione della struttura delle tabelle dal diagramma",
+ addColumnNotSupported: "Il database corrente non supporta l'aggiunta di colonne",
+ dropColumnNotSupported: "Il database corrente non supporta l'eliminazione di colonne",
+ dropTableNotSupported: "Il database corrente non supporta l'eliminazione di tabelle dal diagramma",
},
etcd: {
prefixPlaceholder: "Cerca percorso Key, ad es. /app/ o servizio",
diff --git a/apps/desktop/src/i18n/locales/ja.ts b/apps/desktop/src/i18n/locales/ja.ts
index 95e5aed52..8e8a33a80 100644
--- a/apps/desktop/src/i18n/locales/ja.ts
+++ b/apps/desktop/src/i18n/locales/ja.ts
@@ -2813,10 +2813,10 @@ export default withEnglishFallback({
targetTable: "ターゲットテーブル",
targetColumn: "ターゲット列",
cardinality: "カーディナリティ",
- cardinalityOneToMany: "1:N",
- cardinalityManyToOne: "N:1",
- cardinalityManyToMany: "N:N",
- cardinalityOneToOne: "1:1",
+ cardinalityOneToMany: "ソース 1 · ターゲット N",
+ cardinalityManyToOne: "ソース N · ターゲット 1",
+ cardinalityManyToMany: "ソース N · ターゲット N",
+ cardinalityOneToOne: "ソース 1 · ターゲット 1",
addRelationship: "関係を追加",
removeRelationship: "関係を削除",
relationshipIncomplete: "ソース列とターゲット列を選択してください",
@@ -2833,6 +2833,130 @@ export default withEnglishFallback({
zoomIn: "拡大",
zoomOut: "縮小",
resetLayout: "レイアウトをリセット",
+ refreshConfirm: "更新するとテーブル構造が再読み込みされ、キャンバスレイアウトと元に戻す履歴がリセットされます。ローカルに保存されたモデリング関係には影響しません。続行しますか?",
+ layers: "レイヤー",
+ autoMatch: "自動マッチング",
+ autoLayout: "自動レイアウト",
+ matchRelationshipsCount: "{count} 件の一致",
+ cardinalityDirected: "ソース {source} · ターゲット {target}",
+ matchPickCardinality: "一致を確認する前に基数(1:1 / 1:N / N:1 / N:N)を選択してください。",
+ manualAddRelationship: "手動追加",
+ confirmMatch: "確認",
+ ignoreMatch: "無視",
+ exportPng: "PNG エクスポート",
+ exportJson: "JSON エクスポート",
+ exportDbml: "DBML エクスポート",
+ exportMermaid: "Mermaid エクスポート",
+ export: "エクスポート",
+ exportedFormat: "{format} をエクスポートしました",
+ exportFailed: "{format} のエクスポートに失敗しました:{message}",
+ fitView: "キャンバスにフィット",
+ resetZoom: "ズームをリセット",
+ undo: "元に戻す",
+ redo: "やり直す",
+ fullscreen: "全画面",
+ exitFullscreen: "全画面を終了",
+ addLayer: "レイヤーを追加",
+ noLayersYet: "レイヤーがありません。+ をクリックして作成",
+ layoutFree: "フリー",
+ layoutAuto: "自動",
+ layerLock: "レイヤーをロック(レイヤー内で自動レイアウトしない)",
+ layerUnlock: "レイヤーのロックを解除(レイヤー内で自動レイアウトを許可)",
+ layerLocked: "ロック済み",
+ layerUnlocked: "未ロック",
+ hideLayer: "レイヤーを非表示",
+ showLayer: "レイヤーを表示",
+ deleteLayer: "レイヤーを削除",
+ layerHiddenCannotFocus: "レイヤーは非表示です。表示してからキャンバスに移動してください",
+ createTable: "テーブルを作成",
+ create: "作成",
+ syncToDatabase: "データベースに同期",
+ syncDraftCount: "{count} 件の変更を同期します(新規作成 / 変更 / 削除)",
+ executeSync: "実行",
+ syncing: "同期中…",
+ buildingSql: "SQL を生成中…",
+ noSqlYet: "SQL はまだありません",
+ copySql: "SQL をコピー",
+ tableName: "テーブル名",
+ tableNameRequired: "テーブル名を入力してください",
+ tableNameExists: "同名のテーブルが既に存在します",
+ assignLayer: "所属レイヤー",
+ noLayer: "レイヤーなし",
+ withDefaultIdPk: "デフォルトの id 主キーを含める",
+ inspectorTable: "テーブル",
+ inspectorRelationship: "リレーションシップ",
+ fieldComment: "コメント",
+ fields: "フィールド",
+ tabFields: "フィールド",
+ tabIndexes: "インデックス",
+ addIndex: "インデックスを追加",
+ indexName: "インデックス名",
+ indexUnique: "UNIQUE",
+ indexColumns: "列",
+ noIndexesYet: "インデックスはまだありません",
+ indexesNotSupported: "現在のデータベースはダイアグラムエディタでのインデックス作成をサポートしていません",
+ addField: "フィールドを追加",
+ deleteField: "フィールドを削除",
+ undoDropField: "フィールド削除を元に戻す",
+ noFieldsYet: "フィールドがまだありません。追加後に同期できます",
+ deleteDraftTable: "下書きテーブルを削除",
+ deleteLiveTable: "テーブルを削除",
+ deleteLiveTableHint: "削除待ちとしてマークされ、次回「データベースに同期」時に DROP TABLE を実行します。元に戻すことで復元できます。",
+ deleteLiveTableConfirm: "このテーブルを削除としてマークしますか?同期時に DROP TABLE が実行されます。",
+ liveTableReadOnly: "登録済みテーブル — テーブル構造エディタで変更してください",
+ liveTableAddColumnsHint: "フィールドの追加(ALTER ADD)または既存フィールドの削除マーク(ALTER DROP)が可能です。その後同期します。型変更などはテーブル構造エディタを使用してください。",
+ liveTableIndexesReadOnly: "登録済みテーブルのインデックスはテーブル構造エディタで変更してください",
+ pendingColumnsBadge: "同期待ちの変更",
+ pendingDropColumnBadge: "削除待ち",
+ pendingDropTableBadge: "削除待ちテーブル",
+ source: "ソース",
+ target: "ターゲット",
+ emptyDesignHint: "テーブルがまだありません。レイヤーとテーブルを作成し、プロパティパネルでフィールドを編集して、完了後にデータベースに同期できます。",
+ removeFromLayer: "レイヤーから削除",
+ addTables: "テーブルを追加",
+ tabSelectExisting: "既存を選択",
+ tabCreateTable: "新規テーブル",
+ multiSelect: "複数選択",
+ closeMultiSelect: "閉じる",
+ selectAll: "すべて選択",
+ clearSelection: "クリア",
+ tablesInOtherLayers: "他のレイヤーのテーブル",
+ filterTables: "テーブルをフィルタ...",
+ noMatchingTables: "一致するテーブルがありません",
+ addSelectedCount: "選択した {count} 件を追加",
+ layerNameTooLong: "レイヤー名は50文字以内にしてください",
+ layerNameInvalid: "レイヤー名には中国語、英字、数字、スペース、アンダースコア、ハイフン、ピリオドを使用できます",
+ layerNameExists: "レイヤー名が既に存在します",
+ total: "合計",
+ confirmed: "確認済み",
+ pending: "確認待ち",
+ conflicts: "競合",
+ conflict: "競合",
+ ignored: "無視済み",
+ confirmAll: "すべて確認",
+ ignoreAll: "すべて無視",
+ clearAll: "すべてクリア",
+ confidenceHigh: "高",
+ confidenceMedium: "中",
+ confidenceLow: "低",
+ noInferred: "推論された関係はありません",
+ editRelationship: "編集",
+ saveRelationship: "保存",
+ deleteRelationship: "削除",
+ confirmDeleteRelationship: "このリレーションシップを削除しますか?",
+ confirmDeleteRelationshipAction: "削除を確認",
+ confirmRelationship: "確認",
+ ignoreRelationship: "無視",
+ relationshipKindFk: "外部キー",
+ relationshipKindCustom: "カスタム",
+ relationshipKindInferred: "推論",
+ relationshipReadOnlyFk: "このリレーションシップはデータベースの外部キー制約に基づいており、ここでは変更できません。",
+ relationshipUpdated: "リレーションシップが更新されました",
+ createTableNotSupported: "現在のデータベースは新しいテーブルの作成をサポートしていません",
+ structureSyncNotSupported: "現在のデータベースは図からテーブル構造を同期することをサポートしていません",
+ addColumnNotSupported: "現在のデータベースは列の追加をサポートしていません",
+ dropColumnNotSupported: "現在のデータベースは列の削除をサポートしていません",
+ dropTableNotSupported: "現在のデータベースは図からテーブルを削除することをサポートしていません",
},
etcd: {
prefixPlaceholder: "Key パスを検索(例: /app/、service)",
diff --git a/apps/desktop/src/i18n/locales/pt-BR.ts b/apps/desktop/src/i18n/locales/pt-BR.ts
index e8c38aafe..06963fc9f 100644
--- a/apps/desktop/src/i18n/locales/pt-BR.ts
+++ b/apps/desktop/src/i18n/locales/pt-BR.ts
@@ -2749,10 +2749,10 @@ export default withEnglishFallback({
targetTable: "Tabela de destino",
targetColumn: "Coluna de destino",
cardinality: "Cardinalidade",
- cardinalityOneToMany: "1:N",
- cardinalityManyToOne: "N:1",
- cardinalityManyToMany: "N:N",
- cardinalityOneToOne: "1:1",
+ cardinalityOneToMany: "Origem 1 · Destino N",
+ cardinalityManyToOne: "Origem N · Destino 1",
+ cardinalityManyToMany: "Origem N · Destino N",
+ cardinalityOneToOne: "Origem 1 · Destino 1",
addRelationship: "Adicionar relacionamento",
removeRelationship: "Remover relacionamento",
relationshipIncomplete: "Selecione as colunas de origem e destino",
@@ -2769,6 +2769,130 @@ export default withEnglishFallback({
zoomIn: "Aumentar zoom",
zoomOut: "Diminuir zoom",
resetLayout: "Redefinir layout",
+ refreshConfirm: "Atualizar recarregará a estrutura da tabela e redefinirá o layout do canvas e o histórico de desfazer. Os relacionamentos de modelagem salvos localmente não serão afetados. Continuar?",
+ layers: "Camadas",
+ autoMatch: "Correspondência automática",
+ autoLayout: "Layout automático",
+ matchRelationshipsCount: "{count} correspondências",
+ cardinalityDirected: "Origem {source} · Destino {target}",
+ matchPickCardinality: "Selecione a cardinalidade antes de confirmar a correspondência (1:1 / 1:N / N:1 / N:N).",
+ manualAddRelationship: "Adicionar manualmente",
+ confirmMatch: "Confirmar",
+ ignoreMatch: "Ignorar",
+ exportPng: "Exportar PNG",
+ exportJson: "Exportar JSON",
+ exportDbml: "Exportar DBML",
+ exportMermaid: "Exportar Mermaid",
+ export: "Exportar",
+ exportedFormat: "{format} exportado",
+ exportFailed: "Falha ao exportar {format}: {message}",
+ fitView: "Ajustar ao canvas",
+ resetZoom: "Redefinir zoom",
+ undo: "Desfazer",
+ redo: "Refazer",
+ fullscreen: "Tela cheia",
+ exitFullscreen: "Sair da tela cheia",
+ addLayer: "Adicionar camada",
+ noLayersYet: "Nenhuma camada ainda, clique em + para criar",
+ layoutFree: "Livre",
+ layoutAuto: "Automático",
+ layerLock: "Bloquear camada (sem layout automático dentro da camada)",
+ layerUnlock: "Desbloquear camada (permite layout automático dentro da camada)",
+ layerLocked: "Bloqueada",
+ layerUnlocked: "Desbloqueada",
+ hideLayer: "Ocultar camada",
+ showLayer: "Mostrar camada",
+ deleteLayer: "Excluir camada",
+ layerHiddenCannotFocus: "A camada está oculta. Mostre-a antes de centralizar no canvas.",
+ createTable: "Nova tabela",
+ create: "Criar",
+ syncToDatabase: "Sincronizar com o banco de dados",
+ syncDraftCount: "Serão sincronizadas {count} alterações (nova tabela / alterar tabela / excluir tabela)",
+ executeSync: "Executar",
+ syncing: "Sincronizando…",
+ buildingSql: "Gerando SQL…",
+ noSqlYet: "Nenhum SQL ainda",
+ copySql: "Copiar SQL",
+ tableName: "Nome da tabela",
+ tableNameRequired: "Informe o nome da tabela",
+ tableNameExists: "Já existe uma tabela com esse nome",
+ assignLayer: "Camada",
+ noLayer: "Sem camada",
+ withDefaultIdPk: "Incluir chave primária id padrão",
+ inspectorTable: "Tabela",
+ inspectorRelationship: "Relacionamento",
+ fieldComment: "Comentário",
+ fields: "Campos",
+ tabFields: "Campos",
+ tabIndexes: "Índices",
+ addIndex: "Adicionar índice",
+ indexName: "Nome do índice",
+ indexUnique: "UNIQUE",
+ indexColumns: "Colunas",
+ noIndexesYet: "Nenhum índice ainda",
+ indexesNotSupported: "O banco de dados atual não suporta criar índices no editor de diagramas",
+ addField: "Adicionar campo",
+ deleteField: "Excluir campo",
+ undoDropField: "Desfazer exclusão do campo",
+ noFieldsYet: "Nenhum campo ainda. Adicione antes de sincronizar",
+ deleteDraftTable: "Excluir tabela de rascunho",
+ deleteLiveTable: "Excluir tabela",
+ deleteLiveTableHint: "Marcada para exclusão. O DROP TABLE será executado na próxima sincronização com o banco de dados. Você pode desfazer para restaurar.",
+ deleteLiveTableConfirm: "Confirmar marcação desta tabela para exclusão? O DROP TABLE será executado durante a sincronização.",
+ liveTableReadOnly: "Tabela no banco — edite no editor de estrutura da tabela",
+ liveTableAddColumnsHint: "Você pode adicionar campos (ALTER ADD) ou marcar campos existentes para exclusão (ALTER DROP) e depois sincronizar. Para alterar tipos, use o editor de estrutura da tabela.",
+ liveTableIndexesReadOnly: "Índices de tabelas no banco devem ser alterados no editor de estrutura da tabela",
+ pendingColumnsBadge: "Alterações pendentes de sincronização",
+ pendingDropColumnBadge: "A excluir",
+ pendingDropTableBadge: "Tabela a excluir",
+ source: "Origem",
+ target: "Destino",
+ emptyDesignHint: "Nenhuma tabela ainda. Você pode criar camadas e tabelas, editar campos no painel de propriedades e sincronizar com o banco de dados ao concluir.",
+ removeFromLayer: "Remover da camada",
+ addTables: "Adicionar tabelas",
+ tabSelectExisting: "Selecionar existente",
+ tabCreateTable: "Nova tabela",
+ multiSelect: "Seleção múltipla",
+ closeMultiSelect: "Fechar",
+ selectAll: "Selecionar tudo",
+ clearSelection: "Limpar seleção",
+ tablesInOtherLayers: "Tabelas em outras camadas",
+ filterTables: "Filtrar tabelas...",
+ noMatchingTables: "Nenhuma tabela correspondente",
+ addSelectedCount: "Adicionar {count} selecionados",
+ layerNameTooLong: "O nome da camada não pode exceder 50 caracteres",
+ layerNameInvalid: "O nome da camada pode conter caracteres chineses, letras, números, espaços, sublinhados, hífens e pontos finais.",
+ layerNameExists: "O nome da camada já existe",
+ total: "Total",
+ confirmed: "Confirmado",
+ pending: "Pendente",
+ conflicts: "Conflitos",
+ conflict: "Conflito",
+ ignored: "Ignorado",
+ confirmAll: "Confirmar tudo",
+ ignoreAll: "Ignorar tudo",
+ clearAll: "Limpar tudo",
+ confidenceHigh: "Alta",
+ confidenceMedium: "Média",
+ confidenceLow: "Baixa",
+ noInferred: "Nenhum relacionamento inferido ainda",
+ editRelationship: "Editar",
+ saveRelationship: "Salvar",
+ deleteRelationship: "Excluir",
+ confirmDeleteRelationship: "Confirmar exclusão deste relacionamento?",
+ confirmDeleteRelationshipAction: "Confirmar exclusão",
+ confirmRelationship: "Confirmar",
+ ignoreRelationship: "Ignorar",
+ relationshipKindFk: "Chave estrangeira",
+ relationshipKindCustom: "Personalizado",
+ relationshipKindInferred: "Inferido",
+ relationshipReadOnlyFk: "Este relacionamento vem de uma restrição de chave estrangeira do banco de dados e não pode ser editado aqui.",
+ relationshipUpdated: "Relacionamento atualizado",
+ createTableNotSupported: "O banco de dados atual não suporta criar nova tabela",
+ structureSyncNotSupported: "O banco de dados atual não suporta sincronizar a estrutura da tabela a partir do diagrama",
+ addColumnNotSupported: "O banco de dados atual não suporta adicionar coluna",
+ dropColumnNotSupported: "O banco de dados atual não suporta excluir coluna",
+ dropTableNotSupported: "O banco de dados atual não suporta excluir tabela do diagrama",
},
etcd: {
prefixPlaceholder: "Buscar caminho da Key, por exemplo /app/ ou serviço",
diff --git a/apps/desktop/src/i18n/locales/zh-CN.ts b/apps/desktop/src/i18n/locales/zh-CN.ts
index c1be3c660..b8b0f1db0 100644
--- a/apps/desktop/src/i18n/locales/zh-CN.ts
+++ b/apps/desktop/src/i18n/locales/zh-CN.ts
@@ -2878,6 +2878,7 @@ export default withEnglishFallback({
selectSchema: "选择模式",
searchTables: "搜索表/字段/外键...",
refresh: "刷新关系图",
+ refreshConfirm: "刷新将重新加载表结构并重置画布布局与撤回历史,本地已保存的建模关系不受影响。确定继续?",
loading: "正在读取关系...",
loadingProgress: "正在读取关系... {loaded}/{total}",
partialError: "{count} 张表的元数据读取失败,已跳过",
@@ -2889,6 +2890,10 @@ export default withEnglishFallback({
tableMode: "表结构图",
engineeringMode: "工程 ER 图",
modelRelationships: "建模关系",
+ layers: "Layers",
+ autoMatch: "自动匹配",
+ autoLayout: "自动布局",
+ matchRelationshipsCount: "{count} 条匹配",
copyJoinSql: "复制 SQL",
customRelationshipsCount: "{count} 条建模",
relationshipName: "名称",
@@ -2898,10 +2903,15 @@ export default withEnglishFallback({
targetTable: "目标表",
targetColumn: "目标字段",
cardinality: "基数",
- cardinalityOneToMany: "1:N",
- cardinalityManyToOne: "N:1",
- cardinalityManyToMany: "N:N",
- cardinalityOneToOne: "1:1",
+ cardinalityOneToMany: "源 1 · 目标 N",
+ cardinalityManyToOne: "源 N · 目标 1",
+ cardinalityOneToOne: "源 1 · 目标 1",
+ cardinalityManyToMany: "源 N · 目标 N",
+ cardinalityDirected: "源 {source} · 目标 {target}",
+ matchPickCardinality: "确认匹配前请选择基数(1:1 / 1:N / N:1 / N:N)。",
+ manualAddRelationship: "手动补录",
+ confirmMatch: "确认",
+ ignoreMatch: "忽略",
addRelationship: "添加关系",
removeRelationship: "删除关系",
relationshipIncomplete: "请选择源字段和目标字段",
@@ -2913,11 +2923,125 @@ export default withEnglishFallback({
relatedTables: "相关表",
moreColumns: "+ {count} 个字段",
exportSvg: "导出 SVG",
+ exportPng: "导出 PNG",
+ exportJson: "导出 JSON",
+ exportDbml: "导出 DBML",
+ exportMermaid: "导出 Mermaid",
+ export: "导出",
exportedSvg: "SVG 已导出",
+ exportedFormat: "{format} 已导出",
exportSvgFailed: "导出 SVG 失败:{message}",
+ exportFailed: "导出 {format} 失败:{message}",
zoomIn: "放大",
zoomOut: "缩小",
+ fitView: "适应画布",
+ resetZoom: "重置缩放",
+ undo: "撤回",
+ redo: "前进",
resetLayout: "重置布局",
+ fullscreen: "全屏",
+ exitFullscreen: "退出全屏",
+ addLayer: "新增层",
+ noLayersYet: "暂无图层,点击 + 创建",
+ layoutFree: "自由",
+ layoutAuto: "自动",
+ layerLock: "锁定层(层内不自动布局)",
+ layerUnlock: "解锁层(层内允许自动布局)",
+ layerLocked: "已锁定",
+ layerUnlocked: "未锁定",
+ hideLayer: "隐藏层",
+ showLayer: "显示层",
+ deleteLayer: "删除层",
+ layerHiddenCannotFocus: "层已隐藏,请先显示后再定位到画布",
+ createTable: "新建表",
+ createTableNotSupported: "当前数据库不支持新建表",
+ create: "创建",
+ syncToDatabase: "同步到数据库",
+ structureSyncNotSupported: "当前数据库不支持从图同步表结构",
+ syncDraftCount: "将同步 {count} 项变更(新建 / 改表 / 删表)",
+ executeSync: "执行",
+ syncing: "同步中…",
+ buildingSql: "正在生成 SQL…",
+ noSqlYet: "暂无 SQL",
+ copySql: "复制 SQL",
+ tableName: "表名",
+ tableNameRequired: "请填写表名",
+ tableNameExists: "已存在同名表",
+ assignLayer: "所属层",
+ noLayer: "不分层",
+ withDefaultIdPk: "包含默认 id 主键",
+ inspectorTable: "表",
+ inspectorRelationship: "关系",
+ fieldComment: "注释",
+ fields: "字段",
+ tabFields: "字段",
+ tabIndexes: "索引",
+ addIndex: "添加索引",
+ indexName: "索引名",
+ indexUnique: "UNIQUE",
+ indexColumns: "列",
+ noIndexesYet: "还没有索引",
+ indexesNotSupported: "当前数据库不支持在图编辑器中创建索引",
+ addColumnNotSupported: "当前数据库不支持添加列",
+ dropColumnNotSupported: "当前数据库不支持删除列",
+ dropTableNotSupported: "当前数据库不支持从图删除表",
+ addField: "添加字段",
+ deleteField: "删除字段",
+ undoDropField: "撤销删除字段",
+ noFieldsYet: "还没有字段,添加后才能同步",
+ deleteDraftTable: "删除草稿表",
+ deleteLiveTable: "删除表",
+ deleteLiveTableHint: "标记为待删除,下次「同步到数据库」时执行 DROP TABLE。可用撤销恢复。",
+ deleteLiveTableConfirm: "确认将此表标记为删除?同步时将执行 DROP TABLE。",
+ liveTableReadOnly: "已入库表 — 请在表结构编辑器中修改",
+ liveTableAddColumnsHint: "可新增字段(ALTER ADD)或标记已有字段删除(ALTER DROP),然后同步。改类型等请用表结构编辑器。",
+ liveTableIndexesReadOnly: "已入库表索引请在表结构编辑器中修改",
+ pendingColumnsBadge: "待同步变更",
+ pendingDropColumnBadge: "待删",
+ pendingDropTableBadge: "待删表",
+ source: "源",
+ target: "目标",
+ emptyDesignHint: "还没有表。可新建层与表,在属性面板编辑字段,完成后同步到数据库。",
+ removeFromLayer: "从层中移除",
+ addTables: "添加表",
+ tabSelectExisting: "选择已有",
+ tabCreateTable: "新建表",
+ multiSelect: "多选",
+ closeMultiSelect: "关闭",
+ selectAll: "全选",
+ clearSelection: "清空",
+ tablesInOtherLayers: "其他层中的表",
+ filterTables: "筛选表...",
+ noMatchingTables: "无匹配的表",
+ addSelectedCount: "添加已选 {count} 项",
+ layerNameTooLong: "图层名称不能超过 50 个字符",
+ layerNameInvalid: "图层名称可含中文、字母、数字、空格、下划线、连字符和句点",
+ layerNameExists: "图层名称已存在",
+ total: "总计",
+ confirmed: "已确认",
+ pending: "待确认",
+ conflicts: "冲突",
+ conflict: "冲突",
+ ignored: "已忽略",
+ confirmAll: "全部确认",
+ ignoreAll: "全部忽略",
+ clearAll: "清除全部",
+ confidenceHigh: "高",
+ confidenceMedium: "中",
+ confidenceLow: "低",
+ noInferred: "暂无推断关系",
+ editRelationship: "编辑",
+ saveRelationship: "保存",
+ deleteRelationship: "删除",
+ confirmDeleteRelationship: "确认删除此关系?",
+ confirmDeleteRelationshipAction: "确认删除",
+ confirmRelationship: "确认",
+ ignoreRelationship: "忽略",
+ relationshipKindFk: "外键",
+ relationshipKindCustom: "自定义",
+ relationshipKindInferred: "推断",
+ relationshipReadOnlyFk: "该关系来自数据库外键约束,无法在此修改。",
+ relationshipUpdated: "关系已更新",
},
etcd: {
prefixPlaceholder: "搜索 Key 路径,例如 /app/ 或 service",
diff --git a/apps/desktop/src/i18n/locales/zh-TW.ts b/apps/desktop/src/i18n/locales/zh-TW.ts
index 2e961718f..82fb0b103 100644
--- a/apps/desktop/src/i18n/locales/zh-TW.ts
+++ b/apps/desktop/src/i18n/locales/zh-TW.ts
@@ -2626,10 +2626,10 @@ export default withEnglishFallback({
targetTable: "目標資料表",
targetColumn: "目標欄位",
cardinality: "基數",
- cardinalityOneToMany: "1:N",
- cardinalityManyToOne: "N:1",
- cardinalityManyToMany: "N:N",
- cardinalityOneToOne: "1:1",
+ cardinalityOneToMany: "來源 1 · 目標 N",
+ cardinalityManyToOne: "來源 N · 目標 1",
+ cardinalityManyToMany: "來源 N · 目標 N",
+ cardinalityOneToOne: "來源 1 · 目標 1",
addRelationship: "新增關係",
removeRelationship: "移除關係",
relationshipIncomplete: "請同時選擇來源和目標欄位",
@@ -2637,6 +2637,130 @@ export default withEnglishFallback({
relationshipExists: "此關係已存在",
relationshipAdded: "關係已新增",
noJoinSql: "沒有可複製的關係 SQL",
+ refreshConfirm: "重新整理將重新載入表結構並重設畫布佈局與復原歷史,本機已儲存的建模關係不受影響。確定繼續?",
+ layers: "圖層",
+ autoMatch: "自動匹配",
+ autoLayout: "自動佈局",
+ matchRelationshipsCount: "{count} 條匹配",
+ cardinalityDirected: "來源 {source} · 目標 {target}",
+ matchPickCardinality: "確認匹配前請選擇基數(1:1 / 1:N / N:1 / N:N)。",
+ manualAddRelationship: "手動補錄",
+ confirmMatch: "確認",
+ ignoreMatch: "忽略",
+ exportPng: "匯出 PNG",
+ exportJson: "匯出 JSON",
+ exportDbml: "匯出 DBML",
+ exportMermaid: "匯出 Mermaid",
+ export: "匯出",
+ exportedFormat: "{format} 已匯出",
+ exportFailed: "匯出 {format} 失敗:{message}",
+ fitView: "適應畫布",
+ resetZoom: "重設縮放",
+ undo: "復原",
+ redo: "重做",
+ fullscreen: "全螢幕",
+ exitFullscreen: "退出全螢幕",
+ addLayer: "新增圖層",
+ noLayersYet: "尚無圖層,點擊 + 建立",
+ layoutFree: "自由",
+ layoutAuto: "自動",
+ layerLock: "鎖定圖層(圖層內不自動佈局)",
+ layerUnlock: "解鎖圖層(圖層內允許自動佈局)",
+ layerLocked: "已鎖定",
+ layerUnlocked: "未鎖定",
+ hideLayer: "隱藏圖層",
+ showLayer: "顯示圖層",
+ deleteLayer: "刪除圖層",
+ layerHiddenCannotFocus: "圖層已隱藏,請先顯示後再定位到畫布",
+ createTable: "新增資料表",
+ create: "建立",
+ syncToDatabase: "同步到資料庫",
+ syncDraftCount: "將同步 {count} 項變更(新增 / 修改表 / 刪除表)",
+ executeSync: "執行",
+ syncing: "同步中...",
+ buildingSql: "正在產生 SQL...",
+ noSqlYet: "暫無 SQL",
+ copySql: "複製 SQL",
+ tableName: "資料表名稱",
+ tableNameRequired: "請填寫資料表名稱",
+ tableNameExists: "已存在相同名稱的資料表",
+ assignLayer: "所屬圖層",
+ noLayer: "不分圖層",
+ withDefaultIdPk: "包含預設 id 主鍵",
+ inspectorTable: "資料表",
+ inspectorRelationship: "關係",
+ fieldComment: "註解",
+ fields: "欄位",
+ tabFields: "欄位",
+ tabIndexes: "索引",
+ addIndex: "新增索引",
+ indexName: "索引名稱",
+ indexUnique: "UNIQUE",
+ indexColumns: "欄",
+ noIndexesYet: "尚無索引",
+ indexesNotSupported: "目前資料庫不支援在圖形編輯器中建立索引",
+ addField: "新增欄位",
+ deleteField: "刪除欄位",
+ undoDropField: "復原刪除欄位",
+ noFieldsYet: "尚無欄位,新增後才能同步",
+ deleteDraftTable: "刪除草稿資料表",
+ deleteLiveTable: "刪除資料表",
+ deleteLiveTableHint: "標記為待刪除,下次「同步到資料庫」時執行 DROP TABLE。可用復原恢復。",
+ deleteLiveTableConfirm: "確認將此資料表標記為刪除?同步時將執行 DROP TABLE。",
+ liveTableReadOnly: "已入庫資料表 — 請在資料表結構編輯器中修改",
+ liveTableAddColumnsHint: "可新增欄位(ALTER ADD)或標記已有欄位刪除(ALTER DROP),然後同步。變更類型等請使用資料表結構編輯器。",
+ liveTableIndexesReadOnly: "已入庫資料表索引請在資料表結構編輯器中修改",
+ pendingColumnsBadge: "待同步變更",
+ pendingDropColumnBadge: "待刪除",
+ pendingDropTableBadge: "待刪除資料表",
+ source: "來源",
+ target: "目標",
+ emptyDesignHint: "尚無資料表。可新增圖層與資料表,在屬性面板編輯欄位,完成後同步到資料庫。",
+ removeFromLayer: "從圖層中移除",
+ addTables: "新增資料表",
+ tabSelectExisting: "選擇既有",
+ tabCreateTable: "新增資料表",
+ multiSelect: "多選",
+ closeMultiSelect: "關閉",
+ selectAll: "全選",
+ clearSelection: "清除",
+ tablesInOtherLayers: "其他圖層中的資料表",
+ filterTables: "篩選資料表...",
+ noMatchingTables: "無匹配的資料表",
+ addSelectedCount: "新增已選 {count} 項",
+ layerNameTooLong: "圖層名稱不能超過 50 個字元",
+ layerNameInvalid: "圖層名稱可含中文、字母、數字、空格、底線、連字符和句點",
+ layerNameExists: "圖層名稱已存在",
+ total: "總計",
+ confirmed: "已確認",
+ pending: "待確認",
+ conflicts: "衝突",
+ conflict: "衝突",
+ ignored: "已忽略",
+ confirmAll: "全部確認",
+ ignoreAll: "全部忽略",
+ clearAll: "清除全部",
+ confidenceHigh: "高",
+ confidenceMedium: "中",
+ confidenceLow: "低",
+ noInferred: "暫無推斷關係",
+ editRelationship: "編輯",
+ saveRelationship: "儲存",
+ deleteRelationship: "刪除",
+ confirmDeleteRelationship: "確認刪除此關係?",
+ confirmDeleteRelationshipAction: "確認刪除",
+ confirmRelationship: "確認",
+ ignoreRelationship: "忽略",
+ relationshipKindFk: "外鍵",
+ relationshipKindCustom: "自訂",
+ relationshipKindInferred: "推斷",
+ relationshipReadOnlyFk: "該關係來自資料庫外鍵約束,無法在此修改。",
+ relationshipUpdated: "關係已更新",
+ createTableNotSupported: "目前資料庫不支援建立新資料表",
+ structureSyncNotSupported: "目前資料庫不支援從圖同步資料表結構",
+ addColumnNotSupported: "目前資料庫不支援新增欄位",
+ dropColumnNotSupported: "目前資料庫不支援刪除欄位",
+ dropTableNotSupported: "目前資料庫不支援從圖刪除資料表",
},
redis: {
setDatabaseAlias: "設定資料庫別名",
diff --git a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts
index 8e403fc53..42f478993 100644
--- a/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts
+++ b/apps/desktop/src/lib/__tests__/table/tableStructureEditorState.spec.ts
@@ -288,12 +288,17 @@ describe("tableStructureEditorState", () => {
expect(getDefaultLengthForType("mysql", "float")).toBe("10,2");
});
- it("uses TEXT for a new native SQLite column without changing compatible defaults", () => {
+ it("uses TEXT for SQLite-family columns and dialect defaults elsewhere", () => {
expect(DATA_TYPE_OPTIONS.sqlite).toContain("text");
+ expect(DATA_TYPE_OPTIONS.duckdb).toContain("TEXT");
+ expect(DATA_TYPE_OPTIONS.h2).toContain("VARCHAR");
expect(defaultNewColumnDataType("sqlite")).toBe("text");
- expect(defaultNewColumnDataType("rqlite")).toBe("varchar(255)");
- expect(defaultNewColumnDataType("turso")).toBe("varchar(255)");
+ expect(defaultNewColumnDataType("rqlite")).toBe("text");
+ expect(defaultNewColumnDataType("turso")).toBe("text");
+ expect(defaultNewColumnDataType("duckdb")).toBe("TEXT");
expect(defaultNewColumnDataType("mysql")).toBe("varchar(255)");
+ expect(defaultNewColumnDataType("h2").toLowerCase()).toContain("varchar");
+ expect(defaultNewColumnDataType("clickhouse")).toBe("String");
});
it("requires a SQLite rebuild only for a retained existing column type change", () => {
diff --git a/apps/desktop/src/lib/backend/api.ts b/apps/desktop/src/lib/backend/api.ts
index 1bc0b78a8..e5eabff26 100644
--- a/apps/desktop/src/lib/backend/api.ts
+++ b/apps/desktop/src/lib/backend/api.ts
@@ -158,6 +158,7 @@ export const listCompletionObjects = forward("listCompletionObjects");
export const completionAssistantSearch = forward("completionAssistantSearch");
export const getObjectSource = forward("getObjectSource");
export const getColumns = forward("getColumns");
+export const getAllColumns = forward("getAllColumns");
export const getSqlServerColumnMetadata = forward("getSqlServerColumnMetadata");
export const listDataTypes = forward("listDataTypes");
export const listIndexes = forward("listIndexes");
diff --git a/apps/desktop/src/lib/backend/http.ts b/apps/desktop/src/lib/backend/http.ts
index 64fa30d32..b0e454ed8 100644
--- a/apps/desktop/src/lib/backend/http.ts
+++ b/apps/desktop/src/lib/backend/http.ts
@@ -744,6 +744,16 @@ export async function getSqlServerColumnMetadata(connectionId: string, database:
return get(`/api/schema/sqlserver/column-metadata?${qs({ connection_id: connectionId, database, schema, table })}`);
}
+export interface TableColumnsResult {
+ table_name: string;
+ columns: ColumnInfo[];
+ error?: string;
+}
+
+export async function getAllColumns(connectionId: string, database: string, schema: string): Promise
{
+ return get(`/api/schema/all-columns?${qs({ connection_id: connectionId, database, schema })}`);
+}
+
export async function listDataTypes(connectionId: string, database: string): Promise {
return get(`/api/schema/data-types?${qs({ connection_id: connectionId, database })}`);
}
diff --git a/apps/desktop/src/lib/backend/tauri.ts b/apps/desktop/src/lib/backend/tauri.ts
index 61334bd44..86546fc0b 100644
--- a/apps/desktop/src/lib/backend/tauri.ts
+++ b/apps/desktop/src/lib/backend/tauri.ts
@@ -1042,6 +1042,16 @@ export async function getSqlServerColumnMetadata(connectionId: string, database:
});
}
+export interface TableColumnsResult {
+ table_name: string;
+ columns: ColumnInfo[];
+ error?: string;
+}
+
+export async function getAllColumns(connectionId: string, database: string, schema: string): Promise {
+ return invoke("get_all_columns", { connectionId, database, schema });
+}
+
export async function listDataTypes(connectionId: string, database: string): Promise {
return invoke("list_data_types", { connectionId, database });
}
diff --git a/apps/desktop/src/lib/diagram/cardinality.ts b/apps/desktop/src/lib/diagram/cardinality.ts
new file mode 100644
index 000000000..3c5e9214d
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/cardinality.ts
@@ -0,0 +1,35 @@
+export type CardinalityChoice = "one-to-one" | "one-to-many" | "many-to-one" | "many-to-many";
+
+export type CardinalityPair = {
+ sourceCardinality: "1" | "N";
+ targetCardinality: "1" | "N";
+};
+
+export function cardinalityPairFromChoice(choice: CardinalityChoice): CardinalityPair {
+ if (choice === "one-to-one") return { sourceCardinality: "1", targetCardinality: "1" };
+ if (choice === "one-to-many") return { sourceCardinality: "1", targetCardinality: "N" };
+ if (choice === "many-to-many") return { sourceCardinality: "N", targetCardinality: "N" };
+ return { sourceCardinality: "N", targetCardinality: "1" };
+}
+
+export function cardinalityChoiceFromPair(pair: Partial | null | undefined): CardinalityChoice {
+ if (!pair) return "many-to-one";
+ const s = pair.sourceCardinality;
+ const t = pair.targetCardinality;
+ if (s === "1" && t === "1") return "one-to-one";
+ if (s === "1" && t === "N") return "one-to-many";
+ if (s === "N" && t === "N") return "many-to-many";
+ return "many-to-one";
+}
+
+/** Resolve endpoint cardinalities from a relationship; FK/inferred without explicit pair defaults to N:1. */
+export function edgeCardinalityPair(rel: Partial | object | null | undefined): CardinalityPair {
+ if (rel && typeof rel === "object" && "sourceCardinality" in rel && "targetCardinality" in rel) {
+ const sourceCardinality = (rel as Partial).sourceCardinality;
+ const targetCardinality = (rel as Partial).targetCardinality;
+ if (sourceCardinality && targetCardinality) {
+ return { sourceCardinality, targetCardinality };
+ }
+ }
+ return { sourceCardinality: "N", targetCardinality: "1" };
+}
diff --git a/apps/desktop/src/lib/diagram/diagram-constants.ts b/apps/desktop/src/lib/diagram/diagram-constants.ts
new file mode 100644
index 000000000..918459ffc
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/diagram-constants.ts
@@ -0,0 +1,52 @@
+/** provide/inject key for the currently hovered relationship edge id */
+export const DIAGRAM_HOVERED_EDGE_KEY = "diagramHoveredEdgeId";
+/** provide/inject key for table/layer obstacle rects used by edge routing */
+export const DIAGRAM_EDGE_OBSTACLES_KEY = "diagramEdgeObstacles";
+
+export const CARD_WIDTH = 360;
+export const CARD_HEADER_HEIGHT = 44;
+export const COLUMN_ROW_HEIGHT = 24;
+export const CARD_BOTTOM_PADDING = 12;
+/** Fixed width for the data-type column inside a table card */
+export const COLUMN_TYPE_WIDTH = 112;
+/** Display truncation caps (CSS truncate + char limit) */
+export const COLUMN_NAME_MAX_CHARS = 36;
+export const COLUMN_TYPE_MAX_CHARS = 22;
+export const TABLE_NAME_MAX_CHARS = 40;
+
+export const GAP_X = 80;
+export const GAP_Y = 60;
+export const MARGIN = 40;
+
+/** Handle inset from card edge (0 = centered on border) */
+export const EDGE_HANDLE_OUTSET = 0;
+/** Extra clearance before orthogonal bends around a node */
+export const EDGE_ROUTE_OFFSET = 36;
+/** Idle / hover stroke widths for relationship edges */
+export const EDGE_STROKE_IDLE = 1.6;
+export const EDGE_STROKE_HOVER = 3.5;
+/** Delay before hover opens edge detail popover / highlight */
+export const EDGE_POPOVER_OPEN_DELAY_MS = 400;
+/** Delay before hover popover closes after leaving edge / popover */
+export const EDGE_POPOVER_CLOSE_DELAY_MS = 220;
+
+/** Padding used by ELK nested layer boxes */
+export const LAYER_PADDING = 30;
+export const LAYER_HEADER_HEIGHT = 40;
+
+/** Tighter padding for Size-to-Fit / empty layer chrome */
+export const LAYER_CONTENT_PADDING = 20;
+export const EMPTY_LAYER_WIDTH = 240;
+export const EMPTY_LAYER_HEIGHT = LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING;
+
+export function tableCardHeight(columnCount: number): number {
+ return CARD_HEADER_HEIGHT + columnCount * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING;
+}
+
+/** LTR wrap column count from available canvas width (at least 1). */
+export function columnsPerRowForWidth(viewportWidth?: number): number {
+ if (!viewportWidth || viewportWidth <= 0) return 6;
+ const usable = viewportWidth - MARGIN * 2;
+ const cols = Math.floor(usable / (CARD_WIDTH + GAP_X));
+ return Math.max(1, cols < 1 ? 1 : cols);
+}
diff --git a/apps/desktop/src/lib/diagram/diagram-dialect-adapter.ts b/apps/desktop/src/lib/diagram/diagram-dialect-adapter.ts
new file mode 100644
index 000000000..c6b7eb81a
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/diagram-dialect-adapter.ts
@@ -0,0 +1,75 @@
+/**
+ * Thin column factories for the ER diagram.
+ *
+ * Dialect capability / SQL truth lives in the shared table-structure stack:
+ * - `tableStructureCapabilities` (UI gates)
+ * - `tableStructureEditorState` (type options / defaults)
+ * - Rust `table_structure_sql` via `buildCreateTableSql` / `buildTableStructureChangeSql`
+ *
+ * Do not add ER-specific dialect matrices or SQL generation here.
+ * Adding a dialect: update that shared stack (same checklist as TableStructureEditor).
+ */
+import type { ColumnInfo, DatabaseType } from "@/types/database";
+import { getTableStructureCapabilities, type TableStructureDialect } from "@/lib/table/tableStructureCapabilities";
+import { defaultNewColumnDataType, getDataTypeOptions } from "@/lib/table/tableStructureEditorState";
+
+export interface DiagramDialectAdapter {
+ databaseType: DatabaseType | undefined;
+ createDefaultIdColumn(): ColumnInfo;
+ createEmptyColumn(name?: string): ColumnInfo;
+}
+
+const DEFAULT_ID_TYPE_BY_DIALECT: Partial> = {
+ mysql: "bigint",
+ postgres: "bigint",
+ sqlserver: "bigint",
+ h2: "bigint",
+ informix: "bigint",
+ oracle: "NUMBER",
+ sqlite: "INTEGER",
+ duckdb: "INTEGER",
+ clickhouse: "UInt64",
+};
+
+function resolveDefaultIdType(dialect: TableStructureDialect, dataTypeOptions: readonly string[]): string {
+ const preferred = DEFAULT_ID_TYPE_BY_DIALECT[dialect];
+ if (preferred) {
+ if (dataTypeOptions.length === 0) return preferred;
+ const matched = dataTypeOptions.find((type) => type.trim().toLowerCase() === preferred.toLowerCase());
+ if (matched) return matched;
+ }
+ const integerLike = dataTypeOptions.find((type) => /^(bigint|int|integer|number|uint64|int64)/i.test(type.trim()));
+ if (integerLike) return integerLike;
+ return preferred ?? dataTypeOptions[0] ?? "bigint";
+}
+
+export function resolveDiagramDialectAdapter(databaseType?: DatabaseType): DiagramDialectAdapter {
+ const caps = getTableStructureCapabilities(databaseType);
+ const dataTypeOptions = getDataTypeOptions(databaseType);
+
+ return {
+ databaseType,
+ createDefaultIdColumn() {
+ return {
+ name: "id",
+ data_type: resolveDefaultIdType(caps.dialect, dataTypeOptions),
+ is_nullable: false,
+ column_default: null,
+ is_primary_key: true,
+ comment: null,
+ extra: null,
+ };
+ },
+ createEmptyColumn(name = "column_1") {
+ return {
+ name,
+ data_type: defaultNewColumnDataType(databaseType, dataTypeOptions),
+ is_nullable: true,
+ column_default: null,
+ is_primary_key: false,
+ comment: null,
+ extra: null,
+ };
+ },
+ };
+}
diff --git a/apps/desktop/src/lib/diagram/draft-storage.ts b/apps/desktop/src/lib/diagram/draft-storage.ts
new file mode 100644
index 000000000..b6ce9fcee
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/draft-storage.ts
@@ -0,0 +1,185 @@
+import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
+import type { ColumnInfo } from "@/types/database";
+import type { DiagramPosition, DiagramTable } from "./erDiagram";
+import { isLiveTable, needsDiagramSync } from "./erDiagram";
+import type { DiagramLayer } from "@/types/diagram";
+
+export interface LiveTablePatch {
+ tableName: string;
+ pendingColumns: ColumnInfo[];
+ droppedColumnNames?: string[];
+ pendingDrop?: boolean;
+}
+
+function storageKey(kind: "draft-tables" | "layers" | "positions" | "live-patches", connectionId: string, database: string, schema: string): string {
+ return ["dbx", "diagram", kind, "v1", connectionId, database, schema].join(":");
+}
+
+function isValidColumn(value: unknown): value is ColumnInfo {
+ if (!value || typeof value !== "object") return false;
+ const col = value as Partial;
+ return typeof col.name === "string" && typeof col.data_type === "string" && typeof col.is_nullable === "boolean";
+}
+
+function sanitizeStringList(value: unknown): string[] {
+ if (!Array.isArray(value)) return [];
+ return value.filter((item): item is string => typeof item === "string" && item.length > 0);
+}
+
+function sanitizePositions(raw: unknown): Record {
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) return {};
+ const out: Record = {};
+ for (const [name, value] of Object.entries(raw as Record)) {
+ if (!name || typeof value !== "object" || value == null || Array.isArray(value)) continue;
+ const x = (value as { x?: unknown }).x;
+ const y = (value as { y?: unknown }).y;
+ if (typeof x !== "number" || typeof y !== "number" || !Number.isFinite(x) || !Number.isFinite(y)) continue;
+ out[name] = { x, y };
+ }
+ return out;
+}
+
+function parseJson(raw: string | null, fallback: T): T {
+ if (!raw) return fallback;
+ try {
+ return JSON.parse(raw) as T;
+ } catch {
+ return fallback;
+ }
+}
+
+export function loadDraftTables(connectionId: string, database: string, schema: string): DiagramTable[] {
+ if (!connectionId || !database) return [];
+ const key = storageKey("draft-tables", connectionId, database, schema);
+ const list = parseJson(safeLocalStorageGet(key), []);
+ return list
+ .filter((t) => t && typeof t.name === "string" && Array.isArray(t.columns))
+ .map((t) => ({
+ ...t,
+ foreignKeys: t.foreignKeys || [],
+ origin: "draft" as const,
+ syncStatus: t.syncStatus === "error" ? "error" : "pending",
+ }));
+}
+
+export function saveDraftTables(tables: DiagramTable[], connectionId: string, database: string, schema: string): void {
+ if (!connectionId || !database) return;
+ const key = storageKey("draft-tables", connectionId, database, schema);
+ const drafts = tables.filter((t) => (t.origin ?? "live") === "draft");
+ safeLocalStorageSet(key, JSON.stringify(drafts));
+}
+
+export function loadPersistedLayers(connectionId: string, database: string, schema: string): DiagramLayer[] {
+ if (!connectionId || !database) return [];
+ const key = storageKey("layers", connectionId, database, schema);
+ return parseJson(safeLocalStorageGet(key), []);
+}
+
+export function savePersistedLayers(layers: DiagramLayer[], connectionId: string, database: string, schema: string): void {
+ if (!connectionId || !database) return;
+ const key = storageKey("layers", connectionId, database, schema);
+ safeLocalStorageSet(key, JSON.stringify(layers));
+}
+
+export function loadPersistedPositions(connectionId: string, database: string, schema: string): Record {
+ if (!connectionId || !database) return {};
+ const key = storageKey("positions", connectionId, database, schema);
+ return sanitizePositions(parseJson(safeLocalStorageGet(key), {}));
+}
+
+export function savePersistedPositions(positions: Record, connectionId: string, database: string, schema: string): void {
+ if (!connectionId || !database) return;
+ const key = storageKey("positions", connectionId, database, schema);
+ safeLocalStorageSet(key, JSON.stringify(sanitizePositions(positions)));
+}
+
+/** True when at least one known table name has a finite saved position. */
+export function hasUsablePersistedPositions(saved: Record, tableNames: string[]): boolean {
+ for (const name of tableNames) {
+ const pos = saved[name];
+ if (pos && Number.isFinite(pos.x) && Number.isFinite(pos.y)) return true;
+ }
+ return false;
+}
+
+export function loadLiveTablePatches(connectionId: string, database: string, schema: string): LiveTablePatch[] {
+ if (!connectionId || !database) return [];
+ const key = storageKey("live-patches", connectionId, database, schema);
+ const list = parseJson(safeLocalStorageGet(key), []);
+ if (!Array.isArray(list)) return [];
+ return list
+ .filter((item): item is LiveTablePatch => {
+ if (!item || typeof item !== "object") return false;
+ const patch = item as Partial;
+ if (typeof patch.tableName !== "string") return false;
+ const pendingColumns = Array.isArray(patch.pendingColumns) ? patch.pendingColumns : [];
+ if (!pendingColumns.every(isValidColumn)) return false;
+ const droppedColumnNames = sanitizeStringList(patch.droppedColumnNames);
+ const pendingDrop = patch.pendingDrop === true;
+ return pendingColumns.length > 0 || droppedColumnNames.length > 0 || pendingDrop;
+ })
+ .map((patch) => {
+ const pendingColumns = Array.isArray(patch.pendingColumns) ? patch.pendingColumns.filter(isValidColumn) : [];
+ const droppedColumnNames = sanitizeStringList(patch.droppedColumnNames);
+ const next: LiveTablePatch = {
+ tableName: patch.tableName,
+ pendingColumns: pendingColumns.map((col) => ({ ...col })),
+ };
+ if (droppedColumnNames.length) next.droppedColumnNames = droppedColumnNames;
+ if (patch.pendingDrop === true) next.pendingDrop = true;
+ return next;
+ });
+}
+
+export function saveLiveTablePatches(tables: DiagramTable[], connectionId: string, database: string, schema: string): void {
+ if (!connectionId || !database) return;
+ const key = storageKey("live-patches", connectionId, database, schema);
+ const patches: LiveTablePatch[] = [];
+ for (const table of tables) {
+ if (!isLiveTable(table) || !needsDiagramSync(table)) continue;
+ const pendingNames = new Set(table.pendingColumnNames ?? []);
+ const pendingColumns = table.columns.filter((col) => pendingNames.has(col.name));
+ const droppedColumnNames = [...(table.droppedColumnNames ?? [])];
+ if (pendingColumns.length === 0 && droppedColumnNames.length === 0 && !table.pendingDrop) continue;
+ const patch: LiveTablePatch = {
+ tableName: table.name,
+ pendingColumns: pendingColumns.map((col) => ({ ...col })),
+ };
+ if (droppedColumnNames.length) patch.droppedColumnNames = droppedColumnNames;
+ if (table.pendingDrop) patch.pendingDrop = true;
+ patches.push(patch);
+ }
+ safeLocalStorageSet(key, JSON.stringify(patches));
+}
+
+/** Merge saved pending adds/drops onto live tables loaded from DB metadata. */
+export function applyLiveTablePatches(tables: DiagramTable[], patches: LiveTablePatch[]): DiagramTable[] {
+ if (patches.length === 0) return tables;
+ const byName = new Map(patches.map((p) => [p.tableName, p]));
+ return tables.map((table) => {
+ if (!isLiveTable(table)) return table;
+ const patch = byName.get(table.name);
+ if (!patch) return table;
+
+ const existing = new Set(table.columns.map((c) => c.name.toLowerCase()));
+ const pendingColumns = patch.pendingColumns.filter((col) => !existing.has(col.name.toLowerCase()));
+ const columnNames = new Set([...existing, ...pendingColumns.map((col) => col.name.toLowerCase())]);
+ const droppedColumnNames = (patch.droppedColumnNames ?? []).filter((name) => columnNames.has(name.toLowerCase()));
+
+ let next: DiagramTable = table;
+ if (pendingColumns.length > 0) {
+ next = {
+ ...next,
+ columns: [...next.columns, ...pendingColumns.map((col) => ({ ...col }))],
+ pendingColumnNames: pendingColumns.map((col) => col.name),
+ };
+ }
+ if (droppedColumnNames.length > 0) {
+ next = { ...next, droppedColumnNames };
+ }
+ if (patch.pendingDrop) {
+ next = { ...next, pendingDrop: true };
+ }
+ return next;
+ });
+}
diff --git a/apps/desktop/src/lib/diagram/draft-table.ts b/apps/desktop/src/lib/diagram/draft-table.ts
new file mode 100644
index 000000000..7ccbf275a
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/draft-table.ts
@@ -0,0 +1,177 @@
+import type { ColumnInfo, DatabaseType } from "@/types/database";
+import type { DiagramTable } from "./erDiagram";
+import { editableStructureIndexes, hasDroppedColumns, hasPendingColumns, isPendingColumn } from "./erDiagram";
+import type { BuildTableStructureChangeSqlOptions, EditableStructureColumn, EditableStructureIndex } from "@/lib/table/tableStructureEditorSql";
+import { generateUniqueIndexName } from "@/lib/table/tableStructureEditorState";
+import { resolveDiagramDialectAdapter } from "./diagram-dialect-adapter";
+
+export function createDefaultIdColumn(databaseType?: DatabaseType): ColumnInfo {
+ return resolveDiagramDialectAdapter(databaseType).createDefaultIdColumn();
+}
+
+export function createEmptyColumn(name = "column_1", databaseType?: DatabaseType): ColumnInfo {
+ return resolveDiagramDialectAdapter(databaseType).createEmptyColumn(name);
+}
+
+export function createDraftTable(name: string, options?: { withDefaultId?: boolean; databaseType?: DatabaseType }): DiagramTable {
+ const withDefaultId = options?.withDefaultId !== false;
+ const adapter = resolveDiagramDialectAdapter(options?.databaseType);
+ return {
+ name: name.trim(),
+ columns: withDefaultId ? [adapter.createDefaultIdColumn()] : [],
+ foreignKeys: [],
+ indexes: [],
+ origin: "draft",
+ syncStatus: "pending",
+ };
+}
+
+export function createDraftIndex(tableName: string, columns: string[], existingIndexes: EditableStructureIndex[] = []): EditableStructureIndex {
+ const existingNames = existingIndexes.map((index) => index.name);
+ const name = generateUniqueIndexName(tableName, columns, existingNames) || `idx_${tableName}`;
+ return {
+ id: `idx-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
+ name,
+ columns: [...columns],
+ isUnique: false,
+ isPrimary: false,
+ filter: "",
+ indexType: "",
+ includedColumns: [],
+ comment: "",
+ markedForDrop: false,
+ };
+}
+
+export function columnToEditable(column: ColumnInfo, index: number): EditableStructureColumn {
+ return {
+ id: `col-${index}-${column.name}`,
+ name: column.name,
+ dataType: column.data_type,
+ enumValues: column.enum_values ?? undefined,
+ isNullable: column.is_nullable,
+ defaultValue: column.column_default ?? "",
+ comment: column.comment ?? "",
+ isPrimaryKey: column.is_primary_key,
+ extra: {},
+ characterSet: column.character_set ?? undefined,
+ collation: column.collation ?? undefined,
+ markedForDrop: false,
+ };
+}
+
+export function draftTableToCreateSqlOptions(table: DiagramTable, databaseType: DatabaseType | undefined, schema: string | undefined): BuildTableStructureChangeSqlOptions {
+ return {
+ databaseType,
+ schema: schema || undefined,
+ tableName: table.name,
+ columns: table.columns.map(columnToEditable),
+ indexes: editableStructureIndexes(table).filter((index) => !index.markedForDrop),
+ foreignKeys: [],
+ triggers: [],
+ };
+}
+
+/** Live table pending adds/drops → ALTER ADD / DROP COLUMN. */
+export function liveTableToAlterSqlOptions(table: DiagramTable, databaseType: DatabaseType | undefined, schema: string | undefined): BuildTableStructureChangeSqlOptions {
+ const pending = new Set(table.pendingColumnNames ?? []);
+ const dropped = new Set(table.droppedColumnNames ?? []);
+ return {
+ databaseType,
+ schema: schema || undefined,
+ tableName: table.name,
+ columns: table.columns.map((column, index) => {
+ const editable = columnToEditable(column, index);
+ if (pending.has(column.name)) {
+ return editable;
+ }
+ return {
+ ...editable,
+ original: { ...column },
+ originalPosition: index + 1,
+ markedForDrop: dropped.has(column.name),
+ };
+ }),
+ indexes: [],
+ foreignKeys: [],
+ triggers: [],
+ };
+}
+
+export function nextUniqueColumnName(columns: ColumnInfo[], base = "column"): string {
+ const names = new Set(columns.map((c) => c.name.toLowerCase()));
+ let i = 1;
+ while (names.has(`${base}_${i}`.toLowerCase())) i += 1;
+ return `${base}_${i}`;
+}
+
+export function validateDraftTable(table: DiagramTable): string[] {
+ const errors: string[] = [];
+ if (!table.name.trim()) errors.push("Table name is required");
+ if (table.columns.length === 0) errors.push(`Table "${table.name}" needs at least one column`);
+ const seen = new Set();
+ for (const col of table.columns) {
+ if (!col.name.trim()) errors.push(`Table "${table.name}" has an empty column name`);
+ const key = col.name.toLowerCase();
+ if (seen.has(key)) errors.push(`Table "${table.name}" has duplicate column "${col.name}"`);
+ seen.add(key);
+ if (!col.data_type.trim()) errors.push(`Column "${table.name}.${col.name}" needs a type`);
+ }
+ const columnNames = new Set(table.columns.map((c) => c.name.toLowerCase()));
+ for (const index of editableStructureIndexes(table)) {
+ if (index.markedForDrop) continue;
+ if (!index.name.trim()) errors.push(`Table "${table.name}" has an index with an empty name`);
+ if (index.columns.length === 0) errors.push(`Index "${index.name || "(unnamed)"}" on "${table.name}" needs at least one column`);
+ for (const col of index.columns) {
+ if (!columnNames.has(col.toLowerCase())) {
+ errors.push(`Index "${index.name}" references missing column "${col}"`);
+ }
+ }
+ }
+ return errors;
+}
+
+/** Validate pending ADD columns and DROP column marks on a live table before ALTER sync. */
+export function validateLivePendingColumns(table: DiagramTable): string[] {
+ const errors: string[] = [];
+ if (!hasPendingColumns(table) && !hasDroppedColumns(table)) return errors;
+ const pendingNames = table.pendingColumnNames ?? [];
+ const existingNames = new Set(table.columns.filter((col) => !isPendingColumn(table, col.name)).map((col) => col.name.toLowerCase()));
+ const seenPending = new Set();
+ for (const name of pendingNames) {
+ const col = table.columns.find((c) => c.name === name);
+ if (!col) {
+ errors.push(`Table "${table.name}" pending column "${name}" is missing`);
+ continue;
+ }
+ if (!col.name.trim()) errors.push(`Table "${table.name}" has an empty pending column name`);
+ const key = col.name.toLowerCase();
+ if (seenPending.has(key)) errors.push(`Table "${table.name}" has duplicate pending column "${col.name}"`);
+ seenPending.add(key);
+ if (existingNames.has(key)) {
+ errors.push(`Table "${table.name}" pending column conflicts with existing "${col.name}"`);
+ }
+ if (!col.data_type.trim()) errors.push(`Column "${table.name}.${col.name}" needs a type`);
+ }
+ const seenDropped = new Set();
+ for (const name of table.droppedColumnNames ?? []) {
+ const key = name.toLowerCase();
+ if (seenDropped.has(key)) {
+ errors.push(`Table "${table.name}" has duplicate dropped column "${name}"`);
+ continue;
+ }
+ seenDropped.add(key);
+ if (isPendingColumn(table, name)) {
+ errors.push(`Table "${table.name}" cannot drop pending column "${name}"`);
+ continue;
+ }
+ if (!table.columns.some((col) => col.name === name)) {
+ errors.push(`Table "${table.name}" dropped column "${name}" is missing`);
+ }
+ }
+ return errors;
+}
+
+export function hasLiveColumnChanges(table: DiagramTable): boolean {
+ return hasPendingColumns(table) || hasDroppedColumns(table);
+}
diff --git a/apps/desktop/src/lib/diagram/edge-obstacle-router.ts b/apps/desktop/src/lib/diagram/edge-obstacle-router.ts
new file mode 100644
index 000000000..93bc1f39d
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/edge-obstacle-router.ts
@@ -0,0 +1,356 @@
+import { Position } from "@vue-flow/core";
+import { EDGE_ROUTE_OFFSET } from "./diagram-constants";
+
+export type Point = { x: number; y: number };
+
+export type ObstacleRect = {
+ id: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ kind: "table" | "layer";
+ /** For layers: tables contained (used to skip same-layer fill) */
+ tableNames?: string[];
+};
+
+export type RouteInput = {
+ source: Point;
+ target: Point;
+ sourcePosition: Position;
+ targetPosition: Position;
+ obstacles: ObstacleRect[];
+ /** Endpoint table ids to ignore as obstacles */
+ endpointIds: [string, string];
+ offset?: number;
+};
+
+const AXIS_EPS = 0.5;
+
+function nearlyEqual(a: number, b: number): boolean {
+ return Math.abs(a - b) <= AXIS_EPS;
+}
+
+function inflate(rect: ObstacleRect, pad: number): ObstacleRect {
+ return {
+ ...rect,
+ x: rect.x - pad,
+ y: rect.y - pad,
+ width: rect.width + pad * 2,
+ height: rect.height + pad * 2,
+ };
+}
+
+function segmentIntersectsRect(a: Point, b: Point, rect: ObstacleRect): boolean {
+ const minX = Math.min(a.x, b.x);
+ const maxX = Math.max(a.x, b.x);
+ const minY = Math.min(a.y, b.y);
+ const maxY = Math.max(a.y, b.y);
+
+ const rx2 = rect.x + rect.width;
+ const ry2 = rect.y + rect.height;
+
+ if (maxX < rect.x || minX > rx2 || maxY < rect.y || minY > ry2) return false;
+
+ if (nearlyEqual(a.x, b.x)) {
+ const x = a.x;
+ return x >= rect.x && x <= rx2 && maxY >= rect.y && minY <= ry2;
+ }
+ if (nearlyEqual(a.y, b.y)) {
+ const y = a.y;
+ return y >= rect.y && y <= ry2 && maxX >= rect.x && minX <= rx2;
+ }
+ return true;
+}
+
+export function pathHitsObstacles(points: Point[], obstacles: ObstacleRect[]): boolean {
+ for (let i = 0; i < points.length - 1; i++) {
+ for (const rect of obstacles) {
+ if (segmentIntersectsRect(points[i], points[i + 1], rect)) return true;
+ }
+ }
+ return false;
+}
+
+function relevantObstacles(input: RouteInput, pad: number): ObstacleRect[] {
+ const [srcId, tgtId] = input.endpointIds;
+ return input.obstacles
+ .filter((o) => {
+ if (o.kind === "table") {
+ return o.id !== srcId && o.id !== tgtId;
+ }
+ const names = o.tableNames || [];
+ if (names.includes(srcId) || names.includes(tgtId)) return false;
+ return true;
+ })
+ .map((o) => inflate(o, pad));
+}
+
+/** Table rects for the edge endpoints (source/target), optionally inflated. */
+export function endpointRectsFromObstacles(obstacles: ObstacleRect[], endpointIds: [string, string], pad = 0): ObstacleRect[] {
+ const [srcId, tgtId] = endpointIds;
+ return obstacles.filter((o) => o.kind === "table" && (o.id === srcId || o.id === tgtId)).map((o) => (pad > 0 ? inflate(o, pad) : { ...o }));
+}
+
+export function stubOut(point: Point, position: Position, offset: number): Point {
+ if (position === Position.Left) return { x: point.x - offset, y: point.y };
+ if (position === Position.Top) return { x: point.x, y: point.y - offset };
+ if (position === Position.Bottom) return { x: point.x, y: point.y + offset };
+ return { x: point.x + offset, y: point.y };
+}
+
+/** Point just outside the target handle before the final inbound stub. */
+export function stubIn(point: Point, position: Position, offset: number): Point {
+ if (position === Position.Right) return { x: point.x + offset, y: point.y };
+ if (position === Position.Top) return { x: point.x, y: point.y - offset };
+ if (position === Position.Bottom) return { x: point.x, y: point.y + offset };
+ return { x: point.x - offset, y: point.y };
+}
+
+function pointOnRectBorder(p: Point, rect: ObstacleRect): boolean {
+ const rx2 = rect.x + rect.width;
+ const ry2 = rect.y + rect.height;
+ const onVertical = (nearlyEqual(p.x, rect.x) || nearlyEqual(p.x, rx2)) && p.y >= rect.y - AXIS_EPS && p.y <= ry2 + AXIS_EPS;
+ const onHorizontal = (nearlyEqual(p.y, rect.y) || nearlyEqual(p.y, ry2)) && p.x >= rect.x - AXIS_EPS && p.x <= rx2 + AXIS_EPS;
+ return onVertical || onHorizontal;
+}
+
+/**
+ * True when a segment runs along/through an endpoint table beyond a short handle stub.
+ * Short outward/inward stubs (length ≤ stubLen) that touch the border are allowed.
+ */
+export function pathSkimsEndpoints(points: Point[], endpointRects: ObstacleRect[], stubLen = EDGE_ROUTE_OFFSET): boolean {
+ if (endpointRects.length === 0 || points.length < 2) return false;
+ for (let i = 0; i < points.length - 1; i++) {
+ const a = points[i];
+ const b = points[i + 1];
+ const segLen = Math.hypot(b.x - a.x, b.y - a.y);
+ for (const rect of endpointRects) {
+ if (!segmentIntersectsRect(a, b, rect)) continue;
+ const stubOk = segLen <= stubLen + AXIS_EPS && (pointOnRectBorder(a, rect) || pointOnRectBorder(b, rect));
+ if (stubOk) continue;
+ return true;
+ }
+ }
+ return false;
+}
+
+/**
+ * Build candidate orthogonal polylines with exit/entry stubs; return the shortest that
+ * clears non-endpoint obstacles and does not skim endpoint tables on middle segments.
+ */
+export function routeOrthogonalAroundObstacles(input: RouteInput): Point[] | null {
+ const offset = input.offset ?? EDGE_ROUTE_OFFSET;
+ const obstacles = relevantObstacles(input, 6);
+ const endpoints = endpointRectsFromObstacles(input.obstacles, input.endpointIds, 0);
+ const { source: s, target: t } = input;
+ const so = stubOut(s, input.sourcePosition, offset);
+ const si = stubIn(t, input.targetPosition, offset);
+
+ // Corridors from stubOut → stubIn (handles attached outside)
+ const corridors: Point[][] = [
+ [so, { x: si.x, y: so.y }, si],
+ [so, { x: so.x, y: si.y }, si],
+ [so, { x: so.x + offset, y: so.y }, { x: so.x + offset, y: si.y }, si],
+ [so, { x: so.x - offset, y: so.y }, { x: so.x - offset, y: si.y }, si],
+ [so, { x: so.x, y: so.y + offset }, { x: si.x, y: so.y + offset }, si],
+ [so, { x: so.x, y: so.y - offset }, { x: si.x, y: so.y - offset }, si],
+ [so, { x: si.x + offset, y: so.y }, { x: si.x + offset, y: si.y }, si],
+ [so, { x: si.x - offset, y: so.y }, { x: si.x - offset, y: si.y }, si],
+ [so, { x: so.x, y: Math.min(so.y, si.y) - offset }, { x: si.x, y: Math.min(so.y, si.y) - offset }, si],
+ [so, { x: so.x, y: Math.max(so.y, si.y) + offset }, { x: si.x, y: Math.max(so.y, si.y) + offset }, si],
+ [so, { x: Math.min(so.x, si.x) - offset, y: so.y }, { x: Math.min(so.x, si.x) - offset, y: si.y }, si],
+ [so, { x: Math.max(so.x, si.x) + offset, y: so.y }, { x: Math.max(so.x, si.x) + offset, y: si.y }, si],
+ ];
+
+ let best: Point[] | null = null;
+ let bestLen = Infinity;
+ for (const corridor of corridors) {
+ const cleaned = collapseColinearPoints(dedupePoints([s, ...corridor, t]));
+ if (cleaned.length < 2) continue;
+ if (pathHitsObstacles(cleaned, obstacles)) continue;
+ if (pathSkimsEndpoints(cleaned, endpoints, offset)) continue;
+ const len = polylineLength(cleaned);
+ if (len < bestLen) {
+ bestLen = len;
+ best = cleaned;
+ }
+ }
+
+ return best;
+}
+
+export function polylineLength(points: Point[]): number {
+ let total = 0;
+ for (let i = 1; i < points.length; i++) {
+ total += Math.hypot(points[i].x - points[i - 1].x, points[i].y - points[i - 1].y);
+ }
+ return total;
+}
+
+export function dedupePoints(points: Point[]): Point[] {
+ const out: Point[] = [];
+ for (const p of points) {
+ const last = out[out.length - 1];
+ if (!last || !nearlyEqual(last.x, p.x) || !nearlyEqual(last.y, p.y)) {
+ out.push({ x: p.x, y: p.y });
+ }
+ }
+ return out;
+}
+
+/** Collapse consecutive collinear points on axis-aligned polylines. */
+export function collapseColinearPoints(points: Point[]): Point[] {
+ if (points.length <= 2) return points.map((p) => ({ ...p }));
+ const out: Point[] = [{ ...points[0] }];
+ for (let i = 1; i < points.length - 1; i++) {
+ const prev = out[out.length - 1];
+ const cur = points[i];
+ const next = points[i + 1];
+ const colinearH = nearlyEqual(prev.y, cur.y) && nearlyEqual(cur.y, next.y);
+ const colinearV = nearlyEqual(prev.x, cur.x) && nearlyEqual(cur.x, next.x);
+ if (colinearH || colinearV) continue;
+ out.push({ ...cur });
+ }
+ out.push({ ...points[points.length - 1] });
+ return dedupePoints(out);
+}
+
+function isOrthogonalPolyline(points: Point[]): boolean {
+ for (let i = 0; i < points.length - 1; i++) {
+ const a = points[i];
+ const b = points[i + 1];
+ if (!nearlyEqual(a.x, b.x) && !nearlyEqual(a.y, b.y)) return false;
+ }
+ return true;
+}
+
+/** Orthogonal elbow from a → b (try both corners; prefer shorter). */
+function orthogonalConnect(a: Point, b: Point): Point[] {
+ if (nearlyEqual(a.x, b.x) || nearlyEqual(a.y, b.y)) return [a, b];
+ const viaH: Point[] = [a, { x: b.x, y: a.y }, b];
+ const viaV: Point[] = [a, { x: a.x, y: b.y }, b];
+ const len = (pts: Point[]) => pts.reduce((sum, p, i) => (i === 0 ? 0 : sum + Math.hypot(p.x - pts[i - 1].x, p.y - pts[i - 1].y)), 0);
+ return len(viaH) <= len(viaV) ? viaH : viaV;
+}
+
+/**
+ * Infer Vue Flow handle ids from ELK waypoint exit/entry directions.
+ */
+export function handlesFromWaypoints(waypoints: Point[]): { sourceHandle: string; targetHandle: string } | null {
+ if (waypoints.length < 2) return null;
+ const a = waypoints[0];
+ const b = waypoints[1];
+ const c = waypoints[waypoints.length - 2];
+ const d = waypoints[waypoints.length - 1];
+
+ const outDx = b.x - a.x;
+ const outDy = b.y - a.y;
+ let sourceHandle: string;
+ if (Math.abs(outDx) >= Math.abs(outDy)) {
+ sourceHandle = outDx >= 0 ? "right" : "left";
+ } else {
+ sourceHandle = outDy >= 0 ? "bottom" : "top";
+ }
+
+ const inDx = d.x - c.x;
+ const inDy = d.y - c.y;
+ let targetHandle: string;
+ if (Math.abs(inDx) >= Math.abs(inDy)) {
+ // Arriving with +dx means coming from the left → hit left side
+ targetHandle = inDx >= 0 ? "left-target" : "right-target";
+ } else {
+ targetHandle = inDy >= 0 ? "top-target" : "bottom-target";
+ }
+
+ return { sourceHandle, targetHandle };
+}
+
+export type AlignWaypointsOptions = {
+ obstacles?: ObstacleRect[];
+ endpointIds?: [string, string];
+};
+
+/**
+ * Attach live Vue Flow handle endpoints to ELK interior bends with orthogonal elbows.
+ * Does NOT translate the whole polyline by source delta (that caused diagonals / V shapes).
+ * Returns null when the result is unusable (caller should fall back to obstacle router).
+ */
+export function alignWaypointsToEndpoints(waypoints: Point[], sourceX: number, sourceY: number, targetX: number, targetY: number, options?: AlignWaypointsOptions): Point[] | null {
+ if (waypoints.length < 2) return null;
+
+ const source = { x: sourceX, y: sourceY };
+ const target = { x: targetX, y: targetY };
+ const interior = waypoints.slice(1, -1);
+
+ let merged: Point[];
+ if (interior.length === 0) {
+ merged = dedupePoints(orthogonalConnect(source, target));
+ } else {
+ const head = orthogonalConnect(source, interior[0]);
+ const tail = orthogonalConnect(interior[interior.length - 1], target);
+ merged = dedupePoints([...head.slice(0, -1), ...interior, ...tail.slice(1)]);
+ }
+
+ if (merged.length < 2 || !isOrthogonalPolyline(merged)) return null;
+
+ const cleaned = collapseColinearPoints(merged);
+
+ if (options?.obstacles?.length && options.endpointIds) {
+ const obstacles = relevantObstacles(
+ {
+ source,
+ target,
+ sourcePosition: Position.Right,
+ targetPosition: Position.Left,
+ obstacles: options.obstacles,
+ endpointIds: options.endpointIds,
+ },
+ 4,
+ );
+ if (pathHitsObstacles(cleaned, obstacles)) return null;
+ const endpoints = endpointRectsFromObstacles(options.obstacles, options.endpointIds, 0);
+ if (pathSkimsEndpoints(cleaned, endpoints, EDGE_ROUTE_OFFSET)) return null;
+ }
+
+ return cleaned;
+}
+
+export function pointsToSvgPath(points: Point[]): string {
+ if (points.length === 0) return "";
+ return points.map((p, i) => `${i === 0 ? "M" : "L"}${p.x},${p.y}`).join(" ");
+}
+
+/**
+ * Point at fraction `t` (0..1) along the polyline by arc length.
+ * Out-of-range t is clamped.
+ */
+export function pointAlongPolyline(points: Point[], t: number): Point {
+ if (points.length === 0) return { x: 0, y: 0 };
+ if (points.length === 1) return { ...points[0] };
+ const clamped = Math.min(1, Math.max(0, t));
+ let total = 0;
+ const segs: number[] = [];
+ for (let i = 0; i < points.length - 1; i++) {
+ const d = Math.hypot(points[i + 1].x - points[i].x, points[i + 1].y - points[i].y);
+ segs.push(d);
+ total += d;
+ }
+ if (total === 0) return { ...points[0] };
+ let remain = total * clamped;
+ for (let i = 0; i < segs.length; i++) {
+ if (remain <= segs[i]) {
+ const ratio = segs[i] === 0 ? 0 : remain / segs[i];
+ return {
+ x: points[i].x + (points[i + 1].x - points[i].x) * ratio,
+ y: points[i].y + (points[i + 1].y - points[i].y) * ratio,
+ };
+ }
+ remain -= segs[i];
+ }
+ return { ...points[points.length - 1] };
+}
+
+export function midpointAlongPolyline(points: Point[]): Point {
+ return pointAlongPolyline(points, 0.5);
+}
diff --git a/apps/desktop/src/lib/diagram/elk-layout.ts b/apps/desktop/src/lib/diagram/elk-layout.ts
new file mode 100644
index 000000000..26659ffd7
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/elk-layout.ts
@@ -0,0 +1,407 @@
+import ELK from "elkjs/lib/elk.bundled.js";
+import type { LayoutOptions, DiagramNode, DiagramEdge, DiagramLayer } from "@/types/diagram";
+import { CARD_WIDTH, COLUMN_ROW_HEIGHT, CARD_HEADER_HEIGHT, CARD_BOTTOM_PADDING, LAYER_PADDING, LAYER_HEADER_HEIGHT } from "./diagram-constants";
+import { handlesFromWaypoints, type Point } from "./edge-obstacle-router";
+
+const elk = new ELK();
+
+export interface ElkNode {
+ id: string;
+ width: number;
+ height: number;
+ x?: number;
+ y?: number;
+ children?: ElkNode[];
+ labels?: { text: string }[];
+ layoutOptions?: Record;
+}
+
+export interface ElkEdge {
+ id: string;
+ sources: string[];
+ targets: string[];
+ sections?: {
+ id?: string;
+ startPoint: { x: number; y: number };
+ endPoint: { x: number; y: number };
+ bendPoints?: { x: number; y: number }[];
+ }[];
+}
+
+export interface ElkGraph {
+ id: string;
+ children: ElkNode[];
+ edges: ElkEdge[];
+ layoutOptions?: Record;
+}
+
+export interface LayerLayoutInfo {
+ layerId: string;
+ layerName: string;
+ color: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+}
+
+function calculateTableHeight(tableColumns: unknown[]): number {
+ return CARD_HEADER_HEIGHT + tableColumns.length * COLUMN_ROW_HEIGHT + CARD_BOTTOM_PADDING;
+}
+
+export async function computeLayout(nodes: DiagramNode[], edges: DiagramEdge[], options: LayoutOptions = {}): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[] }> {
+ const elkGraph = buildElkGraph(nodes, edges);
+ const elkOptions = buildElkOptions(options);
+
+ const result = (await elk.layout({
+ ...elkGraph,
+ layoutOptions: elkOptions,
+ } as Parameters[0])) as ElkGraph;
+
+ return extractLayoutResult(result, nodes, edges);
+}
+
+/**
+ * Layout with layers.
+ * - auto layers: nested in ELK hierarchy (children get relative coords on write-back)
+ * - free layers: tables keep existing absolute positions; layer box is size-to-fit after
+ * - unassigned tables: laid out at root with auto layers
+ */
+export async function computeLayoutWithLayers(nodes: DiagramNode[], edges: DiagramEdge[], layers: DiagramLayer[], options: LayoutOptions = {}): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[]; layerLayouts: LayerLayoutInfo[] }> {
+ const visibleLayers = layers.filter((l) => l.visible && l.tableNames.length > 0);
+ const autoLayers = visibleLayers.filter((l) => (l.layoutMode ?? "auto") === "auto");
+ const freeLayers = visibleLayers.filter((l) => (l.layoutMode ?? "auto") === "free");
+
+ if (visibleLayers.length === 0) {
+ const result = await computeLayout(nodes, edges, options);
+ return { ...result, layerLayouts: [] };
+ }
+
+ const freeTableIds = new Set(freeLayers.flatMap((l) => l.tableNames));
+ const nodesForElk = nodes.filter((n) => !freeTableIds.has(n.id));
+ const edgesForElk = edges.filter((e) => !freeTableIds.has(e.source) && !freeTableIds.has(e.target));
+
+ const positionMap = new Map();
+ for (const node of nodes) {
+ positionMap.set(node.id, { ...node.position });
+ }
+
+ const layerLayouts: LayerLayoutInfo[] = [];
+ /** Edges that went through ELK, keyed by id (may include waypoints). */
+ const elkEdgeById = new Map();
+
+ if (nodesForElk.length > 0) {
+ const elkGraph = buildHierarchicalElkGraph(nodesForElk, edgesForElk, autoLayers);
+ const elkOptions = buildElkOptions(options);
+ const result = (await elk.layout({
+ ...elkGraph,
+ layoutOptions: elkOptions,
+ } as Parameters[0])) as ElkGraph;
+
+ const extracted = extractHierarchicalLayoutResult(result, nodesForElk, edgesForElk, autoLayers);
+ for (const node of extracted.nodes) {
+ positionMap.set(node.id, { ...node.position });
+ }
+ layerLayouts.push(...extracted.layerLayouts);
+ for (const edge of extracted.edges) {
+ elkEdgeById.set(edge.id, edge);
+ }
+ }
+
+ // Free layers: keep table positions; compute size-to-fit boxes
+ for (const layer of freeLayers) {
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ let hasAny = false;
+
+ for (const tableName of layer.tableNames) {
+ const node = nodes.find((n) => n.id === tableName);
+ const pos = positionMap.get(tableName) || node?.position;
+ if (!pos) continue;
+ hasAny = true;
+ const height = calculateTableHeight(node?.data?.table?.columns || []);
+ minX = Math.min(minX, pos.x);
+ minY = Math.min(minY, pos.y);
+ maxX = Math.max(maxX, pos.x + CARD_WIDTH);
+ maxY = Math.max(maxY, pos.y + height);
+ }
+
+ if (!hasAny) continue;
+
+ const x = minX - LAYER_PADDING;
+ const y = minY - LAYER_HEADER_HEIGHT - LAYER_PADDING;
+ const width = Math.max(160, maxX - minX + LAYER_PADDING * 2);
+ const height = Math.max(100, maxY - minY + LAYER_HEADER_HEIGHT + LAYER_PADDING * 2);
+
+ layerLayouts.push({
+ layerId: layer.id,
+ layerName: layer.name,
+ color: layer.color,
+ x,
+ y,
+ width,
+ height,
+ });
+ }
+
+ const newNodes = nodes.map((node) => ({
+ ...node,
+ position: positionMap.get(node.id) || node.position,
+ }));
+
+ const newEdges = edges.map((edge) => {
+ const fromElk = elkEdgeById.get(edge.id);
+ if (!fromElk) return edge;
+ return {
+ ...edge,
+ waypoints: fromElk.waypoints,
+ sourceHandle: fromElk.sourceHandle,
+ targetHandle: fromElk.targetHandle,
+ };
+ });
+
+ return {
+ nodes: newNodes,
+ edges: newEdges,
+ layerLayouts,
+ };
+}
+
+function buildElkGraph(nodes: DiagramNode[], edges: DiagramEdge[]): ElkGraph {
+ const elkNodes: ElkNode[] = nodes.map((node) => ({
+ id: node.id,
+ width: CARD_WIDTH,
+ height: calculateTableHeight(node.data?.table?.columns || []),
+ }));
+
+ const elkEdges: ElkEdge[] = edges.map((edge) => ({
+ id: edge.id,
+ sources: [edge.source],
+ targets: [edge.target],
+ }));
+
+ return {
+ id: "diagram",
+ children: elkNodes,
+ edges: elkEdges,
+ };
+}
+
+function buildHierarchicalElkGraph(nodes: DiagramNode[], edges: DiagramEdge[], autoLayers: DiagramLayer[]): ElkGraph {
+ const elkLayers: ElkNode[] = [];
+ const tableIdSet = new Set();
+
+ for (const layer of autoLayers) {
+ const layerTables = nodes.filter((n) => layer.tableNames.includes(n.id));
+ if (layerTables.length === 0) continue;
+
+ const elkTableNodes = layerTables.map((tableNode) => ({
+ id: tableNode.id,
+ width: CARD_WIDTH,
+ height: calculateTableHeight(tableNode.data?.table?.columns || []),
+ }));
+
+ layerTables.forEach((n) => tableIdSet.add(n.id));
+
+ elkLayers.push({
+ id: layer.id,
+ width: 0,
+ height: 0,
+ children: elkTableNodes,
+ labels: [{ text: layer.name }],
+ layoutOptions: {
+ "elk.padding": `[top=${LAYER_HEADER_HEIGHT + LAYER_PADDING},left=${LAYER_PADDING},bottom=${LAYER_PADDING},right=${LAYER_PADDING}]`,
+ },
+ });
+ }
+
+ const unassignedNodes = nodes.filter((n) => !tableIdSet.has(n.id));
+ const unassignedElkNodes = unassignedNodes.map((node) => ({
+ id: node.id,
+ width: CARD_WIDTH,
+ height: calculateTableHeight(node.data?.table?.columns || []),
+ }));
+
+ return {
+ id: "diagram",
+ children: [...elkLayers, ...unassignedElkNodes],
+ edges: edges.map((edge) => ({
+ id: edge.id,
+ sources: [edge.source],
+ targets: [edge.target],
+ })),
+ };
+}
+
+function buildElkOptions(options: LayoutOptions): Record {
+ const directionMap: Record = {
+ LR: "RIGHT",
+ TB: "DOWN",
+ RL: "LEFT",
+ BT: "UP",
+ };
+
+ return {
+ "elk.algorithm": "layered",
+ "elk.direction": directionMap[options.direction || "LR"],
+ "elk.layered.edgeRouting": "ORTHOGONAL",
+ "elk.layered.nodePlacement": "BRANDES_KOEPF",
+ "elk.layered.crossingMinimization": "LAYER_SWEEP",
+ "elk.layered.layering.strategy": "NETWORK_SIMPLEX",
+ "elk.layered.separateConnectedComponents": "true",
+ "elk.spacing.nodeNode": "60",
+ "elk.spacing.layerLayer": "80",
+ "elk.padding": `${LAYER_PADDING}`,
+ "elk.layered.edgeSpacing": "20",
+ "elk.layered.nodeSpacing": "50",
+ "elk.layered.bendPointSpacing": "10",
+ "elk.hierarchyHandling": "INCLUDE_CHILDREN",
+ };
+}
+
+function extractLayoutResult(result: ElkGraph, originalNodes: DiagramNode[], originalEdges: DiagramEdge[]): { nodes: DiagramNode[]; edges: DiagramEdge[] } {
+ const nodePositionMap = new Map();
+
+ for (const child of result.children || []) {
+ if (child.x !== undefined && child.y !== undefined) {
+ nodePositionMap.set(child.id, { x: child.x, y: child.y });
+ }
+ }
+
+ const edgeBendPoints = new Map();
+ for (const edge of result.edges || []) {
+ if (edge.sections) {
+ const points: { x: number; y: number }[][] = [];
+ for (const section of edge.sections) {
+ const sectionPoints: { x: number; y: number }[] = [];
+ sectionPoints.push(section.startPoint);
+ if (section.bendPoints) {
+ sectionPoints.push(...section.bendPoints);
+ }
+ sectionPoints.push(section.endPoint);
+ points.push(sectionPoints);
+ }
+ edgeBendPoints.set(edge.id, points);
+ }
+ }
+
+ const newNodes = originalNodes.map((node) => {
+ const position = nodePositionMap.get(node.id);
+ return {
+ ...node,
+ position: position || node.position,
+ };
+ });
+
+ const newEdges = originalEdges.map((edge) => {
+ const sections = edgeBendPoints.get(edge.id);
+ const waypoints: Point[] | undefined = sections?.length ? sections.flatMap((section, index) => (index === 0 ? section : section.slice(1))) : undefined;
+ const handles = waypoints ? handlesFromWaypoints(waypoints) : null;
+ return {
+ ...edge,
+ waypoints,
+ sourceHandle: handles?.sourceHandle,
+ targetHandle: handles?.targetHandle,
+ };
+ });
+
+ return {
+ nodes: newNodes,
+ edges: newEdges,
+ };
+}
+
+/**
+ * Write back ABSOLUTE positions for tables (parent absolute + child relative from ELK).
+ * Adapter converts to relative for Vue Flow parentNode.
+ */
+function extractHierarchicalLayoutResult(result: ElkGraph, originalNodes: DiagramNode[], originalEdges: DiagramEdge[], layers: DiagramLayer[]): { nodes: DiagramNode[]; edges: DiagramEdge[]; layerLayouts: LayerLayoutInfo[] } {
+ const absolutePositions = new Map();
+ const layerLayouts: LayerLayoutInfo[] = [];
+
+ for (const child of result.children || []) {
+ const childX = child.x || 0;
+ const childY = child.y || 0;
+ const layer = layers.find((l) => l.id === child.id);
+
+ if (layer) {
+ layerLayouts.push({
+ layerId: layer.id,
+ layerName: layer.name,
+ color: layer.color,
+ x: childX,
+ y: childY,
+ width: child.width || 320,
+ height: child.height || 200,
+ });
+
+ // Children coords from ELK are relative to the layer node
+ for (const nested of child.children || []) {
+ absolutePositions.set(nested.id, {
+ x: childX + (nested.x || 0),
+ y: childY + (nested.y || 0),
+ });
+ }
+ } else {
+ absolutePositions.set(child.id, { x: childX, y: childY });
+ }
+ }
+
+ const edgeBendPoints = new Map();
+ for (const edge of result.edges || []) {
+ if (edge.sections) {
+ const points: { x: number; y: number }[][] = [];
+ for (const section of edge.sections) {
+ const sectionPoints: { x: number; y: number }[] = [];
+ sectionPoints.push(section.startPoint);
+ if (section.bendPoints) {
+ sectionPoints.push(...section.bendPoints);
+ }
+ sectionPoints.push(section.endPoint);
+ points.push(sectionPoints);
+ }
+ edgeBendPoints.set(edge.id, points);
+ }
+ }
+
+ const newNodes = originalNodes.map((node) => {
+ const position = absolutePositions.get(node.id);
+ return {
+ ...node,
+ position: position || node.position,
+ };
+ });
+
+ const newEdges = originalEdges.map((edge) => {
+ const sections = edgeBendPoints.get(edge.id);
+ let waypoints: Point[] | undefined = sections?.length ? sections.flatMap((section, index) => (index === 0 ? section : section.slice(1))) : undefined;
+
+ if (waypoints?.length) {
+ const srcLayer = layers.find((l) => l.tableNames.includes(edge.source));
+ const tgtLayer = layers.find((l) => l.tableNames.includes(edge.target));
+ // Same-layer edges: ELK section coords are relative to the layer compound node
+ if (srcLayer && tgtLayer && srcLayer.id === tgtLayer.id) {
+ const layout = layerLayouts.find((l) => l.layerId === srcLayer.id);
+ if (layout) {
+ waypoints = waypoints.map((p) => ({ x: p.x + layout.x, y: p.y + layout.y }));
+ }
+ }
+ }
+
+ const handles = waypoints ? handlesFromWaypoints(waypoints) : null;
+ return {
+ ...edge,
+ waypoints,
+ sourceHandle: handles?.sourceHandle,
+ targetHandle: handles?.targetHandle,
+ };
+ });
+
+ return {
+ nodes: newNodes,
+ edges: newEdges,
+ layerLayouts,
+ };
+}
diff --git a/apps/desktop/src/lib/diagram/engineeringDiagram.ts b/apps/desktop/src/lib/diagram/engineeringDiagram.ts
index 3c31e1aa2..d99812f18 100644
--- a/apps/desktop/src/lib/diagram/engineeringDiagram.ts
+++ b/apps/desktop/src/lib/diagram/engineeringDiagram.ts
@@ -65,7 +65,9 @@ export interface EngineeringRelationshipNode {
id: string;
label: string;
sourceTable: string;
+ sourceColumn: string;
targetTable: string;
+ targetColumn: string;
sourceCardinality: "1" | "N";
targetCardinality: "1" | "N";
x: number;
@@ -283,6 +285,13 @@ function normalizeDiagram(diagram: Omit): Engineer
};
}
+function attributeCenter(attribute: EngineeringAttributeNode): DiagramPosition {
+ return {
+ x: attribute.x + attribute.width / 2,
+ y: attribute.y + attribute.height / 2,
+ };
+}
+
export function buildEngineeringDiagram(tables: DiagramTable[], relationships: DiagramRelationship[], positions: Record): EngineeringDiagram {
const clusters = new Map(tables.map((table) => [table.name, buildEngineeringCluster(table)]));
const rows = orderedTableRows(tables, positions);
@@ -321,21 +330,26 @@ export function buildEngineeringDiagram(tables: DiagramTable[], relationships: D
nextRowY += rowHeight + ENGINEERING_CLUSTER_GAP_Y;
});
const entityMap = new Map(entities.map((entity) => [entity.name, entity]));
+ const attributeMap = new Map(attributes.map((attr) => [`${attr.tableName}:${attr.columnName}`, attr]));
const orderedEntities = tables.map((table) => entityMap.get(table.name)).filter((entity): entity is EngineeringEntityNode => entity !== undefined);
const relationshipNodes: EngineeringRelationshipNode[] = relationships.flatMap((relationship) => {
- const source = entityMap.get(relationship.sourceTable);
- const target = entityMap.get(relationship.targetTable);
- if (!source || !target) return [];
+ const sourceEntity = entityMap.get(relationship.sourceTable);
+ const targetEntity = entityMap.get(relationship.targetTable);
+ const sourceAttr = attributeMap.get(`${relationship.sourceTable}:${relationship.sourceColumn}`);
+ const targetAttr = attributeMap.get(`${relationship.targetTable}:${relationship.targetColumn}`);
+ if (!sourceEntity || !targetEntity) return [];
- const sourceCenter = entityCenter(source);
- const targetCenter = entityCenter(target);
+ const sourceCenter = sourceAttr ? attributeCenter(sourceAttr) : entityCenter(sourceEntity);
+ const targetCenter = targetAttr ? attributeCenter(targetAttr) : entityCenter(targetEntity);
return [
{
id: relationship.id,
label: relationshipLabel(relationship),
sourceTable: relationship.sourceTable,
+ sourceColumn: relationship.sourceColumn,
targetTable: relationship.targetTable,
+ targetColumn: relationship.targetColumn,
sourceCardinality: relationship.sourceCardinality ?? "N",
targetCardinality: relationship.targetCardinality ?? "1",
x: (sourceCenter.x + targetCenter.x) / 2 - ENGINEERING_RELATIONSHIP_WIDTH / 2,
diff --git a/apps/desktop/src/lib/diagram/erDiagram.ts b/apps/desktop/src/lib/diagram/erDiagram.ts
index ecdde3927..f8f4221aa 100644
--- a/apps/desktop/src/lib/diagram/erDiagram.ts
+++ b/apps/desktop/src/lib/diagram/erDiagram.ts
@@ -1,16 +1,84 @@
import type { ColumnInfo, ForeignKeyInfo, IndexInfo } from "@/types/database";
+import type { EditableStructureIndex } from "@/lib/table/tableStructureEditorSql";
+
+export type DiagramTableOrigin = "live" | "draft";
+
+/** Live metadata (`IndexInfo`) or draft editor indexes (`EditableStructureIndex`). */
+export type DiagramTableIndex = IndexInfo | EditableStructureIndex;
+
+export function isEditableStructureIndex(index: DiagramTableIndex): index is EditableStructureIndex {
+ return "isUnique" in index || "markedForDrop" in index;
+}
+
+export function editableStructureIndexes(table: DiagramTable): EditableStructureIndex[] {
+ return (table.indexes ?? []).filter(isEditableStructureIndex);
+}
export interface DiagramTable {
name: string;
columns: ColumnInfo[];
foreignKeys: ForeignKeyInfo[];
- indexes?: IndexInfo[];
+ /** Unique/PK indexes used for FK cardinality; drafts also sync via buildCreateTableSql */
+ indexes?: DiagramTableIndex[];
+ /** live = from DB metadata; draft = local design not yet synced */
+ origin?: DiagramTableOrigin;
+ syncStatus?: "pending" | "synced" | "error";
+ /**
+ * Live tables only: column names not yet in DB (subset of `columns`).
+ * Editable in Inspector; synced via ALTER ADD COLUMN.
+ */
+ pendingColumnNames?: string[];
+ /**
+ * Live tables only: existing column names marked for DROP COLUMN on sync.
+ * Columns remain in `columns` until sync/reload.
+ */
+ droppedColumnNames?: string[];
+ /** Live tables only: marked for DROP TABLE on sync (hidden from canvas). */
+ pendingDrop?: boolean;
+}
+
+export function isDraftTable(table: DiagramTable): boolean {
+ return (table.origin ?? "live") === "draft";
+}
+
+export function isLiveTable(table: DiagramTable): boolean {
+ return !isDraftTable(table);
+}
+
+export function hasPendingColumns(table: DiagramTable): boolean {
+ return (table.pendingColumnNames?.length ?? 0) > 0;
+}
+
+export function isPendingColumn(table: DiagramTable, columnName: string): boolean {
+ return (table.pendingColumnNames ?? []).some((name) => name === columnName);
+}
+
+export function hasDroppedColumns(table: DiagramTable): boolean {
+ return (table.droppedColumnNames?.length ?? 0) > 0;
+}
+
+export function isDroppedColumn(table: DiagramTable, columnName: string): boolean {
+ return (table.droppedColumnNames ?? []).some((name) => name === columnName);
+}
+
+/** Tables that need sync: draft creates, live column adds/drops, or pending DROP TABLE. */
+export function needsDiagramSync(table: DiagramTable): boolean {
+ return isDraftTable(table) || hasPendingColumns(table) || hasDroppedColumns(table) || !!table.pendingDrop;
+}
+
+/** Soft-deleted tables stay in state for Sync but must not be re-added to layers/canvas pickers. */
+export function isDiagramTableAssignable(table: DiagramTable): boolean {
+ return !table.pendingDrop;
+}
+
+export function filterAssignableDiagramTables(tables: DiagramTable[]): DiagramTable[] {
+ return tables.filter(isDiagramTableAssignable);
}
export interface DiagramRelationship {
id: string;
name: string;
- kind: "foreign-key" | "custom";
+ kind: "foreign-key" | "custom" | "inferred";
sourceTable: string;
sourceColumn: string;
targetTable: string;
@@ -43,6 +111,7 @@ export interface DiagramPosition {
export interface DiagramLayoutOptions {
columnsPerRow?: number;
cardWidth?: number;
+ /** When set, every row advances by this height (legacy/tests). When omitted, row height follows tallest table in the row. */
rowHeight?: number;
gapX?: number;
gapY?: number;
@@ -57,6 +126,7 @@ function columnExists(table: DiagramTable | undefined, columnName: string): bool
return !!table?.columns.some((column) => column.name === columnName);
}
+/** True when every unique-key column appears among the FK source columns (unique ⊆ FK). */
function sourceColumnsContainUniqueKey(sourceColumns: string[], keyColumns: string[]): boolean {
if (keyColumns.length === 0) return false;
const sourceColumnSet = new Set(sourceColumns);
@@ -68,11 +138,31 @@ function foreignKeySourceColumns(table: DiagramTable, foreignKey: ForeignKeyInfo
return table.foreignKeys.filter((candidate) => candidate.name === foreignKey.name && candidate.ref_table === foreignKey.ref_table).map((candidate) => candidate.column);
}
-function foreignKeySourceCardinality(table: DiagramTable, foreignKey: ForeignKeyInfo): "1" | "N" {
+function diagramIndexIsUnique(index: DiagramTableIndex): boolean {
+ if ("isUnique" in index) return !!(index.isUnique || index.isPrimary);
+ return !!(index.is_unique || index.is_primary);
+}
+
+function diagramIndexFilter(index: DiagramTableIndex): string {
+ return (index.filter ?? "").trim();
+}
+
+function diagramIndexMarkedForDrop(index: DiagramTableIndex): boolean {
+ return "markedForDrop" in index && !!index.markedForDrop;
+}
+
+export function foreignKeySourceCardinality(table: DiagramTable, foreignKey: ForeignKeyInfo): "1" | "N" {
const sourceColumns = foreignKeySourceColumns(table, foreignKey);
const primaryKeyColumns = table.columns.filter((column) => column.is_primary_key).map((column) => column.name);
if (sourceColumnsContainUniqueKey(sourceColumns, primaryKeyColumns)) return "1";
- return table.indexes?.some((index) => (index.is_unique || index.is_primary) && !index.filter?.trim() && sourceColumnsContainUniqueKey(sourceColumns, index.columns)) ? "1" : "N";
+
+ if (sourceColumns.length === 1) {
+ const col = table.columns.find((column) => column.name === sourceColumns[0]);
+ if (col?.is_unique) return "1";
+ }
+
+ const hasUniqueIndex = (table.indexes ?? []).some((index) => diagramIndexIsUnique(index) && !diagramIndexFilter(index) && !diagramIndexMarkedForDrop(index) && sourceColumnsContainUniqueKey(sourceColumns, index.columns));
+ return hasUniqueIndex ? "1" : "N";
}
function customRelationshipId(relationship: Omit): string {
@@ -114,7 +204,60 @@ export function buildDiagramRelationships(tables: DiagramTable[], customRelation
kind: "custom" as const,
}));
- return [...foreignKeyRelationships, ...custom];
+ return deduplicateRelationships([...foreignKeyRelationships, ...custom]);
+}
+
+export function deduplicateRelationships(relationships: DiagramRelationship[]): DiagramRelationship[] {
+ const seen = new Set();
+ const unique: DiagramRelationship[] = [];
+
+ for (const rel of relationships) {
+ const key = `${rel.sourceTable}:${rel.sourceColumn}:${rel.targetTable}:${rel.targetColumn}`;
+ if (!seen.has(key)) {
+ seen.add(key);
+ unique.push(rel);
+ }
+ }
+
+ return unique;
+}
+
+export interface InferredRelationshipInput {
+ id: string;
+ sourceTable: string;
+ sourceColumn: string;
+ targetTable: string;
+ targetColumn: string;
+ confidence?: "high" | "medium";
+ strategy?: string;
+}
+
+export function toDiagramRelationship(input: InferredRelationshipInput): DiagramRelationship {
+ return {
+ id: input.id,
+ name: `${input.sourceTable}_${input.sourceColumn}_${input.targetTable}_${input.targetColumn}`,
+ kind: "inferred",
+ sourceTable: input.sourceTable,
+ sourceColumn: input.sourceColumn,
+ targetTable: input.targetTable,
+ targetColumn: input.targetColumn,
+ sourceCardinality: "N",
+ targetCardinality: "1",
+ };
+}
+
+export function mergeRelationshipsWithInferred(existing: DiagramRelationship[], inferred: InferredRelationshipInput[]): DiagramRelationship[] {
+ const existingIds = new Set(existing.map((r) => r.id));
+ const merged: DiagramRelationship[] = [...existing];
+
+ for (const inf of inferred) {
+ if (!existingIds.has(inf.id)) {
+ merged.push(toDiagramRelationship(inf));
+ existingIds.add(inf.id);
+ }
+ }
+
+ return deduplicateRelationships(merged);
}
export function filterDiagramTables(tables: DiagramTable[], query: string): DiagramTable[] {
@@ -131,24 +274,32 @@ export function filterDiagramTables(tables: DiagramTable[], query: string): Diag
export function layoutDiagramTables(tables: Pick[], options: DiagramLayoutOptions = {}): Record {
const columnsPerRow = Math.max(1, options.columnsPerRow ?? Math.ceil(Math.sqrt(Math.max(tables.length, 1))));
const cardWidth = options.cardWidth ?? 260;
- const rowHeight = options.rowHeight ?? 220;
const gapX = options.gapX ?? 56;
const gapY = options.gapY ?? 40;
const margin = options.margin ?? 40;
+ const fixedRowHeight = options.rowHeight;
- return Object.fromEntries(
- tables.map((table, index) => {
- const col = index % columnsPerRow;
- const row = Math.floor(index / columnsPerRow);
- return [
- table.name,
- {
- x: margin + col * (cardWidth + gapX),
- y: margin + row * (rowHeight + gapY),
- },
- ];
- }),
- );
+ const measureHeight = (table: Pick): number => {
+ if (fixedRowHeight != null) return fixedRowHeight;
+ const columnCount = table.columns?.length ?? 0;
+ return 44 + columnCount * 24 + 12;
+ };
+
+ const positions: Record = {};
+ let y = margin;
+ for (let start = 0; start < tables.length; start += columnsPerRow) {
+ const rowTables = tables.slice(start, start + columnsPerRow);
+ const rowContentHeight = Math.max(...rowTables.map(measureHeight));
+ rowTables.forEach((table, col) => {
+ positions[table.name] = {
+ x: margin + col * (cardWidth + gapX),
+ y,
+ };
+ });
+ y += rowContentHeight + gapY;
+ }
+
+ return positions;
}
function quoteIdentifier(value: string): string {
diff --git a/apps/desktop/src/lib/diagram/graph-store.ts b/apps/desktop/src/lib/diagram/graph-store.ts
new file mode 100644
index 000000000..3ca91f262
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/graph-store.ts
@@ -0,0 +1,136 @@
+import { defineStore } from "pinia";
+import { ref, computed } from "vue";
+import type { DiagramNode, DiagramEdge, HistorySnapshot, LayoutOptions, DiagramLayer } from "@/types/diagram";
+import { LayoutManager } from "./layout-manager";
+import type { LayerLayoutInfo } from "./elk-layout";
+
+function deepClone(obj: T): T {
+ return JSON.parse(JSON.stringify(obj));
+}
+
+function emptySnapshotExtras(): Pick {
+ return {
+ positions: {},
+ layers: [],
+ tables: [],
+ customRelationships: [],
+ edgeWaypoints: {},
+ edgeHandleHints: {},
+ matchConfirms: [],
+ matchIgnores: [],
+ };
+}
+
+export const useGraphStore = defineStore("diagram-graph", () => {
+ const nodes = ref([]);
+ const edges = ref([]);
+ const layerLayouts = ref([]);
+ const historyStack = ref([]);
+ const redoStack = ref([]);
+ const maxHistorySize = 50;
+ const layoutManager = new LayoutManager();
+
+ const canUndo = computed(() => historyStack.value.length > 0);
+ const canRedo = computed(() => redoStack.value.length > 0);
+
+ function snapshotFromNodesEdges(): HistorySnapshot {
+ return {
+ nodes: deepClone(nodes.value),
+ edges: deepClone(edges.value),
+ ...emptySnapshotExtras(),
+ positions: Object.fromEntries(nodes.value.map((n) => [n.id, { ...n.position }])),
+ };
+ }
+
+ function pushHistory(snapshot: HistorySnapshot) {
+ historyStack.value.push(deepClone(snapshot));
+ if (historyStack.value.length > maxHistorySize) {
+ historyStack.value.shift();
+ }
+ redoStack.value = [];
+ }
+
+ /** Push current store nodes/edges (used by store-owned layout helpers). */
+ function pushStoreHistory() {
+ pushHistory(snapshotFromNodesEdges());
+ }
+
+ function undo(current: HistorySnapshot): HistorySnapshot | null {
+ if (historyStack.value.length === 0) return null;
+ redoStack.value.push(deepClone(current));
+ const prev = historyStack.value.pop()!;
+ nodes.value = deepClone(prev.nodes);
+ edges.value = deepClone(prev.edges);
+ return deepClone(prev);
+ }
+
+ function redo(current: HistorySnapshot): HistorySnapshot | null {
+ if (redoStack.value.length === 0) return null;
+ historyStack.value.push(deepClone(current));
+ const next = redoStack.value.pop()!;
+ nodes.value = deepClone(next.nodes);
+ edges.value = deepClone(next.edges);
+ return deepClone(next);
+ }
+
+ function setNodes(newNodes: DiagramNode[]) {
+ nodes.value = newNodes;
+ }
+
+ function setEdges(newEdges: DiagramEdge[]) {
+ edges.value = newEdges;
+ }
+
+ function updateNodePosition(nodeId: string, position: { x: number; y: number }) {
+ const node = nodes.value.find((n) => n.id === nodeId);
+ if (node) {
+ pushStoreHistory();
+ node.position = position;
+ }
+ }
+
+ async function applyLayout(direction?: LayoutOptions["direction"]) {
+ pushStoreHistory();
+ const result = await layoutManager.applyElkLayout(nodes.value, edges.value, direction);
+ nodes.value = result.nodes;
+ edges.value = result.edges;
+ layerLayouts.value = [];
+ }
+
+ async function applyElkLayoutWithLayers(nodesParam: DiagramNode[], edgesParam: DiagramEdge[], layers: DiagramLayer[]) {
+ pushStoreHistory();
+ const result = await layoutManager.applyElkLayoutWithLayers(nodesParam, edgesParam, layers);
+ nodes.value = result.nodes;
+ edges.value = result.edges;
+ layerLayouts.value = result.layerLayouts || [];
+ return result;
+ }
+
+ function applyGridLayout() {
+ pushStoreHistory();
+ nodes.value = layoutManager.applyGridLayout(nodes.value);
+ }
+
+ function clearHistory() {
+ historyStack.value = [];
+ redoStack.value = [];
+ }
+
+ return {
+ nodes,
+ edges,
+ layerLayouts,
+ undo,
+ redo,
+ canUndo,
+ canRedo,
+ pushHistory,
+ setNodes,
+ setEdges,
+ updateNodePosition,
+ applyLayout,
+ applyElkLayoutWithLayers,
+ applyGridLayout,
+ clearHistory,
+ };
+});
diff --git a/apps/desktop/src/lib/diagram/layer-store.ts b/apps/desktop/src/lib/diagram/layer-store.ts
new file mode 100644
index 000000000..ecc7c7ea9
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/layer-store.ts
@@ -0,0 +1,182 @@
+import { defineStore } from "pinia";
+import { ref, computed } from "vue";
+import type { DiagramLayer, LayerLayoutMode } from "@/types/diagram";
+import { LAYER_COLORS } from "@/types/diagram";
+
+function generateLayerId(): string {
+ return `layer-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
+}
+
+function getNextColor(existingColors: string[]): string {
+ const usedColors = new Set(existingColors);
+ for (const color of LAYER_COLORS) {
+ if (!usedColors.has(color)) {
+ return color;
+ }
+ }
+ return LAYER_COLORS[existingColors.length % LAYER_COLORS.length];
+}
+
+function getNextLayerName(existingNames: string[]): string {
+ let maxIndex = 0;
+ const layerNameRegex = /^Layer (\d+)$/;
+ for (const name of existingNames) {
+ const match = name.match(layerNameRegex);
+ if (match) {
+ const index = parseInt(match[1], 10);
+ if (index > maxIndex) {
+ maxIndex = index;
+ }
+ }
+ }
+ return `Layer ${maxIndex + 1}`;
+}
+
+export const useLayerStore = defineStore("diagram-layer", () => {
+ const layers = ref([]);
+ const activeLayerId = ref(null);
+
+ const activeLayer = computed(() => layers.value.find((l) => l.id === activeLayerId.value));
+
+ function getLayerByTable(tableName: string): DiagramLayer | undefined {
+ return layers.value.find((layer) => layer.tableNames.includes(tableName));
+ }
+
+ function getLayerColor(tableName: string): string {
+ const layer = getLayerByTable(tableName);
+ return layer?.color || "#9ca3af";
+ }
+
+ function addLayer(name?: string, position?: { x: number; y: number }, size?: { width?: number; height?: number }): DiagramLayer {
+ for (const layer of layers.value) {
+ layer.collapsed = true;
+ }
+ const newLayer: DiagramLayer = {
+ id: generateLayerId(),
+ name: name || getNextLayerName(layers.value.map((l) => l.name)),
+ color: getNextColor(layers.value.map((l) => l.color)),
+ tableNames: [],
+ collapsed: false,
+ visible: true,
+ layoutMode: "auto",
+ position: position || { x: 40, y: 40 },
+ width: size?.width ?? 240,
+ height: size?.height ?? 52,
+ };
+ layers.value.push(newLayer);
+ activeLayerId.value = newLayer.id;
+ return newLayer;
+ }
+
+ function setLayoutMode(layerId: string, layoutMode: LayerLayoutMode) {
+ const layer = layers.value.find((l) => l.id === layerId);
+ if (layer) {
+ layer.layoutMode = layoutMode;
+ }
+ }
+
+ function updateLayerGeometry(layerId: string, geometry: { position?: { x: number; y: number }; width?: number; height?: number }) {
+ const layer = layers.value.find((l) => l.id === layerId);
+ if (!layer) return;
+ if (geometry.position) layer.position = geometry.position;
+ if (geometry.width !== undefined) layer.width = geometry.width;
+ if (geometry.height !== undefined) layer.height = geometry.height;
+ }
+
+ function removeLayer(layerId: string) {
+ const index = layers.value.findIndex((l) => l.id === layerId);
+ if (index === -1) return;
+
+ layers.value.splice(index, 1);
+
+ if (activeLayerId.value === layerId) {
+ activeLayerId.value = layers.value[0]?.id || null;
+ }
+ }
+
+ function renameLayer(layerId: string, newName: string) {
+ const layer = layers.value.find((l) => l.id === layerId);
+ if (layer) {
+ layer.name = newName;
+ }
+ }
+
+ function setActiveLayer(layerId: string | null) {
+ activeLayerId.value = layerId;
+ }
+
+ function addTableToLayer(layerId: string, tableName: string) {
+ const layer = layers.value.find((l) => l.id === layerId);
+ if (layer && !layer.tableNames.includes(tableName)) {
+ layer.tableNames.push(tableName);
+ }
+ }
+
+ function removeTableFromLayer(layerId: string, tableName: string) {
+ const layer = layers.value.find((l) => l.id === layerId);
+ if (layer) {
+ layer.tableNames = layer.tableNames.filter((name) => name !== tableName);
+ }
+ }
+
+ function moveTableToLayer(tableName: string, targetLayerId: string) {
+ const currentLayer = getLayerByTable(tableName);
+ if (currentLayer && currentLayer.id !== targetLayerId) {
+ removeTableFromLayer(currentLayer.id, tableName);
+ }
+ addTableToLayer(targetLayerId, tableName);
+ }
+
+ function toggleLayerVisibility(layerId: string) {
+ const layer = layers.value.find((l) => l.id === layerId);
+ if (layer) {
+ layer.visible = !layer.visible;
+ }
+ }
+
+ function toggleLayerCollapse(layerId: string) {
+ const layer = layers.value.find((l) => l.id === layerId);
+ if (layer) {
+ layer.collapsed = !layer.collapsed;
+ }
+ }
+
+ function clearLayers() {
+ layers.value = [];
+ activeLayerId.value = null;
+ }
+
+ function loadLayers(savedLayers: DiagramLayer[]) {
+ layers.value = savedLayers.map((layer) => ({
+ ...layer,
+ layoutMode: layer.layoutMode ?? "auto",
+ }));
+ activeLayerId.value = layers.value[0]?.id || null;
+ }
+
+ function toJSON(): DiagramLayer[] {
+ return JSON.parse(JSON.stringify(layers.value));
+ }
+
+ return {
+ layers,
+ activeLayerId,
+ activeLayer,
+ getLayerByTable,
+ getLayerColor,
+ addLayer,
+ removeLayer,
+ renameLayer,
+ setActiveLayer,
+ setLayoutMode,
+ updateLayerGeometry,
+ addTableToLayer,
+ removeTableFromLayer,
+ moveTableToLayer,
+ toggleLayerVisibility,
+ toggleLayerCollapse,
+ clearLayers,
+ loadLayers,
+ toJSON,
+ };
+});
diff --git a/apps/desktop/src/lib/diagram/layout-manager.ts b/apps/desktop/src/lib/diagram/layout-manager.ts
new file mode 100644
index 000000000..342b4432f
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/layout-manager.ts
@@ -0,0 +1,46 @@
+import type { DiagramNode, DiagramEdge, LayoutOptions, DiagramLayer } from "@/types/diagram";
+import { computeLayout, computeLayoutWithLayers, type LayerLayoutInfo } from "./elk-layout";
+
+export interface LayoutManagerOptions {
+ defaultDirection?: LayoutOptions["direction"];
+ gridColumns?: number;
+}
+
+export class LayoutManager {
+ private defaultDirection: LayoutOptions["direction"];
+ private gridColumns: number;
+
+ constructor(options: LayoutManagerOptions = {}) {
+ this.defaultDirection = options.defaultDirection || "LR";
+ this.gridColumns = options.gridColumns || 4;
+ }
+
+ async applyElkLayout(nodes: DiagramNode[], edges: DiagramEdge[], direction?: LayoutOptions["direction"]): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[] }> {
+ return computeLayout(nodes, edges, { direction: direction || this.defaultDirection });
+ }
+
+ async applyElkLayoutWithLayers(nodes: DiagramNode[], edges: DiagramEdge[], layers: DiagramLayer[], direction?: LayoutOptions["direction"]): Promise<{ nodes: DiagramNode[]; edges: DiagramEdge[]; layerLayouts: LayerLayoutInfo[] }> {
+ return computeLayoutWithLayers(nodes, edges, layers, { direction: direction || this.defaultDirection });
+ }
+
+ applyGridLayout(nodes: DiagramNode[]): DiagramNode[] {
+ const columns = Math.max(1, Math.min(this.gridColumns, Math.ceil(Math.sqrt(nodes.length))));
+ const cardWidth = 270;
+ const rowHeight = 240;
+ const gapX = 64;
+ const gapY = 44;
+ const margin = 40;
+
+ return nodes.map((node, index) => {
+ const col = index % columns;
+ const row = Math.floor(index / columns);
+ return {
+ ...node,
+ position: {
+ x: margin + col * (cardWidth + gapX),
+ y: margin + row * (rowHeight + gapY),
+ },
+ };
+ });
+ }
+}
diff --git a/apps/desktop/src/lib/diagram/ltr-auto-layout.ts b/apps/desktop/src/lib/diagram/ltr-auto-layout.ts
new file mode 100644
index 000000000..29fba284b
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/ltr-auto-layout.ts
@@ -0,0 +1,345 @@
+import type { DiagramLayer } from "@/types/diagram";
+import type { DiagramPosition, DiagramTable } from "@/lib/diagram/erDiagram";
+import { layoutDiagramTables } from "@/lib/diagram/erDiagram";
+import { sizeLayerToFit } from "@/lib/diagram/size-layer";
+import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, GAP_X, GAP_Y, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN, columnsPerRowForWidth, tableCardHeight } from "@/lib/diagram/diagram-constants";
+
+export interface LtrAutoLayoutInput {
+ tables: DiagramTable[];
+ positions: Record;
+ layers: DiagramLayer[];
+ paneWidth: number;
+ /** Optional edges used to cluster related tables in LTR order. */
+ relationships?: Array<{ sourceTable: string; targetTable: string }>;
+}
+
+export interface LtrAutoLayoutResult {
+ positions: Record;
+ /** Mutated copies of visible layers with updated geometry (caller should apply to store). */
+ layers: DiagramLayer[];
+}
+
+type LayerBox = {
+ layer: DiagramLayer;
+ width: number;
+ height: number;
+ /** Absolute table positions relative to a provisional origin (0,0) for the layer box. */
+ localTablePositions: Record;
+};
+
+function tableHeightsMap(tables: DiagramTable[]): Record {
+ const heights: Record = {};
+ for (const table of tables) {
+ heights[table.name] = tableCardHeight(table.columns?.length ?? 0);
+ }
+ return heights;
+}
+
+/** Order tables so connected components stay adjacent (for friendlier LTR grids). */
+export function orderTablesByConnectivity(tables: DiagramTable[], relationships: Array<{ sourceTable: string; targetTable: string }> = []): DiagramTable[] {
+ if (tables.length <= 1 || relationships.length === 0) return [...tables];
+
+ const nameSet = new Set(tables.map((t) => t.name));
+ const adj = new Map>();
+ for (const name of nameSet) adj.set(name, new Set());
+
+ for (const rel of relationships) {
+ if (!nameSet.has(rel.sourceTable) || !nameSet.has(rel.targetTable)) continue;
+ if (rel.sourceTable === rel.targetTable) continue;
+ adj.get(rel.sourceTable)!.add(rel.targetTable);
+ adj.get(rel.targetTable)!.add(rel.sourceTable);
+ }
+
+ const byName = new Map(tables.map((t) => [t.name, t]));
+ const visited = new Set();
+ const ordered: DiagramTable[] = [];
+
+ const visitComponent = (start: string) => {
+ const stack = [start];
+ const component: string[] = [];
+ while (stack.length > 0) {
+ const name = stack.pop()!;
+ if (visited.has(name)) continue;
+ visited.add(name);
+ component.push(name);
+ for (const next of adj.get(name) ?? []) {
+ if (!visited.has(next)) stack.push(next);
+ }
+ }
+ component.sort((a, b) => a.localeCompare(b));
+ for (const name of component) {
+ const table = byName.get(name);
+ if (table) ordered.push(table);
+ }
+ };
+
+ // Prefer starting from tables that have edges, then isolates (stable name order)
+ const withEdges = tables.filter((t) => (adj.get(t.name)?.size ?? 0) > 0).sort((a, b) => a.name.localeCompare(b.name));
+ const isolates = tables.filter((t) => (adj.get(t.name)?.size ?? 0) === 0).sort((a, b) => a.name.localeCompare(b.name));
+
+ for (const table of withEdges) {
+ if (!visited.has(table.name)) visitComponent(table.name);
+ }
+ for (const table of isolates) {
+ if (!visited.has(table.name)) ordered.push(table);
+ }
+
+ return ordered;
+}
+
+/**
+ * Reflow unassigned (not in any visible layer) tables into an LTR grid,
+ * preserving approximate visual order (sort by y then x).
+ */
+export function reflowUnassignedTables(input: { tables: DiagramTable[]; positions: Record; layers: DiagramLayer[]; paneWidth: number; yOrigin?: number }): Record {
+ const { tables, positions, layers, paneWidth } = input;
+ const columnsPerRow = columnsPerRowForWidth(paneWidth);
+ const assigned = new Set(layers.filter((l) => l.visible).flatMap((l) => l.tableNames));
+ const unassigned = tables.filter((t) => !assigned.has(t.name));
+ if (unassigned.length === 0) return { ...positions };
+
+ const sorted = [...unassigned].sort((a, b) => {
+ const pa = positions[a.name] ?? { x: 0, y: 0 };
+ const pb = positions[b.name] ?? { x: 0, y: 0 };
+ if (pa.y !== pb.y) return pa.y - pb.y;
+ return pa.x - pb.x;
+ });
+
+ let layersBottom = MARGIN;
+ for (const layer of layers.filter((l) => l.visible)) {
+ if (layer.position == null || layer.height == null) continue;
+ layersBottom = Math.max(layersBottom, layer.position.y + layer.height);
+ }
+
+ const yOrigin = input.yOrigin ?? (layers.some((l) => l.visible) ? layersBottom + GAP_Y : MARGIN);
+ const grid = layoutDiagramTables(sorted, {
+ columnsPerRow,
+ cardWidth: CARD_WIDTH,
+ gapX: GAP_X,
+ gapY: GAP_Y,
+ margin: MARGIN,
+ });
+
+ const next = { ...positions };
+ const yOffset = yOrigin - MARGIN;
+ for (const [name, pos] of Object.entries(grid)) {
+ next[name] = { x: pos.x, y: pos.y + yOffset };
+ }
+ return next;
+}
+
+function measureFreeLocal(layer: DiagramLayer, positions: Record, heights: Record): LayerBox {
+ if (layer.tableNames.length === 0) {
+ return {
+ layer,
+ width: EMPTY_LAYER_WIDTH,
+ height: EMPTY_LAYER_HEIGHT,
+ localTablePositions: {},
+ };
+ }
+
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+ const absolute: Record = {};
+
+ for (const name of layer.tableNames) {
+ const position = positions[name];
+ if (!position) continue;
+ const height = heights[name] ?? 120;
+ absolute[name] = { ...position };
+ minX = Math.min(minX, position.x);
+ minY = Math.min(minY, position.y);
+ maxX = Math.max(maxX, position.x + CARD_WIDTH);
+ maxY = Math.max(maxY, position.y + height);
+ }
+
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
+ return {
+ layer,
+ width: EMPTY_LAYER_WIDTH,
+ height: EMPTY_LAYER_HEIGHT,
+ localTablePositions: {},
+ };
+ }
+
+ const originX = minX - LAYER_CONTENT_PADDING;
+ const originY = minY - LAYER_HEADER_HEIGHT - LAYER_CONTENT_PADDING;
+ const localTablePositions: Record = {};
+ for (const [name, pos] of Object.entries(absolute)) {
+ localTablePositions[name] = { x: pos.x - originX, y: pos.y - originY };
+ }
+
+ return {
+ layer,
+ width: Math.max(EMPTY_LAYER_WIDTH, maxX - minX + LAYER_CONTENT_PADDING * 2),
+ height: Math.max(EMPTY_LAYER_HEIGHT, maxY - minY + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING * 2),
+ localTablePositions,
+ };
+}
+
+function measureAutoLocal(layer: DiagramLayer, tablesByName: Map, columnsPerRow: number): LayerBox {
+ if (layer.tableNames.length === 0) {
+ return {
+ layer,
+ width: EMPTY_LAYER_WIDTH,
+ height: EMPTY_LAYER_HEIGHT,
+ localTablePositions: {},
+ };
+ }
+
+ const layerTables = layer.tableNames.map((name) => tablesByName.get(name)).filter((t): t is DiagramTable => !!t);
+
+ if (layerTables.length === 0) {
+ return {
+ layer,
+ width: EMPTY_LAYER_WIDTH,
+ height: EMPTY_LAYER_HEIGHT,
+ localTablePositions: {},
+ };
+ }
+
+ // Layout with margin 0, then offset into layer content area
+ const grid = layoutDiagramTables(layerTables, {
+ columnsPerRow,
+ cardWidth: CARD_WIDTH,
+ gapX: GAP_X,
+ gapY: GAP_Y,
+ margin: 0,
+ });
+
+ const localTablePositions: Record = {};
+ for (const [name, pos] of Object.entries(grid)) {
+ localTablePositions[name] = {
+ x: pos.x + LAYER_CONTENT_PADDING,
+ y: pos.y + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING,
+ };
+ }
+
+ // Provisional size via sizeLayerToFit against local coords (origin 0,0)
+ const scratch: DiagramLayer = {
+ ...layer,
+ position: { x: 0, y: 0 },
+ tableNames: [...layer.tableNames],
+ };
+ const heights = tableHeightsMap(layerTables);
+ sizeLayerToFit(scratch, localTablePositions, heights);
+
+ return {
+ layer,
+ width: scratch.width ?? EMPTY_LAYER_WIDTH,
+ height: scratch.height ?? EMPTY_LAYER_HEIGHT,
+ localTablePositions,
+ };
+}
+
+/**
+ * Auto-layout: visible layers packed at top (LTR wrap), unassigned tables
+ * in an LTR grid below — same column logic as initial resetLayout.
+ */
+export function computeLtrAutoLayout(input: LtrAutoLayoutInput): LtrAutoLayoutResult {
+ const { tables, positions: prevPositions, layers, paneWidth, relationships = [] } = input;
+ const columnsPerRow = columnsPerRowForWidth(paneWidth);
+ const heights = tableHeightsMap(tables);
+ const tablesByName = new Map(tables.map((t) => [t.name, t]));
+ const nextPositions: Record = { ...prevPositions };
+
+ const visibleLayers = layers.filter((l) => l.visible);
+ const assigned = new Set(visibleLayers.flatMap((l) => l.tableNames));
+ const orderedTables = orderTablesByConnectivity(tables, relationships);
+ const unassigned = orderedTables.filter((t) => !assigned.has(t.name));
+
+ if (visibleLayers.length === 0) {
+ const grid = layoutDiagramTables(orderedTables, {
+ columnsPerRow,
+ cardWidth: CARD_WIDTH,
+ gapX: GAP_X,
+ gapY: GAP_Y,
+ margin: MARGIN,
+ });
+ return { positions: { ...nextPositions, ...grid }, layers: [] };
+ }
+
+ const boxes: LayerBox[] = visibleLayers.map((layer) => {
+ const mode = layer.layoutMode ?? "free";
+ if (mode === "auto") {
+ const layerTables = orderTablesByConnectivity(
+ layer.tableNames.map((n) => tablesByName.get(n)).filter((t): t is DiagramTable => !!t),
+ relationships,
+ );
+ const scratchLayer = { ...layer, tableNames: layerTables.map((t) => t.name) };
+ return measureAutoLocal(scratchLayer, tablesByName, columnsPerRow);
+ }
+ return measureFreeLocal(layer, prevPositions, heights);
+ });
+
+ const usableWidth = Math.max(CARD_WIDTH, paneWidth - MARGIN * 2);
+ let cursorX = MARGIN;
+ let cursorY = MARGIN;
+ let rowHeight = 0;
+ let layersBottom = MARGIN;
+
+ const updatedLayers: DiagramLayer[] = [];
+
+ for (const box of boxes) {
+ const { width, height, localTablePositions, layer } = box;
+
+ if (cursorX > MARGIN && cursorX + width > MARGIN + usableWidth) {
+ cursorX = MARGIN;
+ cursorY += rowHeight + GAP_Y;
+ rowHeight = 0;
+ }
+
+ const placed: DiagramLayer = {
+ ...layer,
+ position: { x: cursorX, y: cursorY },
+ width,
+ height,
+ tableNames: [...layer.tableNames],
+ };
+
+ for (const [name, local] of Object.entries(localTablePositions)) {
+ nextPositions[name] = {
+ x: cursorX + local.x,
+ y: cursorY + local.y,
+ };
+ }
+
+ // Reconcile size from absolute table positions (auto / free with tables)
+ if (placed.tableNames.length > 0) {
+ sizeLayerToFit(placed, nextPositions, heights);
+ } else {
+ placed.width = EMPTY_LAYER_WIDTH;
+ placed.height = EMPTY_LAYER_HEIGHT;
+ placed.position = { x: cursorX, y: cursorY };
+ }
+
+ const finalW = placed.width ?? width;
+ const finalH = placed.height ?? height;
+ const finalX = placed.position?.x ?? cursorX;
+ const finalY = placed.position?.y ?? cursorY;
+
+ updatedLayers.push(placed);
+
+ cursorX = finalX + finalW + GAP_X;
+ rowHeight = Math.max(rowHeight, finalH);
+ layersBottom = Math.max(layersBottom, finalY + finalH);
+ }
+
+ if (unassigned.length > 0) {
+ const grid = layoutDiagramTables(unassigned, {
+ columnsPerRow,
+ cardWidth: CARD_WIDTH,
+ gapX: GAP_X,
+ gapY: GAP_Y,
+ margin: MARGIN,
+ });
+ const yOffset = layersBottom + GAP_Y - MARGIN;
+ for (const [name, pos] of Object.entries(grid)) {
+ nextPositions[name] = { x: pos.x, y: pos.y + yOffset };
+ }
+ }
+
+ return { positions: nextPositions, layers: updatedLayers };
+}
diff --git a/apps/desktop/src/lib/diagram/match-engine.ts b/apps/desktop/src/lib/diagram/match-engine.ts
new file mode 100644
index 000000000..1c3dd7ad5
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/match-engine.ts
@@ -0,0 +1,131 @@
+import type { DiagramTable, DiagramRelationship } from "./erDiagram";
+import type { InferredRelationship, MatchResult } from "@/types/diagram";
+import { buildPrimaryKeyIndex, extractTableNameFromColumn, isTypeCompatible, toSnakeCase } from "./match-strategies";
+
+export function buildRelationshipId(sourceTable: string, sourceColumn: string, targetTable: string, targetColumn: string): string {
+ return ["inferred", sourceTable, sourceColumn, targetTable, targetColumn].join(":");
+}
+
+export function inferRelationships(tables: DiagramTable[]): InferredRelationship[] {
+ const results: InferredRelationship[] = [];
+ const tableNameSet = new Set(tables.map((t) => t.name));
+ const primaryKeys = buildPrimaryKeyIndex(tables);
+
+ for (const table of tables) {
+ for (const column of table.columns) {
+ if (column.is_primary_key) continue;
+
+ const candidateTableName = extractTableNameFromColumn(column.name);
+ if (!candidateTableName) continue;
+
+ const candidateTable = toSnakeCase(candidateTableName);
+ if (!tableNameSet.has(candidateTable)) {
+ const partialMatch = tables.find((t) => t.name.toLowerCase().includes(candidateTableName.toLowerCase()));
+ if (partialMatch) {
+ const targetPK = primaryKeys.get(partialMatch.name);
+ if (targetPK) {
+ const strategy = isTypeCompatible(column.data_type, targetPK.data_type) ? "type_signature" : "naming_convention";
+ const confidence = "medium";
+
+ results.push({
+ id: buildRelationshipId(table.name, column.name, partialMatch.name, targetPK.name),
+ sourceTable: table.name,
+ sourceColumn: column.name,
+ targetTable: partialMatch.name,
+ targetColumn: targetPK.name,
+ confidence,
+ strategy,
+ });
+ }
+ }
+ continue;
+ }
+
+ const targetPK = primaryKeys.get(candidateTable);
+ if (!targetPK) continue;
+
+ const strategy = isTypeCompatible(column.data_type, targetPK.data_type) ? "type_signature" : "naming_convention";
+ const confidence = strategy === "type_signature" ? "high" : "high";
+
+ results.push({
+ id: buildRelationshipId(table.name, column.name, candidateTable, targetPK.name),
+ sourceTable: table.name,
+ sourceColumn: column.name,
+ targetTable: candidateTable,
+ targetColumn: targetPK.name,
+ confidence,
+ strategy,
+ });
+ }
+ }
+
+ return deduplicate(results);
+}
+
+function deduplicate(relationships: InferredRelationship[]): InferredRelationship[] {
+ const seen = new Set();
+ const unique: InferredRelationship[] = [];
+
+ for (const rel of relationships) {
+ const key = `${rel.sourceTable}:${rel.sourceColumn}:${rel.targetTable}:${rel.targetColumn}`;
+ if (!seen.has(key)) {
+ seen.add(key);
+ unique.push(rel);
+ }
+ }
+
+ return unique;
+}
+
+export function filterByStorage(inferred: InferredRelationship[], confirms: string[], ignores: string[]): MatchResult {
+ const confirmed = inferred.filter((r) => confirms.includes(r.id));
+ const pending = inferred.filter((r) => !confirms.includes(r.id) && !ignores.includes(r.id));
+
+ const conflictList = findConflicts(pending.filter((r) => r.confidence === "high"));
+ const conflictIds = new Set(conflictList.map((r) => r.id));
+ const actionablePending = pending.filter((r) => !conflictIds.has(r.id));
+
+ return {
+ relationships: [...confirmed, ...actionablePending],
+ conflicts: conflictList,
+ pending: actionablePending,
+ stats: {
+ total: inferred.length,
+ high: inferred.filter((r) => r.confidence === "high").length,
+ medium: inferred.filter((r) => r.confidence === "medium").length,
+ },
+ };
+}
+
+function findConflicts(relationships: InferredRelationship[]): InferredRelationship[] {
+ const conflicts: InferredRelationship[] = [];
+ const sourceMap = new Map();
+
+ for (const rel of relationships) {
+ const key = `${rel.sourceTable}:${rel.sourceColumn}`;
+ const existing = sourceMap.get(key) || [];
+ existing.push(rel);
+ sourceMap.set(key, existing);
+ }
+
+ for (const [, rels] of sourceMap) {
+ if (rels.length > 1) {
+ conflicts.push(...rels);
+ }
+ }
+
+ return conflicts;
+}
+
+export function mergeRelationships(existing: DiagramRelationship[], inferred: InferredRelationship[]): (DiagramRelationship | InferredRelationship)[] {
+ const existingIds = new Set(existing.map((r) => r.id));
+ const merged: (DiagramRelationship | InferredRelationship)[] = [...existing];
+
+ for (const rel of inferred) {
+ if (!existingIds.has(rel.id)) {
+ merged.push(rel);
+ }
+ }
+
+ return merged;
+}
diff --git a/apps/desktop/src/lib/diagram/match-storage.ts b/apps/desktop/src/lib/diagram/match-storage.ts
new file mode 100644
index 000000000..06604bcbb
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/match-storage.ts
@@ -0,0 +1,63 @@
+import { safeLocalStorageGet, safeLocalStorageSet } from "@/lib/backend/safeStorage";
+
+function matchStorageKey(type: "match-confirms" | "match-ignores" | "match-rules", connectionId: string, database: string, schema: string): string {
+ return ["dbx", "diagram", type, "v1", connectionId, database, schema].join(":");
+}
+
+export function loadMatchConfirms(connectionId: string, database: string, schema: string): string[] {
+ const key = matchStorageKey("match-confirms", connectionId, database, schema);
+ try {
+ return JSON.parse(safeLocalStorageGet(key) || "[]");
+ } catch {
+ return [];
+ }
+}
+
+export function saveMatchConfirms(ids: string[], connectionId: string, database: string, schema: string): void {
+ const key = matchStorageKey("match-confirms", connectionId, database, schema);
+ safeLocalStorageSet(key, JSON.stringify(ids));
+}
+
+export function loadMatchIgnores(connectionId: string, database: string, schema: string): string[] {
+ const key = matchStorageKey("match-ignores", connectionId, database, schema);
+ try {
+ return JSON.parse(safeLocalStorageGet(key) || "[]");
+ } catch {
+ return [];
+ }
+}
+
+export function saveMatchIgnores(ids: string[], connectionId: string, database: string, schema: string): void {
+ const key = matchStorageKey("match-ignores", connectionId, database, schema);
+ safeLocalStorageSet(key, JSON.stringify(ids));
+}
+
+export function loadMatchRules(connectionId: string, database: string, schema: string): MatchRule[] {
+ const key = matchStorageKey("match-rules", connectionId, database, schema);
+ try {
+ return JSON.parse(safeLocalStorageGet(key) || "[]");
+ } catch {
+ return [];
+ }
+}
+
+export function saveMatchRules(rules: MatchRule[], connectionId: string, database: string, schema: string): void {
+ const key = matchStorageKey("match-rules", connectionId, database, schema);
+ safeLocalStorageSet(key, JSON.stringify(rules));
+}
+
+export function isAutoMatchEnabled(): boolean {
+ return safeLocalStorageGet("dbx:diagram:match-enabled") !== "false";
+}
+
+export function setAutoMatchEnabled(enabled: boolean): void {
+ safeLocalStorageSet("dbx:diagram:match-enabled", String(enabled));
+}
+
+export interface MatchRule {
+ id: string;
+ name: string;
+ pattern: string;
+ enabled: boolean;
+ priority: number;
+}
diff --git a/apps/desktop/src/lib/diagram/match-strategies.ts b/apps/desktop/src/lib/diagram/match-strategies.ts
new file mode 100644
index 000000000..baa33fc16
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/match-strategies.ts
@@ -0,0 +1,71 @@
+import type { DiagramTable } from "./erDiagram";
+
+const TYPE_COMPATIBLE_MAP: Record = {
+ bigint: ["bigint", "int", "smallint", "tinyint", "integer", "long"],
+ int: ["int", "bigint", "smallint", "tinyint", "integer"],
+ smallint: ["smallint", "int", "bigint", "tinyint"],
+ tinyint: ["tinyint", "smallint", "int", "bigint"],
+ integer: ["integer", "int", "bigint", "smallint"],
+ uuid: ["uuid", "char", "varchar", "nvarchar", "uniqueidentifier", "guid"],
+ char: ["char", "varchar", "uuid", "uniqueidentifier"],
+ varchar: ["varchar", "char", "uuid", "uniqueidentifier"],
+ nvarchar: ["nvarchar", "varchar", "char", "uuid"],
+ uniqueidentifier: ["uniqueidentifier", "uuid", "char", "varchar"],
+};
+
+export function isTypeCompatible(sourceType: string, targetType: string): boolean {
+ const source = sourceType.toLowerCase();
+ const target = targetType.toLowerCase();
+
+ if (source === target) return true;
+
+ const compatibleWithSource = TYPE_COMPATIBLE_MAP[source];
+ if (compatibleWithSource && compatibleWithSource.includes(target)) {
+ return true;
+ }
+
+ const compatibleWithTarget = TYPE_COMPATIBLE_MAP[target];
+ if (compatibleWithTarget && compatibleWithTarget.includes(source)) {
+ return true;
+ }
+
+ return false;
+}
+
+export function toSnakeCase(str: string): string {
+ return str
+ .replace(/([a-z0-9])([A-Z])/g, "$1_$2")
+ .replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
+ .toLowerCase();
+}
+
+export interface PrimaryKeyInfo {
+ name: string;
+ data_type: string;
+}
+
+export function buildPrimaryKeyIndex(tables: DiagramTable[]): Map {
+ const index = new Map();
+
+ for (const table of tables) {
+ const pk = table.columns.find((col) => col.is_primary_key);
+ if (pk) {
+ index.set(table.name, { name: pk.name, data_type: pk.data_type });
+ }
+ }
+
+ return index;
+}
+
+export function extractTableNameFromColumn(columnName: string): string | null {
+ const camelCaseMatch = columnName.match(/^(.+?)(?:ID|UID)$/);
+ if (camelCaseMatch) return camelCaseMatch[1];
+
+ const underscoreMatch = columnName.match(/^(.+?)_(?:id|uuid|pk)$/i);
+ if (underscoreMatch) return underscoreMatch[1];
+
+ const lowercaseMatch = columnName.match(/^(.+?)(?:id|uid)$/i);
+ if (lowercaseMatch) return lowercaseMatch[1];
+
+ return null;
+}
diff --git a/apps/desktop/src/lib/diagram/size-layer.ts b/apps/desktop/src/lib/diagram/size-layer.ts
new file mode 100644
index 000000000..793a8aab5
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/size-layer.ts
@@ -0,0 +1,116 @@
+import type { DiagramLayer } from "@/types/diagram";
+import { CARD_WIDTH, EMPTY_LAYER_HEIGHT, EMPTY_LAYER_WIDTH, LAYER_CONTENT_PADDING, LAYER_HEADER_HEIGHT, MARGIN } from "./diagram-constants";
+
+type Rect = { x: number; y: number; width: number; height: number };
+
+function rectsOverlap(a: Rect, b: Rect, gap = 8): boolean {
+ return !(a.x + a.width + gap <= b.x || b.x + b.width + gap <= a.x || a.y + a.height + gap <= b.y || b.y + b.height + gap <= a.y);
+}
+
+function collectOccupiedRects(layers: DiagramLayer[], tablePositions: Record, tableHeights: Record, excludeLayerId?: string): Rect[] {
+ const rects: Rect[] = [];
+
+ for (const [name, position] of Object.entries(tablePositions)) {
+ const parented = layers.some((l) => l.id !== excludeLayerId && l.tableNames.includes(name));
+ // Tables inside a layer are covered by the layer rect; still include unassigned tables
+ if (parented) continue;
+ rects.push({
+ x: position.x,
+ y: position.y,
+ width: CARD_WIDTH,
+ height: tableHeights[name] ?? 120,
+ });
+ }
+
+ for (const layer of layers) {
+ if (excludeLayerId && layer.id === excludeLayerId) continue;
+ if (!layer.visible || layer.position == null || layer.width == null || layer.height == null) continue;
+ rects.push({
+ x: layer.position.x,
+ y: layer.position.y,
+ width: layer.width,
+ height: layer.height,
+ });
+ }
+
+ return rects;
+}
+
+export function sizeLayerToFit(layer: DiagramLayer, tablePositions: Record, tableHeights: Record): void {
+ if (layer.tableNames.length === 0) {
+ layer.width = EMPTY_LAYER_WIDTH;
+ layer.height = EMPTY_LAYER_HEIGHT;
+ return;
+ }
+
+ let minX = Infinity;
+ let minY = Infinity;
+ let maxX = -Infinity;
+ let maxY = -Infinity;
+
+ for (const name of layer.tableNames) {
+ const position = tablePositions[name];
+ if (!position) continue;
+ const height = tableHeights[name] ?? 120;
+ minX = Math.min(minX, position.x);
+ minY = Math.min(minY, position.y);
+ maxX = Math.max(maxX, position.x + CARD_WIDTH);
+ maxY = Math.max(maxY, position.y + height);
+ }
+
+ if (!Number.isFinite(minX) || !Number.isFinite(minY)) {
+ layer.width = EMPTY_LAYER_WIDTH;
+ layer.height = EMPTY_LAYER_HEIGHT;
+ return;
+ }
+
+ layer.position = {
+ x: minX - LAYER_CONTENT_PADDING,
+ y: minY - LAYER_HEADER_HEIGHT - LAYER_CONTENT_PADDING,
+ };
+ layer.width = Math.max(EMPTY_LAYER_WIDTH, maxX - minX + LAYER_CONTENT_PADDING * 2);
+ layer.height = Math.max(EMPTY_LAYER_HEIGHT, maxY - minY + LAYER_HEADER_HEIGHT + LAYER_CONTENT_PADDING * 2);
+}
+
+export function pointInLayerBounds(point: { x: number; y: number }, layer: DiagramLayer): boolean {
+ if (!layer.visible || layer.position == null || layer.width == null || layer.height == null) {
+ return false;
+ }
+ const { x, y } = layer.position;
+ return point.x >= x && point.x <= x + layer.width && point.y >= y && point.y <= y + layer.height;
+}
+
+export function findLayerAtPoint(point: { x: number; y: number }, layers: DiagramLayer[], excludeLayerId?: string): DiagramLayer | undefined {
+ for (let i = layers.length - 1; i >= 0; i -= 1) {
+ const layer = layers[i];
+ if (excludeLayerId && layer.id === excludeLayerId) continue;
+ if (pointInLayerBounds(point, layer)) return layer;
+ }
+ return undefined;
+}
+
+/**
+ * Place a new empty layer near the top-left of the canvas without overlapping
+ * existing tables or layers.
+ */
+export function placeNewLayer(layers: DiagramLayer[], tablePositions: Record, tableHeights: Record, options?: { width?: number; height?: number; startY?: number }): { position: { x: number; y: number }; width: number; height: number } {
+ const width = options?.width ?? EMPTY_LAYER_WIDTH;
+ const height = options?.height ?? EMPTY_LAYER_HEIGHT;
+ const startY = options?.startY ?? MARGIN;
+ const occupied = collectOccupiedRects(layers, tablePositions, tableHeights);
+ const stepX = width + 16;
+ const stepY = height + 16;
+ const maxX = 2400;
+
+ for (let row = 0; row < 40; row += 1) {
+ const y = startY + row * stepY;
+ for (let x = MARGIN; x + width <= maxX; x += stepX) {
+ const candidate: Rect = { x, y, width, height };
+ if (!occupied.some((rect) => rectsOverlap(candidate, rect))) {
+ return { position: { x, y }, width, height };
+ }
+ }
+ }
+
+ return { position: { x: MARGIN, y: startY + occupied.length * stepY }, width, height };
+}
diff --git a/apps/desktop/src/lib/diagram/vue-flow-adapter.ts b/apps/desktop/src/lib/diagram/vue-flow-adapter.ts
new file mode 100644
index 000000000..3857b7b27
--- /dev/null
+++ b/apps/desktop/src/lib/diagram/vue-flow-adapter.ts
@@ -0,0 +1,167 @@
+import type { DiagramTable, DiagramRelationship } from "./erDiagram";
+import type { InferredRelationship, DiagramNode, DiagramEdge, DiagramLayer } from "@/types/diagram";
+import type { Node, Edge } from "@vue-flow/core";
+import { useLayerStore } from "./layer-store";
+import { CARD_WIDTH, tableCardHeight } from "./diagram-constants";
+import { handlesFromWaypoints, type Point } from "./edge-obstacle-router";
+
+const LAYER_Z_INDEX = 0;
+const TABLE_Z_INDEX = 10;
+
+export type RelationshipEdgeData = {
+ relationship: DiagramRelationship | InferredRelationship;
+ waypoints?: Point[];
+};
+
+/** Table is on canvas when unlayered, or when its layer is visible. */
+export function isTableCanvasVisible(tableName: string, layers: DiagramLayer[]): boolean {
+ const layer = layers.find((l) => l.tableNames.includes(tableName));
+ if (!layer) return true;
+ return layer.visible;
+}
+
+/** positions are absolute canvas coordinates; converted to relative when parented to a visible layer */
+export function toVueFlowNodes(tables: DiagramTable[], positions?: Record): Node<{ table: DiagramTable }>[] {
+ const layerStore = useLayerStore();
+ const layers = layerStore.layers;
+
+ return tables
+ .filter((table) => !table.pendingDrop && isTableCanvasVisible(table.name, layers))
+ .map((table) => {
+ const layer = layerStore.getLayerByTable(table.name);
+ const absolute = positions?.[table.name] || { x: 0, y: 0 };
+ const visibleParent = layer?.visible ? layer : undefined;
+ const layerPos = visibleParent?.position || { x: 0, y: 0 };
+ const relative = visibleParent ? { x: absolute.x - layerPos.x, y: absolute.y - layerPos.y } : absolute;
+
+ return {
+ id: table.name,
+ type: "table",
+ position: relative,
+ parentNode: visibleParent?.id,
+ expandParent: false,
+ zIndex: TABLE_Z_INDEX,
+ data: { table },
+ };
+ });
+}
+
+export function toDiagramNodes(vueFlowNodes: Node<{ table: DiagramTable }>[]): DiagramNode[] {
+ return vueFlowNodes
+ .filter((node) => (node.type === "table" || !node.type) && node.data?.table)
+ .map((node) => ({
+ id: node.id,
+ type: node.type || "table",
+ position: node.position,
+ data: { table: node.data!.table },
+ }));
+}
+
+/** Pick L/R or T/B handles from table center deltas. */
+export function pickHandles(sourcePos: { x: number; y: number } | undefined, targetPos: { x: number; y: number } | undefined, sourceHeight = 120, targetHeight = 120, cardWidth = CARD_WIDTH): { sourceHandle: string; targetHandle: string } {
+ if (!sourcePos || !targetPos) {
+ return { sourceHandle: "right", targetHandle: "left-target" };
+ }
+ const scx = sourcePos.x + cardWidth / 2;
+ const scy = sourcePos.y + sourceHeight / 2;
+ const tcx = targetPos.x + cardWidth / 2;
+ const tcy = targetPos.y + targetHeight / 2;
+ const dx = tcx - scx;
+ const dy = tcy - scy;
+
+ if (Math.abs(dx) >= Math.abs(dy)) {
+ if (dx >= 0) return { sourceHandle: "right", targetHandle: "left-target" };
+ return { sourceHandle: "left", targetHandle: "right-target" };
+ }
+ if (dy >= 0) return { sourceHandle: "bottom", targetHandle: "top-target" };
+ return { sourceHandle: "top", targetHandle: "bottom-target" };
+}
+
+export function toVueFlowEdges(
+ relationships: (DiagramRelationship | InferredRelationship)[],
+ positions?: Record,
+ waypointsById?: Record,
+ tableHeights?: Record,
+ handleHintsById?: Record,
+): Edge[] {
+ const layers = useLayerStore().layers;
+
+ return relationships
+ .filter((rel) => isTableCanvasVisible(rel.sourceTable, layers) && isTableCanvasVisible(rel.targetTable, layers))
+ .map((rel) => {
+ const sourceHeight = tableHeights?.[rel.sourceTable] ?? tableCardHeight(0);
+ const targetHeight = tableHeights?.[rel.targetTable] ?? tableCardHeight(0);
+ const waypoints = waypointsById?.[rel.id];
+ const fromWaypoints = waypoints?.length ? handlesFromWaypoints(waypoints) : null;
+ const hint = handleHintsById?.[rel.id];
+ const picked = pickHandles(positions?.[rel.sourceTable], positions?.[rel.targetTable], sourceHeight, targetHeight);
+ const sourceHandle = fromWaypoints?.sourceHandle || hint?.sourceHandle || picked.sourceHandle;
+ const targetHandle = fromWaypoints?.targetHandle || hint?.targetHandle || picked.targetHandle;
+ return {
+ id: rel.id,
+ type: "relationship",
+ source: rel.sourceTable,
+ target: rel.targetTable,
+ sourceHandle,
+ targetHandle,
+ class: "relationship-edge",
+ selectable: true,
+ interactionWidth: 24,
+ data: {
+ relationship: rel,
+ ...(waypoints?.length ? { waypoints } : {}),
+ },
+ };
+ });
+}
+
+export function toDiagramEdges(vueFlowEdges: Edge[]): DiagramEdge[] {
+ return vueFlowEdges
+ .filter((edge) => edge.data?.relationship)
+ .map((edge) => ({
+ id: edge.id,
+ source: edge.source,
+ target: edge.target,
+ sourceHandle: edge.sourceHandle ?? undefined,
+ targetHandle: edge.targetHandle ?? undefined,
+ waypoints: edge.data?.waypoints,
+ data: { relationship: edge.data!.relationship },
+ }));
+}
+
+export function toVueFlowLayerNodes(layers: DiagramLayer[]): Node<{ layer: DiagramLayer }>[] {
+ return layers
+ .filter((layer) => layer.visible)
+ .map((layer) => ({
+ id: layer.id,
+ type: "layer",
+ position: layer.position || { x: 0, y: 0 },
+ width: layer.width || 240,
+ height: layer.height || 52,
+ zIndex: LAYER_Z_INDEX,
+ draggable: true,
+ selectable: true,
+ dragHandle: ".layer-drag-handle",
+ data: { layer },
+ style: {
+ width: `${layer.width || 240}px`,
+ height: `${layer.height || 52}px`,
+ pointerEvents: "none",
+ },
+ }));
+}
+
+/** Convert Vue Flow node position (possibly relative) to absolute canvas coords */
+export function toAbsolutePosition(nodeId: string, relativeOrAbsolute: { x: number; y: number }, layers: DiagramLayer[]): { x: number; y: number } {
+ const layer = layers.find((l) => l.id === nodeId);
+ if (layer) return relativeOrAbsolute;
+
+ const parent = layers.find((l) => l.visible && l.tableNames.includes(nodeId));
+ if (parent?.position) {
+ return {
+ x: parent.position.x + relativeOrAbsolute.x,
+ y: parent.position.y + relativeOrAbsolute.y,
+ };
+ }
+ return relativeOrAbsolute;
+}
diff --git a/apps/desktop/src/lib/export/diagramFormats.ts b/apps/desktop/src/lib/export/diagramFormats.ts
new file mode 100644
index 000000000..f71edd2a8
--- /dev/null
+++ b/apps/desktop/src/lib/export/diagramFormats.ts
@@ -0,0 +1,194 @@
+import type { CustomDiagramRelationship, DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram";
+import type { DiagramLayer } from "@/types/diagram";
+
+export type DiagramExportFormat = "svg" | "png" | "json" | "dbml" | "mermaid";
+
+export interface DiagramJsonSnapshot {
+ meta: {
+ connectionName: string;
+ database: string;
+ schema: string;
+ mode: "table" | "engineering";
+ exportedAt: string;
+ };
+ tables: Array<{
+ name: string;
+ columns: Array<{
+ name: string;
+ dataType: string;
+ nullable: boolean;
+ primaryKey: boolean;
+ }>;
+ foreignKeys: Array<{
+ name: string;
+ column: string;
+ refTable: string;
+ refColumn: string;
+ }>;
+ }>;
+ relationships: DiagramRelationship[];
+ positions: Record;
+ layers: DiagramLayer[];
+ customRelationships: CustomDiagramRelationship[];
+ matchConfirms: string[];
+ matchIgnores: string[];
+}
+
+function fileToken(value: string): string {
+ return value
+ .trim()
+ .replace(/[^\p{L}\p{N}._-]+/gu, "-")
+ .replace(/-+/g, "-")
+ .replace(/^-|-$/g, "");
+}
+
+function quoteIdent(name: string): string {
+ if (/^[A-Za-z_][A-Za-z0-9_]*$/.test(name)) return name;
+ return `"${name.replace(/"/g, '\\"')}"`;
+}
+
+function mermaidIdent(name: string): string {
+ const cleaned = name.replace(/[^A-Za-z0-9_]/g, "_");
+ return cleaned || "entity";
+}
+
+function dbmlType(dataType: string): string {
+ const t = dataType.trim() || "varchar";
+ // Keep parentheses content; quote if spaces / special chars
+ if (/^[A-Za-z_][A-Za-z0-9_]*(?:\([^)]*\))?$/.test(t)) return t;
+ return `"${t.replace(/"/g, '\\"')}"`;
+}
+
+function dbmlRefOp(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string {
+ if (sourceCardinality === "1" && targetCardinality === "1") return "-";
+ if (sourceCardinality === "N" && targetCardinality === "N") return "<>";
+ if (sourceCardinality === "N" && targetCardinality === "1") return ">";
+ return "<";
+}
+
+function mermaidCardinality(sourceCardinality: "1" | "N", targetCardinality: "1" | "N"): string {
+ // Mermaid: ENTITY1 ||--o{ ENTITY2 : "label"
+ if (sourceCardinality === "1" && targetCardinality === "1") return "||--||";
+ if (sourceCardinality === "1" && targetCardinality === "N") return "||--o{";
+ if (sourceCardinality === "N" && targetCardinality === "1") return "}o--||";
+ return "}o--o{";
+}
+
+export function diagramExportFileName(connectionName: string, databaseName: string, mode: "table" | "engineering", format: DiagramExportFormat): string {
+ const context = [connectionName, databaseName].map(fileToken).filter(Boolean);
+ const modeSuffix = mode === "engineering" ? "engineering-er" : "table-structure";
+ const ext = format === "svg" ? "svg" : format === "png" ? "png" : format === "json" ? "json" : format === "dbml" ? "dbml" : "mmd";
+ const kind = format === "svg" || format === "png" ? modeSuffix : format === "json" ? "diagram" : format === "dbml" ? "schema" : "er";
+ return ["dbx", ...(context.length > 0 ? context : ["diagram"]), kind].join("-") + `.${ext}`;
+}
+
+export function buildDiagramJson(snapshot: DiagramJsonSnapshot): string {
+ return `${JSON.stringify(snapshot, null, 2)}\n`;
+}
+
+export function buildDiagramDbml(tables: DiagramTable[], relationships: DiagramRelationship[]): string {
+ const lines: string[] = ["// Generated by DBX", ""];
+
+ for (const table of tables) {
+ lines.push(`Table ${quoteIdent(table.name)} {`);
+ for (const column of table.columns) {
+ const attrs: string[] = [];
+ if (column.is_primary_key) attrs.push("pk");
+ if (!column.is_nullable) attrs.push("not null");
+ const attrSuffix = attrs.length > 0 ? ` [${attrs.join(", ")}]` : "";
+ lines.push(` ${quoteIdent(column.name)} ${dbmlType(column.data_type)}${attrSuffix}`);
+ }
+ lines.push("}", "");
+ }
+
+ for (const rel of relationships) {
+ const op = dbmlRefOp(rel.sourceCardinality, rel.targetCardinality);
+ lines.push(`Ref: ${quoteIdent(rel.sourceTable)}.${quoteIdent(rel.sourceColumn)} ${op} ${quoteIdent(rel.targetTable)}.${quoteIdent(rel.targetColumn)}`);
+ }
+
+ if (relationships.length > 0) lines.push("");
+ return lines.join("\n");
+}
+
+export function buildDiagramMermaid(tables: DiagramTable[], relationships: DiagramRelationship[]): string {
+ const lines: string[] = ["erDiagram"];
+
+ for (const table of tables) {
+ const entity = mermaidIdent(table.name);
+ lines.push(` ${entity} {`);
+ for (const column of table.columns) {
+ const typeToken = (column.data_type || "unknown").replace(/\s+/g, "_").replace(/[^A-Za-z0-9_]/g, "") || "unknown";
+ const key = column.is_primary_key ? " PK" : "";
+ lines.push(` ${typeToken} ${mermaidIdent(column.name)}${key}`);
+ }
+ lines.push(" }");
+ }
+
+ for (const rel of relationships) {
+ const left = mermaidIdent(rel.sourceTable);
+ const right = mermaidIdent(rel.targetTable);
+ const card = mermaidCardinality(rel.sourceCardinality, rel.targetCardinality);
+ const label = `${rel.sourceColumn} -> ${rel.targetColumn}`.replace(/"/g, "'");
+ lines.push(` ${left} ${card} ${right} : "${label}"`);
+ }
+
+ lines.push("");
+ return lines.join("\n");
+}
+
+export function svgToPngBlob(svg: string, scale = 2): Promise {
+ return new Promise((resolve, reject) => {
+ const blob = new Blob([svg], { type: "image/svg+xml;charset=utf-8" });
+ const url = URL.createObjectURL(blob);
+ const img = new Image();
+ img.onload = () => {
+ try {
+ const width = Math.max(1, Math.ceil(img.naturalWidth * scale));
+ const height = Math.max(1, Math.ceil(img.naturalHeight * scale));
+ const canvas = document.createElement("canvas");
+ canvas.width = width;
+ canvas.height = height;
+ const ctx = canvas.getContext("2d");
+ if (!ctx) {
+ URL.revokeObjectURL(url);
+ reject(new Error("Canvas unsupported"));
+ return;
+ }
+ ctx.fillStyle = "#fafafa";
+ ctx.fillRect(0, 0, width, height);
+ ctx.drawImage(img, 0, 0, width, height);
+ URL.revokeObjectURL(url);
+ canvas.toBlob((png) => {
+ if (!png) {
+ reject(new Error("PNG encode failed"));
+ return;
+ }
+ resolve(png);
+ }, "image/png");
+ } catch (err) {
+ URL.revokeObjectURL(url);
+ reject(err);
+ }
+ };
+ img.onerror = () => {
+ URL.revokeObjectURL(url);
+ reject(new Error("Failed to load SVG for PNG export"));
+ };
+ img.src = url;
+ });
+}
+
+export function diagramExportDialogFilter(format: DiagramExportFormat): { name: string; extensions: string[] } {
+ switch (format) {
+ case "svg":
+ return { name: "SVG", extensions: ["svg"] };
+ case "png":
+ return { name: "PNG", extensions: ["png"] };
+ case "json":
+ return { name: "JSON", extensions: ["json"] };
+ case "dbml":
+ return { name: "DBML", extensions: ["dbml"] };
+ case "mermaid":
+ return { name: "Mermaid", extensions: ["mmd", "md"] };
+ }
+}
diff --git a/apps/desktop/src/lib/export/diagramSvgExport.ts b/apps/desktop/src/lib/export/diagramSvgExport.ts
index 50f9433f4..3dbfb7fd7 100644
--- a/apps/desktop/src/lib/export/diagramSvgExport.ts
+++ b/apps/desktop/src/lib/export/diagramSvgExport.ts
@@ -1,34 +1,51 @@
import type { EngineeringDiagram, EngineeringEntityNode } from "@/lib/diagram/engineeringDiagram";
import type { DiagramPosition, DiagramRelationship, DiagramTable } from "@/lib/diagram/erDiagram";
+import { pickHandles } from "@/lib/diagram/vue-flow-adapter";
+import { pointAlongPolyline, pointsToSvgPath, type Point } from "@/lib/diagram/edge-obstacle-router";
+import { CARD_BOTTOM_PADDING, CARD_HEADER_HEIGHT, CARD_WIDTH, COLUMN_ROW_HEIGHT, MARGIN } from "@/lib/diagram/diagram-constants";
-type DiagramSvgMode = "table" | "engineering";
+const SOURCE_CARDINALITY_T = 0.18;
+const TARGET_CARDINALITY_T = 0.82;
interface DiagramCanvas {
width: number;
height: number;
+ /** viewBox origin; defaults to 0 when omitted (engineering mode already normalizes to ~0). */
+ originX?: number;
+ originY?: number;
}
-export interface TableDiagramRelationshipLayout {
- path: string;
- routePoints: DiagramPosition[];
- sourceCardinality: DiagramPosition;
- targetCardinality: DiagramPosition;
+export interface DiagramSvgLayer {
+ id: string;
+ name: string;
+ color: string;
+ x: number;
+ y: number;
+ width: number;
+ height: number;
}
export interface TableDiagramSvgOptions {
tables: DiagramTable[];
relationships: DiagramRelationship[];
positions: Record;
- relationshipLayouts: Record;
+ relationshipPaths: Record;
+ /** Polyline points for endpoint cardinality badges (aligned with relationshipPaths). */
+ relationshipPolylines?: Record;
canvas: DiagramCanvas;
cardWidth: number;
cardHeaderHeight: number;
columnRowHeight: number;
- maxVisibleColumns: number;
cardBottomPadding?: number;
- moreColumnsLabel?: (count: number) => string;
+ layers?: DiagramSvgLayer[];
}
+type CardHeightMetrics = {
+ cardHeaderHeight: number;
+ columnRowHeight: number;
+ cardBottomPadding?: number;
+};
+
function escapeXml(value: string | number): string {
return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'");
}
@@ -37,17 +54,11 @@ function svgNumber(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/\.?0+$/, "");
}
-interface SvgViewport {
- x: number;
- y: number;
- width: number;
- height: number;
-}
-
-function svgHeader(canvas: DiagramCanvas, viewport: SvgViewport = { x: 0, y: 0, width: canvas.width, height: canvas.height }): string {
+function svgHeader(canvas: DiagramCanvas): string {
+ // Always viewBox 0 0 — callers that use non-zero canvas.origin must translate content (see buildTableDiagramSvg).
return [
- `