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
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ All notable changes to Data Hopper EDW (formerly hop-datavault) are documented i

## Unreleased

### Relationship lines prefer side attachments (issue #135)

- Table-to-table relationship lines stay on left/right edges until about 50° of inclination (was ~25–30° for typical wide cards)

### Import tables shows progress (issue #134)

- Catalog **Import database tables**, source-model **Import schema**, and dimensional **Import database tables** run JDBC listing and per-table import under Hop's cancelable progress dialog (wait cursor on Hop Web)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@
public final class ModelGraphConnectionGeometry {
public static final int DEFAULT_NUMBER_OF_SPLINE_SEGMENTS = 20;

/**
* Inclination from horizontal (degrees) at which routing switches from left/right to top/bottom
* anchors. Table cards are wide with horizontal labels, so side routing should persist through
* about 45–55° rather than following the box aspect ratio (~25–30° for a typical 2:1 card).
*/
static final double SIDE_ROUTING_MAX_INCLINATION_DEGREES = 50.0;

private static final double SIDE_ROUTING_MAX_SLOPE =
Math.tan(Math.toRadians(SIDE_ROUTING_MAX_INCLINATION_DEGREES));

private ModelGraphConnectionGeometry() {}

/** Returns the configured number of segments used to approximate each connection spline. */
Expand Down Expand Up @@ -52,15 +62,18 @@ public record ConnectionAnchors(Point from, Point to) {}
private record Normal(int x, int y) {}

/**
* Returns the midpoint on the side of {@code from} that faces {@code to}, using dominant-axis
* routing (horizontal when |dx|*h dominates, otherwise vertical; ties prefer vertical).
* Returns the midpoint on the side of {@code from} that faces {@code to}. Uses the inclination of
* the center-to-center line: left/right when the angle from horizontal is at most {@link
* #SIDE_ROUTING_MAX_INCLINATION_DEGREES}, otherwise top/bottom. Overlapping boxes always use
* vertical routing. Equal inclination prefers sides.
*/
public static Point anchorToward(Bounds from, Bounds to) {
int dx = to.centerX() - from.centerX();
int dy = to.centerY() - from.centerY();

boolean horizontal =
!rectanglesOverlap(from, to) && Math.abs(dx) * from.height() > Math.abs(dy) * from.width();
!rectanglesOverlap(from, to)
&& Math.abs(dx) * SIDE_ROUTING_MAX_SLOPE >= Math.abs((double) dy);
if (horizontal) {
if (dx > 0) {
return rightMid(from);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -62,10 +62,41 @@ void anchorWhenTargetToTheLeft() {
}

@Test
void tieBreakPrefersVertical() {
Bounds diagonal = new Bounds(100, 50, 80, 40);
Point fromAnchor = ModelGraphConnectionGeometry.anchorToward(BOX_WIDE, diagonal);
assertEquals(new Point(100, 40), fromAnchor);
void shallowInclinationUsesSideEvenOnWideCards() {
// ~30° (tan 30° ≈ 0.577). A 2:1 card used to attach top/bottom past ~26°.
Bounds target = boundsOffsetFrom(BOX_WIDE, 200, 115);
Point fromAnchor = ModelGraphConnectionGeometry.anchorToward(BOX_WIDE, target);
assertEquals(new Point(BOX_WIDE.x() + BOX_WIDE.width(), BOX_WIDE.centerY()), fromAnchor);
}

@Test
void fortyFiveDegreeInclinationUsesSide() {
Bounds target = boundsOffsetFrom(BOX_A, 120, 120);
Point fromAnchor = ModelGraphConnectionGeometry.anchorToward(BOX_A, target);
assertEquals(new Point(BOX_A.x() + BOX_A.width(), BOX_A.centerY()), fromAnchor);
}

@Test
void thresholdInclinationPrefersSide() {
int dx = 200;
int dy =
(int)
Math.floor(
dx
* Math.tan(
Math.toRadians(
ModelGraphConnectionGeometry.SIDE_ROUTING_MAX_INCLINATION_DEGREES)));
Bounds target = boundsOffsetFrom(BOX_A, dx, dy);
Point fromAnchor = ModelGraphConnectionGeometry.anchorToward(BOX_A, target);
assertEquals(new Point(BOX_A.x() + BOX_A.width(), BOX_A.centerY()), fromAnchor);
}

@Test
void steepInclinationUsesTopBottom() {
// ~55° (tan 55° ≈ 1.428), past the 50° side-routing threshold.
Bounds target = boundsOffsetFrom(BOX_WIDE, 200, 286);
Point fromAnchor = ModelGraphConnectionGeometry.anchorToward(BOX_WIDE, target);
assertEquals(new Point(BOX_WIDE.centerX(), BOX_WIDE.y() + BOX_WIDE.height()), fromAnchor);
}

@Test
Expand Down Expand Up @@ -154,6 +185,13 @@ void effectiveSegmentCountScalesWithScreenLength() {
<= ModelGraphConnectionGeometry.effectiveSegmentCount(5000, 20));
}

/** Target box whose center is offset from {@code from}'s center by {@code dx},{@code dy}. */
private static Bounds boundsOffsetFrom(Bounds from, int dx, int dy) {
int cx = from.centerX() + dx;
int cy = from.centerY() + dy;
return new Bounds(cx - 40, cy - 20, 80, 40);
}

private static int expectedSegmentCount(
double screenLength, int configuredSegments, double zoomFactor) {
int configured = Math.max(1, configuredSegments);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,20 @@ void sortsSharedSideByOtherNodeToAvoidCrossings() {
assertTrue(hubForUpper.y < hubForLower.y);
}

@Test
void shallowDiagonalPrefersLeftRightSides() {
Bounds from = new Bounds(0, 0, 200, 40);
// ~30° down-right (Δx=220, Δy=127): wide cards used to attach on top/bottom here.
Bounds to = new Bounds(220, 127, 200, 40);
Map<String, EdgeGeometry> layout =
ModelGraphEdgeLayout.layout(List.of(new Edge("one", "from", from, "to", to)));

EdgeGeometry geometry = layout.get("one");
assertNotNull(geometry);
assertEquals(Side.RIGHT, geometry.fromSide());
assertEquals(Side.LEFT, geometry.toSide());
}

@Test
void singleEdgeCentersOnFacingSide() {
Bounds from = new Bounds(0, 0, 100, 80);
Expand Down
Loading