Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 18 additions & 15 deletions lib/features/erd/erd_export.dart
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import 'package:querya_desktop/features/erd/erd_layout.dart';
import 'package:querya_desktop/features/erd/erd_model.dart';
import 'package:querya_desktop/features/erd/erd_router.dart';

/// Text exports of an [ErdSchema].
class ErdExport {
Expand Down Expand Up @@ -38,25 +39,27 @@ class ErdExport {
.replaceAll('>', '>')
.replaceAll('"', '"');

/// Standalone SVG rendering of the diagram.
static String toSvg(ErdSchema schema, ErdLayout layout) {
final byName = {for (final t in schema.tables) t.name: t};
final w = layout.size.width, h = layout.size.height;
static String _n(double v) => v.toStringAsFixed(1);

/// Standalone SVG rendering of the diagram, with the same positions and
/// edge routes as the screen ([routes] defaults to a fresh routing).
static String toSvg(ErdSchema schema, ErdLayout layout, {List<ErdRoute>? routes}) {
final size = layout.size;
final w = _n(size.width), h = _n(size.height);
final b = StringBuffer()
..writeln('<svg xmlns="http://www.w3.org/2000/svg" width="$w" height="$h" '
'viewBox="0 0 $w $h" font-family="sans-serif" font-size="12">')
..writeln('<rect width="100%" height="100%" fill="#ffffff"/>');
for (final r in schema.relations) {
final from = byName[r.fromTable], to = byName[r.toTable];
if (from == null || to == null) continue;
final fr = layout.rectOf(from), tr = layout.rectOf(to);
final fromRight = fr.center.dx < tr.center.dx;
final x1 = fromRight ? fr.right : fr.left;
final x2 = fromRight ? tr.left : tr.right;
final y1 = layout.columnY(from, r.fromColumn);
final y2 = layout.columnY(to, r.toColumn);
b.writeln('<line x1="$x1" y1="$y1" x2="$x2" y2="$y2" '
'stroke="#64748b" stroke-width="1.5"/>');
for (final r in routes ?? ErdRouter.route(schema, layout)) {
if (r.points.length < 2) continue;
final d = StringBuffer('M ${_n(r.points.first.dx)} ${_n(r.points.first.dy)}');
for (final p in r.points.skip(1)) {
d.write(' L ${_n(p.dx)} ${_n(p.dy)}');
}
b.writeln('<path d="$d" fill="none" stroke="#64748b" stroke-width="1.5" '
'stroke-linejoin="round"><title>${_esc(r.relation.fromTable)}.'
'${_esc(r.relation.fromColumn)} → ${_esc(r.relation.toTable)}.'
'${_esc(r.relation.toColumn)}</title></path>');
}
for (final t in schema.tables) {
final rect = layout.rectOf(t);
Expand Down
150 changes: 108 additions & 42 deletions lib/features/erd/erd_layout.dart
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,24 @@ import 'dart:ui';
import 'package:querya_desktop/features/erd/erd_model.dart';

/// Geometry of the diagram: card sizes, positions and canvas extent.
///
/// Immutable; [withPosition] returns a copy with one card moved (drag).
class ErdLayout {
ErdLayout._(this.positions, this.size);
ErdLayout._(this.positions, this._heights);

static const double cardWidth = 220;
static const double headerHeight = 32;
static const double headerHeight = 34;
static const double rowHeight = 22;
static const double gap = 60;
static const double margin = 40;

/// Horizontal gap between layers: room for the edge tracks.
static const double layerGap = 110;

/// Vertical gap between cards of one layer.
static const double cardGap = 44;

final Map<String, Offset> positions;
final Size size;
final Map<String, double> _heights;

static double cardHeight(ErdTable t) =>
headerHeight + rowHeight * t.columns.length + 6;
Expand All @@ -22,6 +30,16 @@ class ErdLayout {
Rect rectOf(ErdTable t) =>
positions[t.name]! & Size(cardWidth, cardHeight(t));

/// Canvas extent: every card plus a margin.
Size get size {
var w = margin * 2, h = margin * 2;
positions.forEach((name, p) {
w = math.max(w, p.dx + cardWidth + margin);
h = math.max(h, p.dy + (_heights[name] ?? 0) + margin);
});
return Size(w, h);
}

/// Vertical center of [column] inside [t]'s card (header when unknown).
double columnY(ErdTable t, String column) {
final i = t.columns.indexWhere((c) => c.name == column);
Expand All @@ -30,54 +48,102 @@ class ErdLayout {
return top + headerHeight + rowHeight * i + rowHeight / 2;
}

/// Grid layout; tables are ordered so related ones sit next to each other.
/// Copy with [table] moved to [topLeft] (kept inside the canvas origin).
ErdLayout withPosition(String table, Offset topLeft) {
final next = Map<String, Offset>.of(positions);
next[table] = Offset(math.max(8, topLeft.dx), math.max(8, topLeft.dy));
return ErdLayout._(next, _heights);
}

/// Layered layout: a referenced table sits in a layer left of the tables
/// that reference it; inside a layer, cards are ordered by the average
/// position of their neighbours to reduce crossings. Tables without
/// relations go into a grid below.
factory ErdLayout.compute(ErdSchema schema) {
final names = [for (final t in schema.tables) t.name];
final byName = {for (final t in schema.tables) t.name: t};
final adj = <String, Set<String>>{
for (final t in schema.tables) t.name: <String>{},
};
final heights = {for (final t in schema.tables) t.name: cardHeight(t)};
final neighbours = {for (final n in names) n: <String>{}};
for (final r in schema.relations) {
adj[r.fromTable]?.add(r.toTable);
adj[r.toTable]?.add(r.fromTable);
if (r.fromTable == r.toTable) continue;
neighbours[r.fromTable]!.add(r.toTable);
neighbours[r.toTable]!.add(r.fromTable);
}
final ordered = <ErdTable>[];
final seen = <String>{};
final roots = schema.tables.toList()
..sort((a, b) => adj[b.name]!.length.compareTo(adj[a.name]!.length));
for (final root in roots) {
if (!seen.add(root.name)) continue;
final queue = [root.name];
while (queue.isNotEmpty) {
final cur = queue.removeAt(0);
ordered.add(byName[cur]!);
final next = adj[cur]!.toList()..sort();
for (final n in next) {
if (seen.add(n)) queue.add(n);
final related = [for (final n in names) if (neighbours[n]!.isNotEmpty) n];
final isolated = [for (final n in names) if (neighbours[n]!.isEmpty) n];

// Longest-path layering; the cap breaks cycles.
final layer = {for (final n in related) n: 0};
final cap = math.max(0, related.length - 1);
for (var pass = 0; pass < related.length; pass++) {
var changed = false;
for (final r in schema.relations) {
if (r.fromTable == r.toTable) continue;
final want = math.min(cap, layer[r.toTable]! + 1);
if (layer[r.fromTable]! < want) {
layer[r.fromTable] = want;
changed = true;
}
}
if (!changed) break;
}
final layerCount =
related.isEmpty ? 0 : layer.values.reduce(math.max) + 1;
final layers = [
for (var i = 0; i < layerCount; i++)
[for (final n in related) if (layer[n] == i) n],
]..removeWhere((l) => l.isEmpty);

// Barycenter sweeps.
Map<String, double> rank() => {
for (final l in layers)
for (var i = 0; i < l.length; i++) l[i]: i / math.max(1, l.length - 1),
};
for (var sweep = 0; sweep < 4; sweep++) {
final r = rank();
for (final l in layers) {
final bary = {
for (final n in l)
n: neighbours[n]!.isEmpty
? r[n]!
: neighbours[n]!.map((m) => r[m]!).reduce((a, b) => a + b) /
neighbours[n]!.length,
};
l.sort((a, b) {
final c = bary[a]!.compareTo(bary[b]!);
return c != 0 ? c : names.indexOf(a).compareTo(names.indexOf(b));
});
}
}

double columnHeight(List<String> l) =>
l.fold<double>(0, (s, n) => s + heights[n]!) +
cardGap * math.max(0, l.length - 1);
final tallest = layers.isEmpty ? 0.0 : layers.map(columnHeight).reduce(math.max);

final perRow = math.max(1, math.sqrt(ordered.length).ceil());
final positions = <String, Offset>{};
var x = gap;
var y = gap;
var rowMaxH = 0.0;
var maxX = 0.0;
for (var i = 0; i < ordered.length; i++) {
if (i > 0 && i % perRow == 0) {
x = gap;
y += rowMaxH + gap;
rowMaxH = 0;
for (var i = 0; i < layers.length; i++) {
final x = margin + i * (cardWidth + layerGap);
var y = margin + (tallest - columnHeight(layers[i])) / 2;
for (final n in layers[i]) {
positions[n] = Offset(x, y);
y += heights[n]! + cardGap;
}
}

if (isolated.isNotEmpty) {
final perRow = math.max(
math.max(1, layers.length), math.sqrt(isolated.length).ceil());
var y = layers.isEmpty ? margin : margin + tallest + cardGap * 2;
for (var i = 0; i < isolated.length; i += perRow) {
final row = isolated.sublist(i, math.min(i + perRow, isolated.length));
for (var j = 0; j < row.length; j++) {
positions[row[j]] = Offset(margin + j * (cardWidth + cardGap), y);
}
y += row.map((n) => heights[n]!).reduce(math.max) + cardGap;
}
final t = ordered[i];
positions[t.name] = Offset(x, y);
rowMaxH = math.max(rowMaxH, cardHeight(t));
x += cardWidth + gap;
maxX = math.max(maxX, x);
}
return ErdLayout._(
positions,
Size(math.max(maxX, gap * 2), y + rowMaxH + gap),
);
assert(positions.length == byName.length);
return ErdLayout._(positions, heights);
}
}
Loading
Loading