diff --git a/src/components/EditorHeader/ControlPanel.jsx b/src/components/EditorHeader/ControlPanel.jsx index 685669c64..73aba5fac 100644 --- a/src/components/EditorHeader/ControlPanel.jsx +++ b/src/components/EditorHeader/ControlPanel.jsx @@ -85,6 +85,7 @@ import { getTableHeight } from "../../utils/utils"; import { deleteFromCache, STORAGE_KEY } from "../../utils/cache"; import { useLiveQuery } from "dexie-react-hooks"; import { DateTime } from "luxon"; +import { arrangeTables } from "../../utils/arrangeTables"; export default function ControlPanel({ title, setTitle, lastSaved }) { const { id: diagramId } = useParams(); @@ -510,6 +511,53 @@ export default function ControlPanel({ title, setTitle, lastSaved }) { }; const resetView = () => setTransform((prev) => ({ ...prev, zoom: 1, pan: { x: 0, y: 0 } })); + const autoArrange = () => { + if (layout.readOnly || tables.length === 0) return; + + const diagram = { + tables: tables.map((t) => ({ ...t })), + relationships: relationships.map((r) => ({ ...r })), + }; + + arrangeTables(diagram, { tableWidth: settings.tableWidth }); + + const movedElements = []; + const nextTables = tables.map((table) => { + const updated = diagram.tables.find((t) => t.id === table.id); + if (!updated) return table; + + if (updated.x === table.x && updated.y === table.y) { + return table; + } + + movedElements.push({ + id: table.id, + type: ObjectType.TABLE, + undo: { x: table.x, y: table.y }, + redo: { x: updated.x, y: updated.y }, + }); + + return { ...table, x: updated.x, y: updated.y }; + }); + + if (movedElements.length === 0) { + Toast.info(t("no_changes_to_record")); + return; + } + + setTables(nextTables); + setUndoStack((prev) => [ + ...prev, + { + action: Action.MOVE, + bulk: true, + message: t("auto_arrange"), + elements: movedElements, + }, + ]); + setRedoStack([]); + setSaveState(State.SAVING); + }; const fitWindow = () => { const canvas = document.getElementById("canvas").getBoundingClientRect(); @@ -1686,6 +1734,15 @@ export default function ControlPanel({ title, setTitle, lastSaved }) { + + + + + t.id)); + const adjacency = new Map(); + + idSet.forEach((id) => { + adjacency.set(id, new Set()); + }); + + relationships.forEach((r) => { + const start = r.startTableId; + const end = r.endTableId; + if (!idSet.has(start) || !idSet.has(end) || start === end) return; + adjacency.get(start).add(end); + adjacency.get(end).add(start); + }); + + let isolatedTables = 0; + adjacency.forEach((neighbors) => { + if (neighbors.size === 0) isolatedTables += 1; + }); + + let maxDepth = 0; + const ids = Array.from(idSet); + + // Compute graph "depth" as the maximum shortest-path distance between + // any two connected tables in the (undirected) relationship graph. + for (const sourceId of ids) { + const visited = new Set([sourceId]); + const queue = [[sourceId, 0]]; + + while (queue.length) { + const [current, dist] = queue.shift(); + if (dist > maxDepth) { + maxDepth = dist; + } + const neighbors = adjacency.get(current); + if (!neighbors) continue; + neighbors.forEach((n) => { + if (!visited.has(n)) { + visited.add(n); + queue.push([n, dist + 1]); + } + }); + } + } + + const avgDegree = + numTables === 0 ? 0 : (numRelationships * 2) / numTables || 0; + + return { + numTables, + numRelationships, + maxDepth, + isolatedTables, + avgDegree, + }; +} + +export default function StatsBox() { + const { tables, relationships } = useDiagram(); + const { t } = useTranslation(); + + const stats = useMemo( + () => computeGraphStats(tables ?? [], relationships ?? []), + [tables, relationships], + ); + + if (!stats.numTables && !stats.numRelationships) return null; + + return ( + + + + {t("stats")} + + + + {t("tables")}:{" "} + {stats.numTables} + + + {t("relationships")}:{" "} + {stats.numRelationships} + + + + + {t("max_depth")}:{" "} + {stats.maxDepth} + + + {t("isolated_tables")}:{" "} + {stats.isolatedTables} + + + + + ); +} + diff --git a/src/components/Workspace.jsx b/src/components/Workspace.jsx index fa0f1ebd7..ab6efa060 100644 --- a/src/components/Workspace.jsx +++ b/src/components/Workspace.jsx @@ -18,6 +18,7 @@ import { useEnums, } from "../hooks"; import FloatingControls from "./FloatingControls"; +import StatsBox from "./StatsBox"; import { Button, Modal, Tag } from "@douyinfe/semi-ui"; import { IconAlertTriangle } from "@douyinfe/semi-icons"; import { useTranslation } from "react-i18next"; @@ -491,6 +492,7 @@ export default function WorkSpace() { + {version && ( { + if (t && t.id != null) indexById.set(t.id, i); + }); + + const edges = []; + for (const rel of relationships) { + const startIdx = indexById.get(rel.startTableId); + const endIdx = indexById.get(rel.endTableId); + if ( + typeof startIdx !== "number" || + typeof endIdx !== "number" || + startIdx === endIdx + ) { + continue; + } + edges.push([startIdx, endIdx]); + } + + // If we couldn't construct any edges, fallback to the simple layout. + if (!edges.length) { + simpleTwoRowLayout(tables, tableWidth); + return; + } + + // ----- Force-directed layout (Fruchterman–Reingold style) ----- + + // Logical drawing area; we normalize positions into this box. + const areaWidth = Math.max(800, Math.sqrt(n) * (tableWidth + 160)); + const areaHeight = areaWidth; + const centerX = areaWidth / 2; + const centerY = areaHeight / 2; + + // Initial positions: use existing coordinates when available, + // otherwise place nodes on a circle. + const positions = tables.map((t, i) => { + if ( + typeof t.x === "number" && + !Number.isNaN(t.x) && + typeof t.y === "number" && + !Number.isNaN(t.y) + ) { + return { x: t.x, y: t.y }; + } + + const angle = (2 * Math.PI * i) / n; + const radius = Math.min(areaWidth, areaHeight) / 3; + return { + x: centerX + radius * Math.cos(angle), + y: centerY + radius * Math.sin(angle), + }; + }); + + // Optimal pairwise distance + const k = Math.sqrt((areaWidth * areaHeight) / n); + const maxIterations = Math.min(200, 30 + n * 5); + let temperature = areaWidth / 10; + + for (let iter = 0; iter < maxIterations; iter++) { + const disp = Array.from({ length: n }, () => ({ x: 0, y: 0 })); + + // Repulsive forces between all pairs of tables + for (let v = 0; v < n; v++) { + for (let u = v + 1; u < n; u++) { + let dx = positions[v].x - positions[u].x; + let dy = positions[v].y - positions[u].y; + let dist = Math.hypot(dx, dy); + if (dist === 0) { + // Avoid division by zero by nudging slightly + dx = (Math.random() - 0.5) || 0.01; + dy = (Math.random() - 0.5) || 0.01; + dist = Math.hypot(dx, dy); + } + const force = (k * k) / dist; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + disp[v].x += fx; + disp[v].y += fy; + disp[u].x -= fx; + disp[u].y -= fy; + } + } + + // Attractive forces along edges (relationships) + for (const [v, u] of edges) { + let dx = positions[v].x - positions[u].x; + let dy = positions[v].y - positions[u].y; + let dist = Math.hypot(dx, dy); + if (dist === 0) { + dx = (Math.random() - 0.5) || 0.01; + dy = (Math.random() - 0.5) || 0.01; + dist = Math.hypot(dx, dy); + } + const force = (dist * dist) / k; + const fx = (dx / dist) * force; + const fy = (dy / dist) * force; + disp[v].x -= fx; + disp[v].y -= fy; + disp[u].x += fx; + disp[u].y += fy; + } + + // Mild gravity towards center to keep components together + for (let v = 0; v < n; v++) { + const dx = positions[v].x - centerX; + const dy = positions[v].y - centerY; + disp[v].x -= dx * 0.01; + disp[v].y -= dy * 0.01; + } + + // Apply displacements with cooling and bounding box + for (let v = 0; v < n; v++) { + const dx = disp[v].x; + const dy = disp[v].y; + const dispLen = Math.hypot(dx, dy); + if (dispLen > 0) { + const limited = Math.min(temperature, dispLen); + positions[v].x += (dx / dispLen) * limited; + positions[v].y += (dy / dispLen) * limited; + } + + // Keep within bounds, leaving a margin for table size + const margin = 40; + const maxX = areaWidth - tableWidth - margin; + const maxY = + areaHeight - + (tableHeaderHeight + tableColorStripHeight + tableFieldHeight * 2) - + margin; + + positions[v].x = Math.min( + maxX, + Math.max(margin, positions[v].x), + ); + positions[v].y = Math.min( + maxY, + Math.max(margin, positions[v].y), + ); + } + + temperature *= 0.95; + if (temperature < 1) break; + } + + // Normalize so that the minimum coordinates start near a fixed padding + let minX = Infinity; + let minY = Infinity; + for (const p of positions) { + minX = Math.min(minX, p.x); + minY = Math.min(minY, p.y); + } + const offsetX = 40 - minX; + const offsetY = 40 - minY; + + positions.forEach((p, i) => { + tables[i].x = Math.round(p.x + offsetX); + tables[i].y = Math.round(p.y + offsetY); + }); +} + +function simpleTwoRowLayout(tables, tableWidth) { let maxHeight = -1; - const tableWidth = 200; const gapX = 54; const gapY = 40; - diagram.tables.forEach((table, i) => { - if (i < diagram.tables.length / 2) { + + tables.forEach((table, i) => { + if (i < tables.length / 2) { table.x = i * tableWidth + (i + 1) * gapX; table.y = gapY; const height = @@ -19,7 +208,7 @@ export function arrangeTables(diagram) { tableColorStripHeight; maxHeight = Math.max(height, maxHeight); } else { - const index = diagram.tables.length - i - 1; + const index = tables.length - i - 1; table.x = index * tableWidth + (index + 1) * gapX; table.y = maxHeight + 2 * gapY; }