diff --git a/lib/features/erd/erd_export.dart b/lib/features/erd/erd_export.dart index 5515265c..0580451a 100644 --- a/lib/features/erd/erd_export.dart +++ b/lib/features/erd/erd_export.dart @@ -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 { @@ -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? routes}) { + final size = layout.size; + final w = _n(size.width), h = _n(size.height); final b = StringBuffer() ..writeln('') ..writeln(''); - 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(''); + 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('${_esc(r.relation.fromTable)}.' + '${_esc(r.relation.fromColumn)} → ${_esc(r.relation.toTable)}.' + '${_esc(r.relation.toColumn)}'); } for (final t in schema.tables) { final rect = layout.rectOf(t); diff --git a/lib/features/erd/erd_layout.dart b/lib/features/erd/erd_layout.dart index 64f30631..e9e976ae 100644 --- a/lib/features/erd/erd_layout.dart +++ b/lib/features/erd/erd_layout.dart @@ -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 positions; - final Size size; + final Map _heights; static double cardHeight(ErdTable t) => headerHeight + rowHeight * t.columns.length + 6; @@ -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); @@ -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.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 = >{ - for (final t in schema.tables) t.name: {}, - }; + final heights = {for (final t in schema.tables) t.name: cardHeight(t)}; + final neighbours = {for (final n in names) n: {}}; 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 = []; - final seen = {}; - 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 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 l) => + l.fold(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 = {}; - 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); } } diff --git a/lib/features/erd/erd_router.dart b/lib/features/erd/erd_router.dart new file mode 100644 index 00000000..0d0a1e72 --- /dev/null +++ b/lib/features/erd/erd_router.dart @@ -0,0 +1,379 @@ +import 'dart:math' as math; +import 'dart:ui'; + +import 'package:querya_desktop/features/erd/erd_layout.dart'; +import 'package:querya_desktop/features/erd/erd_model.dart'; + +/// One routed relation: an orthogonal polyline from the FK column row of +/// [relation]'s `fromTable` to the referenced column row of `toTable`. +class ErdRoute { + const ErdRoute(this.relation, this.points); + + final ErdRelation relation; + + /// First point on the FK card's edge, last on the referenced card's edge. + final List points; +} + +/// Orthogonal edge router: A* over a sparse grid made of the card borders +/// (inflated by [clearance]) and the column rows, with a penalty per bend, so +/// edges go around cards. Overlapping parallel segments are then spread +/// apart ("nudged") so they do not draw on top of each other. +abstract final class ErdRouter { + /// Free space kept around every card. + static const double clearance = 14; + static const double _bendCost = 60; + static const double _nudgeStep = 6; + + static List route(ErdSchema schema, ErdLayout layout) { + final byName = {for (final t in schema.tables) t.name: t}; + final relations = [ + for (final r in schema.relations) + if (byName.containsKey(r.fromTable) && byName.containsKey(r.toTable)) r, + ]; + if (relations.isEmpty) return const []; + + final rects = [ + for (final t in schema.tables) layout.rectOf(t).inflate(clearance), + ]; + + // Ports: both sides of each end, at the column row. + final ports = <_Ends>[]; + final xs = {}, ys = {}; + for (final r in rects) { + xs..add(r.left)..add(r.right); + ys..add(r.top)..add(r.bottom); + } + for (final rel in relations) { + final from = byName[rel.fromTable]!, to = byName[rel.toTable]!; + final fr = layout.rectOf(from), tr = layout.rectOf(to); + final fy = layout.columnY(from, rel.fromColumn); + final ty = layout.columnY(to, rel.toColumn); + ports.add(_Ends(fr, fy, tr, ty)); + ys..add(fy)..add(ty); + } + final grid = _Grid(_withMidpoints(xs), _withMidpoints(ys), rects); + + final routes = >[]; + for (var i = 0; i < relations.length; i++) { + routes.add(_routeOne(grid, ports[i], relations[i].fromTable == relations[i].toTable)); + } + _nudge(routes); + return [ + for (var i = 0; i < relations.length; i++) ErdRoute(relations[i], routes[i]), + ]; + } + + static List _withMidpoints(Set values) { + final sorted = values.toList()..sort(); + final out = []; + for (var i = 0; i < sorted.length; i++) { + if (i > 0 && sorted[i] - sorted[i - 1] > 2 * clearance) { + out.add((sorted[i] + sorted[i - 1]) / 2); + } + out.add(sorted[i]); + } + return out; + } + + static List _routeOne(_Grid g, _Ends e, bool self) { + // Start: leave the FK card left or right; goal: enter the target likewise. + final starts = <(Offset port, Offset stub, int dir)>[ + (Offset(e.from.left, e.fromY), Offset(e.from.left - clearance, e.fromY), _left), + (Offset(e.from.right, e.fromY), Offset(e.from.right + clearance, e.fromY), _right), + ]; + final goals = <(Offset port, Offset stub, int dir)>[ + (Offset(e.to.left, e.toY), Offset(e.to.left - clearance, e.toY), _right), + (Offset(e.to.right, e.toY), Offset(e.to.right + clearance, e.toY), _left), + ]; + final path = g.search( + [for (final s in starts) (s.$2, s.$3)], + [for (final t in goals) (t.$2, t.$3)], + bendCost: _bendCost, + ); + if (path == null) { + // No free path (cards overlap): straight dog-leg between the near sides. + final s = e.from.center.dx <= e.to.center.dx ? starts[1] : starts[0]; + final t = e.from.center.dx <= e.to.center.dx ? goals[0] : goals[1]; + final midX = (s.$2.dx + t.$2.dx) / 2; + return _simplify([ + s.$1, s.$2, Offset(midX, s.$2.dy), Offset(midX, t.$2.dy), t.$2, t.$1, + ]); + } + final (points, startIndex, goalIndex) = path; + return _simplify([starts[startIndex].$1, ...points, goals[goalIndex].$1]); + } + + static List _simplify(List pts) { + final out = []; + for (final p in pts) { + if (out.isNotEmpty && (out.last - p).distance < 0.01) continue; + if (out.length >= 2) { + final a = out[out.length - 2], b = out.last; + final collinear = (a.dx == b.dx && b.dx == p.dx) || + (a.dy == b.dy && b.dy == p.dy); + if (collinear) { + out[out.length - 1] = p; + continue; + } + } + out.add(p); + } + return out; + } + + /// Spreads segments that share a grid line and overlap. The first and last + /// segments (attached to column rows) never move. + static void _nudge(List> routes) { + final groups = >{}; + for (var r = 0; r < routes.length; r++) { + final pts = routes[r]; + for (var s = 1; s < pts.length - 2; s++) { + final a = pts[s], b = pts[s + 1]; + if (a.dx == b.dx) { + groups.putIfAbsent('v${a.dx}', () => []).add( + (r, s, math.min(a.dy, b.dy), math.max(a.dy, b.dy))); + } else if (a.dy == b.dy) { + groups.putIfAbsent('h${a.dy}', () => []).add( + (r, s, math.min(a.dx, b.dx), math.max(a.dx, b.dx))); + } + } + } + for (final entry in groups.entries) { + final vertical = entry.key.startsWith('v'); + final segs = entry.value..sort((a, b) => a.$3.compareTo(b.$3)); + // Clusters of overlapping intervals. + var cluster = <(int, int, double, double)>[]; + var end = double.negativeInfinity; + void flush() { + if (cluster.length > 1) { + final n = cluster.length; + final step = math.min(_nudgeStep, (2 * (clearance - 4)) / (n - 1)); + for (var k = 0; k < n; k++) { + final off = (k - (n - 1) / 2) * step; + final (r, s, _, _) = cluster[k]; + final pts = routes[r]; + pts[s] = vertical + ? Offset(pts[s].dx + off, pts[s].dy) + : Offset(pts[s].dx, pts[s].dy + off); + pts[s + 1] = vertical + ? Offset(pts[s + 1].dx + off, pts[s + 1].dy) + : Offset(pts[s + 1].dx, pts[s + 1].dy + off); + } + } + cluster = []; + } + + for (final seg in segs) { + if (seg.$3 >= end - 0.5) flush(); + cluster.add(seg); + end = cluster.length == 1 ? seg.$4 : math.max(end, seg.$4); + } + flush(); + } + } +} + +const _left = 0, _right = 1, _up = 2, _down = 3; + +class _Ends { + _Ends(this.from, this.fromY, this.to, this.toY); + final Rect from, to; + final double fromY, toY; +} + +/// Sparse orthogonal visibility grid. +class _Grid { + _Grid(this.xs, this.ys, this.rects) + : _free = List.generate( + xs.length, + (i) => List.generate( + ys.length, (j) => !_insideAny(rects, Offset(xs[i], ys[j]))), + ); + + final List xs, ys; + final List rects; + final List> _free; + + static bool _insideAny(List rects, Offset p) { + for (final r in rects) { + if (p.dx > r.left + 0.01 && + p.dx < r.right - 0.01 && + p.dy > r.top + 0.01 && + p.dy < r.bottom - 0.01) { + return true; + } + } + return false; + } + + bool _segmentFree(Offset a, Offset b) => + !_insideAny(rects, Offset((a.dx + b.dx) / 2, (a.dy + b.dy) / 2)); + + int _ix(double x) => _index(xs, x); + int _iy(double y) => _index(ys, y); + + static int _index(List v, double x) { + var lo = 0, hi = v.length - 1; + while (lo <= hi) { + final mid = (lo + hi) >> 1; + if ((v[mid] - x).abs() < 0.01) return mid; + if (v[mid] < x) { + lo = mid + 1; + } else { + hi = mid - 1; + } + } + return -1; + } + + /// A* from any start (point, direction it leaves in) to any goal (point, + /// direction it must arrive in). Returns grid points, start and goal index. + (List, int, int)? search( + List<(Offset, int)> starts, + List<(Offset, int)> goals, { + required double bendCost, + }) { + final w = xs.length, h = ys.length; + int key(int i, int j, int d) => ((i * h) + j) * 4 + d; + final goalAt = {}; + for (var g = 0; g < goals.length; g++) { + final i = _ix(goals[g].$1.dx), j = _iy(goals[g].$1.dy); + if (i < 0 || j < 0 || !_free[i][j]) continue; + goalAt[i * h + j] = (g, goals[g].$2); + } + if (goalAt.isEmpty) return null; + + double heuristic(int i, int j) { + var best = double.infinity; + for (final g in goals) { + final d = (xs[i] - g.$1.dx).abs() + (ys[j] - g.$1.dy).abs(); + if (d < best) best = d; + } + return best; + } + + final cost = {}; + final prev = {}; + final startOf = {}; + final heap = _Heap(); + for (var s = 0; s < starts.length; s++) { + final i = _ix(starts[s].$1.dx), j = _iy(starts[s].$1.dy); + if (i < 0 || j < 0 || !_free[i][j]) continue; + final k = key(i, j, starts[s].$2); + cost[k] = 0; + startOf[k] = s; + heap.push(k, heuristic(i, j)); + } + + // A goal is final once nothing cheaper is left in the heap (f >= g). + var bestCost = double.infinity; + int? bestKey; + var bestGoal = -1; + var expanded = 0; + final closed = {}; + while (heap.isNotEmpty) { + if (heap.peekPriority >= bestCost) break; + final k = heap.pop(); + if (!closed.add(k)) continue; + if (++expanded > w * h * 4) break; + final d = k % 4, cell = k ~/ 4, i = cell ~/ h, j = cell % h; + final c = cost[k]!; + final goal = goalAt[cell]; + if (goal != null) { + final want = goal.$2; + final opposite = (d ^ want) == 1 && (d >> 1) == (want >> 1); + final total = c + (d == want ? 0 : opposite ? 2 * bendCost : bendCost); + if (total < bestCost) { + bestCost = total; + bestKey = k; + bestGoal = goal.$1; + } + } + for (final nd in const [_left, _right, _up, _down]) { + final ni = i + (nd == _left ? -1 : nd == _right ? 1 : 0); + final nj = j + (nd == _up ? -1 : nd == _down ? 1 : 0); + if (ni < 0 || nj < 0 || ni >= w || nj >= h || !_free[ni][nj]) continue; + // No U-turns. + if ((d == _left && nd == _right) || + (d == _right && nd == _left) || + (d == _up && nd == _down) || + (d == _down && nd == _up)) { + continue; + } + final a = Offset(xs[i], ys[j]), b = Offset(xs[ni], ys[nj]); + if (!_segmentFree(a, b)) continue; + final nc = c + (a - b).distance + (nd == d ? 0 : bendCost); + final nk = key(ni, nj, nd); + if (nc < (cost[nk] ?? double.infinity)) { + cost[nk] = nc; + prev[nk] = k; + heap.push(nk, nc + heuristic(ni, nj)); + } + } + } + final end = bestKey; + if (end == null) return null; + final pts = []; + var cur = end; + while (true) { + final cc = cur ~/ 4; + pts.add(Offset(xs[cc ~/ h], ys[cc % h])); + final p = prev[cur]; + if (p == null) break; + cur = p; + } + return (pts.reversed.toList(), startOf[cur]!, bestGoal); + } +} + +/// Binary min-heap of (key, priority). +class _Heap { + final _keys = []; + final _prio = []; + + bool get isNotEmpty => _keys.isNotEmpty; + bool get isEmpty => _keys.isEmpty; + double get peekPriority => _prio.first; + + void push(int k, double p) { + _keys.add(k); + _prio.add(p); + var i = _keys.length - 1; + while (i > 0) { + final parent = (i - 1) >> 1; + if (_prio[parent] <= _prio[i]) break; + _swap(i, parent); + i = parent; + } + } + + int pop() { + final top = _keys.first; + final lastK = _keys.removeLast(), lastP = _prio.removeLast(); + if (_keys.isNotEmpty) { + _keys[0] = lastK; + _prio[0] = lastP; + var i = 0; + while (true) { + final l = 2 * i + 1, r = l + 1; + var m = i; + if (l < _keys.length && _prio[l] < _prio[m]) m = l; + if (r < _keys.length && _prio[r] < _prio[m]) m = r; + if (m == i) break; + _swap(i, m); + i = m; + } + } + return top; + } + + void _swap(int a, int b) { + final k = _keys[a]; + _keys[a] = _keys[b]; + _keys[b] = k; + final p = _prio[a]; + _prio[a] = _prio[b]; + _prio[b] = p; + } +} diff --git a/lib/features/erd/erd_view.dart b/lib/features/erd/erd_view.dart index d4200e84..d44909d1 100644 --- a/lib/features/erd/erd_view.dart +++ b/lib/features/erd/erd_view.dart @@ -3,6 +3,7 @@ import 'dart:typed_data'; import 'dart:ui' as ui; import 'package:file_selector/file_selector.dart'; +import 'package:flutter/gestures.dart' show DragStartBehavior; import 'package:flutter/material.dart' as material; import 'package:flutter/rendering.dart'; import 'package:querya_desktop/core/database/table_mutation_engine.dart'; @@ -11,7 +12,9 @@ import 'package:querya_desktop/features/erd/erd_catalog.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; 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'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; +import 'package:querya_desktop/shared/widgets/querya_action_button.dart'; import 'package:querya_desktop/shared/widgets/querya_spinner.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart'; @@ -55,9 +58,23 @@ class _ErdViewState extends material.State { final _boundaryKey = material.GlobalKey(); ErdSchema? _schema; ErdLayout? _layout; + List _routes = const []; String? _error; bool _loading = true; + /// Table under the mouse, and the one being dragged: their edges are + /// highlighted. + String? _hovered; + String? _dragging; + + final _transform = material.TransformationController(); + + @override + void dispose() { + _transform.dispose(); + super.dispose(); + } + @override void initState() { super.initState(); @@ -74,7 +91,7 @@ class _ErdViewState extends material.State { if (!mounted) return; setState(() { _schema = schema; - _layout = ErdLayout.compute(schema); + _setLayout(ErdLayout.compute(schema)); _loading = false; }); } catch (e) { @@ -86,9 +103,48 @@ class _ErdViewState extends material.State { } } + void _setLayout(ErdLayout layout) { + _layout = layout; + final schema = _schema; + _routes = schema == null ? const [] : ErdRouter.route(schema, layout); + } + + void _autoLayout() { + final schema = _schema; + if (schema == null) return; + setState(() => _setLayout(ErdLayout.compute(schema))); + } + + void _dragStart(String table) => setState(() => _dragging = table); + + /// [screenDelta] is in screen pixels; the canvas may be zoomed. + void _dragMove(String table, material.Offset screenDelta) { + final layout = _layout; + if (_dragging != table || layout == null) return; + final scale = _transform.value.getMaxScaleOnAxis(); + final delta = screenDelta / (scale == 0 ? 1 : scale); + setState(() => + _setLayout(layout.withPosition(table, layout.positions[table]! + delta))); + } + + void _dragEnd() { + if (_dragging == null) return; + setState(() => _dragging = null); + } + Future _save(String name, Uint8List bytes) => (widget.onSaveFile ?? defaultErdFileSaver)(name, bytes); + /// The focused table and the tables it is related to. + bool _isFocused(ErdSchema schema, String table) { + final focus = _dragging ?? _hovered; + if (focus == null) return false; + if (focus == table) return true; + return schema.relations.any((r) => + (r.fromTable == focus && r.toTable == table) || + (r.toTable == focus && r.fromTable == table)); + } + Future _exportPng() async { final boundary = _boundaryKey.currentContext?.findRenderObject() as RenderRepaintBoundary?; @@ -117,6 +173,9 @@ class _ErdViewState extends material.State { } else { body = material.InteractiveViewer( constrained: false, + transformationController: _transform, + // A card drag must not pan the canvas. + panEnabled: _dragging == null, minScale: 0.2, maxScale: 3, boundaryMargin: const material.EdgeInsets.all(400), @@ -131,9 +190,10 @@ class _ErdViewState extends material.State { material.Positioned.fill( child: material.CustomPaint( painter: _RelationPainter( - schema: schema, - layout: layout, + routes: _routes, color: wb.mutedForeground, + highlight: wb.accent, + focus: _dragging ?? _hovered, ), ), ), @@ -143,9 +203,16 @@ class _ErdViewState extends material.State { top: layout.positions[t.name]!.dy, child: _TableCard( table: t, + highlighted: _isFocused(schema, t.name), + dragging: _dragging == t.name, onOpen: widget.onOpenTable == null ? null : () => widget.onOpenTable!(t.name), + onHover: (inside) => setState( + () => _hovered = inside ? t.name : (_hovered == t.name ? null : _hovered)), + onDragStart: () => _dragStart(t.name), + onDragMove: (d) => _dragMove(t.name, d), + onDragEnd: _dragEnd, ), ), ], @@ -160,16 +227,26 @@ class _ErdViewState extends material.State { children: [ material.Padding( padding: const material.EdgeInsets.all(8), - child: material.Row( + child: material.Wrap( + spacing: 8, + runSpacing: 8, children: [ - OutlineButton( + QueryaActionButton( key: const material.ValueKey('erd_refresh'), + label: 'Refresh', + icon: material.Icons.refresh_rounded, onPressed: _loading ? null : _load, - child: const Text('Refresh'), ), - const material.SizedBox(width: 8), - OutlineButton( + QueryaActionButton( + key: const material.ValueKey('erd_auto_layout'), + label: 'Auto layout', + icon: material.Icons.auto_fix_high_rounded, + tooltip: 'Arrange the tables again (undoes manual moves)', + onPressed: ready ? _autoLayout : null, + ), + QueryaActionButton( key: const material.ValueKey('erd_mermaid'), + label: 'Mermaid', onPressed: !ready ? null : () => _save( @@ -177,25 +254,22 @@ class _ErdViewState extends material.State { Uint8List.fromList( utf8.encode(ErdExport.toMermaid(schema))), ), - child: const Text('Mermaid'), ), - const material.SizedBox(width: 8), - OutlineButton( + QueryaActionButton( key: const material.ValueKey('erd_svg'), + label: 'SVG', onPressed: !ready ? null : () => _save( 'diagram.svg', - Uint8List.fromList( - utf8.encode(ErdExport.toSvg(schema, layout))), + Uint8List.fromList(utf8.encode( + ErdExport.toSvg(schema, layout, routes: _routes))), ), - child: const Text('SVG'), ), - const material.SizedBox(width: 8), - OutlineButton( + QueryaActionButton( key: const material.ValueKey('erd_png'), + label: 'PNG', onPressed: ready ? _exportPng : null, - child: const Text('PNG'), ), ], ), @@ -207,78 +281,139 @@ class _ErdViewState extends material.State { } class _TableCard extends material.StatelessWidget { - const _TableCard({required this.table, this.onOpen}); + const _TableCard({ + required this.table, + required this.highlighted, + required this.dragging, + required this.onHover, + required this.onDragStart, + required this.onDragMove, + required this.onDragEnd, + this.onOpen, + }); final ErdTable table; + final bool highlighted; + final bool dragging; final material.VoidCallback? onOpen; + final void Function(bool inside) onHover; + final material.VoidCallback onDragStart; + final void Function(material.Offset delta) onDragMove; + final material.VoidCallback onDragEnd; @override material.Widget build(material.BuildContext context) { final wb = context.workbench; final palette = context.semanticPalette; - return material.GestureDetector( - key: material.ValueKey('erd_table_${table.name}'), - onDoubleTap: onOpen, - child: material.Container( - width: ErdLayout.cardWidth, - height: ErdLayout.cardHeight(table), - decoration: material.BoxDecoration( - color: wb.surface, - border: material.Border.all(color: wb.borderSubtle), - borderRadius: material.BorderRadius.circular(6), - ), - child: material.Column( - crossAxisAlignment: material.CrossAxisAlignment.start, - children: [ - material.Container( - height: ErdLayout.headerHeight, - alignment: material.Alignment.centerLeft, - padding: const material.EdgeInsets.symmetric(horizontal: 10), - child: Text(table.name, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: const material.TextStyle( - fontWeight: material.FontWeight.bold)), + final radius = material.BorderRadius.circular(8); + return material.MouseRegion( + cursor: dragging + ? material.SystemMouseCursors.grabbing + : material.SystemMouseCursors.grab, + onEnter: (_) => onHover(true), + onExit: (_) => onHover(false), + // The card's pan recognizer joins the arena before the canvas's one + // and wins it, so dragging a card never pans the canvas. `down` + // reports the movement from the press, slop included. + child: material.GestureDetector( + key: material.ValueKey('erd_table_${table.name}'), + dragStartBehavior: DragStartBehavior.down, + onDoubleTap: onOpen, + onPanStart: (_) => onDragStart(), + onPanUpdate: (d) => onDragMove(d.delta), + onPanEnd: (_) => onDragEnd(), + onPanCancel: onDragEnd, + child: material.Container( + width: ErdLayout.cardWidth, + height: ErdLayout.cardHeight(table), + decoration: material.BoxDecoration( + color: wb.surface, + borderRadius: radius, + border: material.Border.all( + color: highlighted ? wb.accent : wb.borderSubtle, + width: highlighted ? 1.5 : 1, + ), + boxShadow: [ + material.BoxShadow( + color: const material.Color(0xFF000000) + .withValues(alpha: dragging ? 0.28 : 0.12), + blurRadius: dragging ? 18 : 8, + offset: material.Offset(0, dragging ? 6 : 2), + ), + ], ), - for (final c in table.columns) - material.SizedBox( - height: ErdLayout.rowHeight, - child: material.Padding( - padding: const material.EdgeInsets.symmetric(horizontal: 10), - child: material.Row( - children: [ - material.SizedBox( - width: 26, - child: Text( - c.isPrimaryKey - ? 'PK' - : c.isForeignKey - ? 'FK' - : '', - style: material.TextStyle( - fontSize: 9, - color: c.isPrimaryKey - ? palette.type1 - : palette.type2, - ), + child: material.ClipRRect( + borderRadius: radius, + child: material.Column( + crossAxisAlignment: material.CrossAxisAlignment.start, + children: [ + material.Container( + height: ErdLayout.headerHeight, + color: wb.accent.withValues(alpha: highlighted ? 0.18 : 0.10), + padding: const material.EdgeInsets.symmetric(horizontal: 10), + child: material.Row( + children: [ + material.Icon(material.Icons.table_chart_outlined, + size: 14, color: wb.accent), + const material.SizedBox(width: 6), + material.Expanded( + child: Text(table.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: const material.TextStyle( + fontWeight: material.FontWeight.w600, + fontSize: 13)), ), - ), - material.Expanded( - child: Text(c.name, - maxLines: 1, - overflow: material.TextOverflow.ellipsis, - style: const material.TextStyle(fontSize: 12)), - ), - Text(c.type, - maxLines: 1, - style: material.TextStyle( - fontSize: 10, color: wb.mutedForeground)), - ], + Text('${table.columns.length}', + style: material.TextStyle( + fontSize: 10, color: wb.mutedForeground)), + ], + ), ), - ), + for (final c in table.columns) + material.SizedBox( + height: ErdLayout.rowHeight, + child: material.Padding( + padding: + const material.EdgeInsets.symmetric(horizontal: 10), + child: material.Row( + children: [ + material.SizedBox( + width: 20, + child: c.isPrimaryKey + ? material.Icon(material.Icons.key_rounded, + size: 12, color: palette.type1) + : c.isForeignKey + ? material.Icon(material.Icons.link_rounded, + size: 12, color: palette.type2) + : null, + ), + material.Expanded( + child: Text(c.name, + maxLines: 1, + overflow: material.TextOverflow.ellipsis, + style: material.TextStyle( + fontSize: 12, + fontWeight: c.isPrimaryKey + ? material.FontWeight.w600 + : material.FontWeight.normal, + )), + ), + const material.SizedBox(width: 6), + Text(c.type, + maxLines: 1, + style: material.TextStyle( + fontSize: 10, + fontFamily: 'monospace', + color: wb.mutedForeground)), + ], + ), + ), + ), + ], ), - ], - ), + ), + ), ), ); } @@ -286,42 +421,91 @@ class _TableCard extends material.StatelessWidget { class _RelationPainter extends material.CustomPainter { _RelationPainter({ - required this.schema, - required this.layout, + required this.routes, required this.color, + required this.highlight, + required this.focus, }); - final ErdSchema schema; - final ErdLayout layout; + final List routes; final material.Color color; + final material.Color highlight; + + /// Table whose relations are drawn on top in [highlight]. + final String? focus; + + bool _focused(ErdRoute r) => + focus != null && + (r.relation.fromTable == focus || r.relation.toTable == focus); @override void paint(material.Canvas canvas, material.Size size) { - final paint = material.Paint() - ..color = color - ..style = material.PaintingStyle.stroke - ..strokeWidth = 1.5; - final byName = {for (final t in schema.tables) t.name: t}; - 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 p1 = material.Offset( - fromRight ? fr.right : fr.left, layout.columnY(from, r.fromColumn)); - final p2 = material.Offset( - fromRight ? tr.left : tr.right, layout.columnY(to, r.toColumn)); - final dx = (fromRight ? 1 : -1) * 30.0; - final path = material.Path() - ..moveTo(p1.dx, p1.dy) - ..cubicTo(p1.dx + dx, p1.dy, p2.dx - dx, p2.dy, p2.dx, p2.dy); - canvas.drawPath(path, paint); - canvas.drawCircle(p2, 3, paint..style = material.PaintingStyle.fill); - paint.style = material.PaintingStyle.stroke; + // Others first, focused on top. + for (final pass in [false, true]) { + for (final r in routes) { + if (_focused(r) != pass || r.points.length < 2) continue; + final paint = material.Paint() + ..color = pass ? highlight : color.withValues(alpha: focus == null ? 0.85 : 0.35) + ..style = material.PaintingStyle.stroke + ..strokeWidth = pass ? 2 : 1.4 + ..strokeCap = material.StrokeCap.round + ..strokeJoin = material.StrokeJoin.round; + canvas.drawPath(roundedPath(r.points), paint); + _crowFoot(canvas, r.points[0], r.points[1], paint); + _oneBar(canvas, r.points.last, r.points[r.points.length - 2], paint); + } } } + /// "Many" end at the FK card: three prongs meeting 12 px out. + static void _crowFoot( + material.Canvas c, material.Offset edge, material.Offset next, material.Paint p) { + final d = _dir(edge, next); + final n = material.Offset(-d.dy, d.dx); + final tip = edge + d * 12; + c + ..drawLine(tip, edge + n * 6, p) + ..drawLine(tip, edge - n * 6, p) + ..drawLine(tip, edge, p); + } + + /// "One" end at the referenced card: a bar across the line. + static void _oneBar( + material.Canvas c, material.Offset edge, material.Offset prev, material.Paint p) { + final d = _dir(edge, prev); + final n = material.Offset(-d.dy, d.dx); + final at = edge + d * 8; + c.drawLine(at + n * 6, at - n * 6, p); + } + + static material.Offset _dir(material.Offset from, material.Offset to) { + final v = to - from; + final len = v.distance; + return len == 0 ? const material.Offset(1, 0) : v / len; + } + @override bool shouldRepaint(_RelationPainter old) => - old.schema != schema || old.layout != layout || old.color != color; + old.routes != routes || + old.color != color || + old.highlight != highlight || + old.focus != focus; +} + +/// Polyline with corners rounded by up to 8 px. +material.Path roundedPath(List pts, {double radius = 8}) { + final path = material.Path()..moveTo(pts.first.dx, pts.first.dy); + for (var i = 1; i < pts.length - 1; i++) { + final a = pts[i - 1], b = pts[i], c = pts[i + 1]; + final r = [radius, (b - a).distance / 2, (c - b).distance / 2] + .reduce((x, y) => x < y ? x : y); + final inDir = (b - a) / ((b - a).distance == 0 ? 1 : (b - a).distance); + final outDir = (c - b) / ((c - b).distance == 0 ? 1 : (c - b).distance); + final p1 = b - inDir * r, p2 = b + outDir * r; + path + ..lineTo(p1.dx, p1.dy) + ..quadraticBezierTo(b.dx, b.dy, p2.dx, p2.dy); + } + path.lineTo(pts.last.dx, pts.last.dy); + return path; } diff --git a/test/features/erd/erd_test.dart b/test/features/erd/erd_test.dart index f23187fa..f347094a 100644 --- a/test/features/erd/erd_test.dart +++ b/test/features/erd/erd_test.dart @@ -8,6 +8,7 @@ import 'package:querya_desktop/features/erd/erd_catalog.dart'; import 'package:querya_desktop/features/erd/erd_export.dart'; 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'; import 'package:querya_desktop/features/erd/erd_view.dart'; import 'package:querya_desktop/features/workspace/sql_execution_delegate.dart'; @@ -80,6 +81,184 @@ void main() { }); }); + group('ErdLayout (layered)', () { + // users <- orders <- order_items -> products, users <- sessions, lonely. + ErdSchema shop() => ErdSchema.fromCatalog( + columnRows: [ + ['users', 'id', 'int', '1'], + ['users', 'email', 'text', '0'], + ['orders', 'id', 'int', '1'], + ['orders', 'user_id', 'int', '0'], + ['order_items', 'id', 'int', '1'], + ['order_items', 'order_id', 'int', '0'], + ['order_items', 'product_id', 'int', '0'], + ['products', 'id', 'int', '1'], + ['sessions', 'id', 'int', '1'], + ['sessions', 'user_id', 'int', '0'], + ['lonely', 'id', 'int', '1'], + ], + fkRows: [ + ['orders', 'user_id', 'users', 'id'], + ['order_items', 'order_id', 'orders', 'id'], + ['order_items', 'product_id', 'products', 'id'], + ['sessions', 'user_id', 'users', 'id'], + ], + ); + + test('referenced tables sit left of the tables that reference them', () { + final s = shop(); + final l = ErdLayout.compute(s); + double x(String t) => l.positions[t]!.dx; + expect(x('users'), lessThan(x('orders'))); + expect(x('orders'), lessThan(x('order_items'))); + expect(x('products'), lessThan(x('order_items'))); + expect(x('users'), lessThan(x('sessions'))); + }); + + test('tables without relations go below the diagram', () { + final s = shop(); + final l = ErdLayout.compute(s); + final lonely = l.rectOf(s.tables.firstWhere((t) => t.name == 'lonely')); + for (final t in s.tables.where((t) => t.name != 'lonely')) { + expect(lonely.top, greaterThan(l.rectOf(t).bottom), reason: t.name); + } + }); + + test('cards never overlap, also with a cycle and a self reference', () { + final s = ErdSchema.fromCatalog( + columnRows: [ + for (final t in ['a', 'b', 'c', 'd']) ...[ + [t, 'id', 'int', '1'], + [t, 'ref', 'int', '0'], + ], + ], + fkRows: [ + ['a', 'ref', 'b', 'id'], + ['b', 'ref', 'c', 'id'], + ['c', 'ref', 'a', 'id'], + ['d', 'ref', 'd', 'id'], + ], + ); + final l = ErdLayout.compute(s); + final rects = [for (final t in s.tables) l.rectOf(t)]; + for (var i = 0; i < rects.length; i++) { + for (var j = i + 1; j < rects.length; j++) { + expect(rects[i].overlaps(rects[j]), isFalse); + } + } + }); + + test('withPosition moves one card and grows the canvas', () { + final s = shop(); + final l = ErdLayout.compute(s); + final moved = l.withPosition('users', const material.Offset(2000, 1500)); + expect(moved.positions['users'], const material.Offset(2000, 1500)); + expect(moved.positions['orders'], l.positions['orders']); + expect(moved.size.width, greaterThan(2000 + ErdLayout.cardWidth)); + expect(moved.size.height, greaterThan(1500)); + expect(l.withPosition('users', const material.Offset(-50, -9)).positions['users'], + const material.Offset(8, 8)); + }); + + group('ErdRouter', () { + void expectClean(ErdSchema s, ErdLayout l, List routes) { + final byName = {for (final t in s.tables) t.name: t}; + final cards = [for (final t in s.tables) l.rectOf(t)]; + for (final r in routes) { + final from = byName[r.relation.fromTable]!, to = byName[r.relation.toTable]!; + final pts = r.points; + final fr = l.rectOf(from), tr = l.rectOf(to); + // Ends sit on the card sides at the column rows. + expect(pts.first.dy, l.columnY(from, r.relation.fromColumn)); + expect([fr.left, fr.right], contains(pts.first.dx)); + expect(pts.last.dy, l.columnY(to, r.relation.toColumn)); + expect([tr.left, tr.right], contains(pts.last.dx)); + for (var i = 0; i + 1 < pts.length; i++) { + final a = pts[i], b = pts[i + 1]; + expect(a.dx == b.dx || a.dy == b.dy, isTrue, + reason: 'orthogonal segment $a -> $b'); + for (var k = 1; k < 10; k++) { + final p = material.Offset.lerp(a, b, k / 10)!; + for (final c in cards) { + final inside = p.dx > c.left + 0.5 && + p.dx < c.right - 0.5 && + p.dy > c.top + 0.5 && + p.dy < c.bottom - 0.5; + expect(inside, isFalse, + reason: '${r.relation.fromTable}->${r.relation.toTable} crosses $c'); + } + } + } + } + } + + test('every relation is routed around the cards', () { + final s = shop(); + final l = ErdLayout.compute(s); + final routes = ErdRouter.route(s, l); + expect(routes, hasLength(4)); + expectClean(s, l, routes); + }); + + test('a card placed in the way is avoided', () { + final s = ErdSchema.fromCatalog( + columnRows: [ + ['users', 'id', 'int', '1'], + ['orders', 'user_id', 'int', '0'], + ['blocker', 'id', 'int', '1'], + ['blocker', 'a', 'int', '0'], + ['blocker', 'b', 'int', '0'], + ], + fkRows: [ + ['orders', 'user_id', 'users', 'id'], + ], + ); + final l = ErdLayout.compute(s) + .withPosition('users', const material.Offset(40, 100)) + .withPosition('blocker', const material.Offset(400, 80)) + .withPosition('orders', const material.Offset(760, 100)); + final routes = ErdRouter.route(s, l); + expectClean(s, l, routes); + expect(routes.single.points.length, greaterThan(2), + reason: 'must bend around the blocker'); + }); + + test('parallel segments of different edges do not overlap', () { + final s = shop(); + final routes = ErdRouter.route(s, ErdLayout.compute(s)); + final segs = <(int, material.Offset, material.Offset)>[]; + for (var r = 0; r < routes.length; r++) { + final p = routes[r].points; + for (var i = 1; i + 2 < p.length; i++) { + segs.add((r, p[i], p[i + 1])); + } + } + for (var i = 0; i < segs.length; i++) { + for (var j = i + 1; j < segs.length; j++) { + final (ra, a1, a2) = segs[i]; + final (rb, b1, b2) = segs[j]; + if (ra == rb) continue; + if (a1.dx == a2.dx && b1.dx == b2.dx && a1.dx == b1.dx) { + final lo = [a1.dy, a2.dy].reduce((x, y) => x < y ? x : y); + final hi = [a1.dy, a2.dy].reduce((x, y) => x > y ? x : y); + final lo2 = [b1.dy, b2.dy].reduce((x, y) => x < y ? x : y); + final hi2 = [b1.dy, b2.dy].reduce((x, y) => x > y ? x : y); + expect(lo < hi2 - 0.5 && lo2 < hi - 0.5, isFalse, + reason: 'vertical overlap at x=${a1.dx}'); + } + } + } + }); + + test('no relations means no routes', () { + final s = ErdSchema.fromCatalog(columnRows: [ + ['a', 'id', 'int', '1'], + ], fkRows: const []); + expect(ErdRouter.route(s, ErdLayout.compute(s)), isEmpty); + }); + }); + }); + group('ErdExport', () { test('mermaid contains tables, keys and relation', () { final m = ErdExport.toMermaid(sample()); @@ -160,6 +339,34 @@ void main() { await t.pump(const Duration(seconds: 1)); }); + testWidgets('a card can be dragged and Auto layout puts it back', + (t) async { + await t.binding.setSurfaceSize(const material.Size(1200, 800)); + addTearDown(() => t.binding.setSurfaceSize(null)); + await t.pumpWidget(queryaThemeTestShell( + child: ErdView(delegate: delegate(), dialect: SqlDialect.sqlite), + )); + await t.pump(); + await t.pump(); + final card = find.byKey(const material.ValueKey('erd_table_orders')); + final users = find.byKey(const material.ValueKey('erd_table_users')); + final before = t.getTopLeft(card); + final usersBefore = t.getTopLeft(users); + + await t.drag(card, const material.Offset(120, 60)); + await t.pump(); + final after = t.getTopLeft(card); + expect(after.dx - before.dx, greaterThan(100)); + expect(after.dy - before.dy, greaterThan(40)); + // The other card stayed: the drag moved the card, not the canvas. + expect(t.getTopLeft(users), usersBefore); + + await t.tap(find.byKey(const material.ValueKey('erd_auto_layout'))); + await t.pump(); + expect(t.getTopLeft(card), before); + await t.pump(const Duration(seconds: 1)); + }); + testWidgets('shows empty state', (t) async { await t.pumpWidget(queryaThemeTestShell( child: ErdView(