This is an automated email from the ASF dual-hosted git repository.

hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git


The following commit(s) were added to refs/heads/main by this push:
     new bc502f0363 Minimap and infinite navigation, fixes #6094 (#6533)
bc502f0363 is described below

commit bc502f036307406cb381cb894a8447c49bd326ee
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Tue Feb 10 11:49:05 2026 +0100

    Minimap and infinite navigation, fixes #6094 (#6533)
---
 .../java/org/apache/hop/core/gui/BasePainter.java  | 580 +++++++++------------
 .../org/apache/hop/pipeline/PipelinePainter.java   |  44 ++
 .../org/apache/hop/workflow/WorkflowPainter.java   |  44 ++
 .../core/gui/messages/messages_en_US.properties    |  18 +
 .../main/java/org/apache/hop/ui/core/PropsUi.java  |   9 +
 .../hopgui/file/pipeline/HopGuiPipelineGraph.java  |  19 +-
 .../hopgui/file/workflow/HopGuiWorkflowGraph.java  |  20 +-
 .../configuration/tabs/ConfigGuiOptionsTab.java    |  14 +
 .../perspective/execution/DragViewZoomBase.java    | 112 ++--
 .../org/apache/hop/ui/hopgui/shared/SwtGc.java     |   1 -
 .../core/dialog/messages/messages_en_US.properties |   2 +
 11 files changed, 470 insertions(+), 393 deletions(-)

diff --git a/engine/src/main/java/org/apache/hop/core/gui/BasePainter.java 
b/engine/src/main/java/org/apache/hop/core/gui/BasePainter.java
index ef312afca3..012b3bd1cc 100644
--- a/engine/src/main/java/org/apache/hop/core/gui/BasePainter.java
+++ b/engine/src/main/java/org/apache/hop/core/gui/BasePainter.java
@@ -18,6 +18,8 @@
 package org.apache.hop.core.gui;
 
 import java.util.List;
+import lombok.Getter;
+import lombok.Setter;
 import org.apache.hop.base.BaseHopMeta;
 import org.apache.hop.base.IBaseMeta;
 import org.apache.hop.core.Const;
@@ -25,27 +27,24 @@ import org.apache.hop.core.NotePadMeta;
 import org.apache.hop.core.exception.HopException;
 import org.apache.hop.core.gui.AreaOwner.AreaType;
 import org.apache.hop.core.gui.IGc.EColor;
+import org.apache.hop.core.gui.IGc.EFont;
 import org.apache.hop.core.gui.IGc.EImage;
 import org.apache.hop.core.gui.IGc.ELineStyle;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.i18n.BaseMessages;
 import org.apache.hop.pipeline.transform.stream.StreamIcon;
 
+@Getter
+@Setter
 public abstract class BasePainter<Hop extends BaseHopMeta<?>, Part extends 
IBaseMeta> {
 
+  private static final Class<?> PKG = BasePainter.class;
+
   public final double theta = Math.toRadians(11); // arrowhead sharpness
 
   public static final int MINI_ICON_MARGIN = 5;
-  public static final int MINI_ICON_TRIANGLE_BASE = 20;
-  public static final int MINI_ICON_DISTANCE = 4;
-  public static final int MINI_ICON_SKEW = 0;
-
-  public static final int CONTENT_MENU_INDENT = 4;
-
   public static final int CORNER_RADIUS_5 = 10;
-  public static final int CORNER_RADIUS_4 = 8;
-  public static final int CORNER_RADIUS_3 = 6;
-  public static final int CORNER_RADIUS_2 = 4;
 
   protected boolean drawingBorderAroundName;
 
@@ -81,6 +80,12 @@ public abstract class BasePainter<Hop extends 
BaseHopMeta<?>, Part extends IBase
   protected Rectangle viewPort;
   protected String mouseOverName;
 
+  /**
+   * When true, draw the origin boundary (dashed lines, hatching, and label). 
Should be set from the
+   * UI based on "Enable infinite move" (only show when infinite move is 
enabled).
+   */
+  protected boolean showOriginBoundary;
+
   public BasePainter(
       IGc gc,
       IVariables variables,
@@ -152,7 +157,7 @@ public abstract class BasePainter<Hop extends 
BaseHopMeta<?>, Part extends IBase
     }
     gc.setFont(
         Const.NVL(note.getFontName(), noteFontName),
-        (int) ((double) fontHeight / zoomFactor),
+        (int) (fontHeight / zoomFactor),
         note.isFontBold(),
         note.isFontItalic());
 
@@ -241,7 +246,7 @@ public abstract class BasePainter<Hop extends 
BaseHopMeta<?>, Part extends IBase
     }
     gc.setLineStyle(ELineStyle.DASHDOT);
     gc.setLineWidth(lineWidth);
-    gc.setForeground(EColor.GRAY);
+    gc.setForeground(EColor.BLACK);
     // SWT on Windows doesn't cater for negative rect.width/height so handle 
here.
     Point s = real2screen(rect.x, rect.y);
     if (rect.width < 0) {
@@ -254,135 +259,186 @@ public abstract class BasePainter<Hop extends 
BaseHopMeta<?>, Part extends IBase
     gc.setLineStyle(ELineStyle.SOLID);
   }
 
+  /**
+   * Maximum grid points to draw; avoids severe slowdown on large/zoomed-out 
canvases (e.g.
+   * Windows).
+   */
+  private static final int MAX_GRID_POINTS = 50_000;
+
   protected void drawGrid() {
-    Point bounds = gc.getDeviceBounds();
-    for (int x = 0; x < bounds.x; x += gridSize) {
-      for (int y = 0; y < bounds.y; y += gridSize) {
-        gc.drawPoint((int) (x + (offset.x % gridSize)), (int) (y + (offset.y % 
gridSize)));
-      }
+    if (area == null || area.x <= 0 || area.y <= 0) {
+      return;
+    }
+    // Grid is drawn in the same coordinate system as drawOriginBoundary: the 
origin (0,0) of the
+    // pipeline is at (offset.x, offset.y) here. The hatched "outside 
workable" area is x < offset.x
+    // or y < offset.y. So we only draw grid where x >= offset.x and y >= 
offset.y.
+    float mag = Math.max(0.1f, magnification);
+    int originX = (int) Math.round(offset.x);
+    int originY = (int) Math.round(offset.y);
+    int workableMinX = Math.max(0, originX);
+    int workableMinY = Math.max(0, originY);
+    // Visible extent in this coordinate system is (0,0) to (area.x/mag, 
area.y/mag); workable part
+    // is from (originX, originY) to that right/bottom edge.
+    int workableMaxX = (int) Math.ceil(area.x / mag);
+    int workableMaxY = (int) Math.ceil(area.y / mag);
+    if (workableMaxX <= workableMinX || workableMaxY <= workableMinY) {
+      return;
     }
-  }
-
-  protected int round(double value) {
-    return (int) Math.round(value);
-  }
-
-  protected int calcArrowLength() {
-    return 19 + (lineWidth - 1) * 5; // arrowhead length
-  }
 
-  /**
-   * @return the magnification
-   */
-  public float getMagnification() {
-    return magnification;
+    int baseStep = (mag < 1.0f) ? Math.max(gridSize, (int) (gridSize / mag)) : 
gridSize;
+    int rangeX = workableMaxX - workableMinX;
+    int rangeY = workableMaxY - workableMinY;
+    long totalPoints = (long) (rangeX / baseStep + 1) * (rangeY / baseStep + 
1);
+    int step = baseStep;
+    if (totalPoints > MAX_GRID_POINTS) {
+      int minStep =
+          Math.max(
+              gridSize,
+              (int) Math.ceil(Math.sqrt((double) rangeX * rangeY / (double) 
MAX_GRID_POINTS)));
+      step = Math.max(baseStep, minStep);
+    }
+    int offsetX = (int) (offset.x % step);
+    int offsetY = (int) (offset.y % step);
+    if (offsetX < 0) {
+      offsetX += step;
+    }
+    if (offsetY < 0) {
+      offsetY += step;
+    }
+    // First grid position at or after workable visible origin (never in 
hatched area)
+    int startX =
+        Math.max(
+            workableMinX,
+            offsetX + step * (int) Math.ceil((double) (workableMinX - offsetX) 
/ step));
+    int startY =
+        Math.max(
+            workableMinY,
+            offsetY + step * (int) Math.ceil((double) (workableMinY - offsetY) 
/ step));
+    for (int x = startX; x < workableMaxX; x += step) {
+      for (int y = startY; y < workableMaxY; y += step) {
+        if (x >= 0 && y >= 0) {
+          gc.drawPoint(x, y);
+        }
+      }
+    }
   }
 
   /**
-   * @param magnification the magnification to set
+   * Draws a subtle boundary at the origin (0,0) and hatches the negative 
coordinate space to
+   * indicate that transforms/actions cannot be created there. Only drawn when 
showOriginBoundary is
+   * true (e.g. when "Enable infinite move" is on). Same coordinate system as 
drawGrid (real = graph
+   * + offset).
    */
-  public void setMagnification(float magnification) {
-    this.magnification = magnification;
-  }
-
-  public Point getArea() {
-    return area;
-  }
-
-  public void setArea(Point area) {
-    this.area = area;
-  }
-
-  public List<AreaOwner> getAreaOwners() {
-    return areaOwners;
-  }
-
-  public void setAreaOwners(List<AreaOwner> areaOwners) {
-    this.areaOwners = areaOwners;
-  }
-
-  public DPoint getOffset() {
-    return offset;
-  }
-
-  public void setOffset(DPoint offset) {
-    this.offset = offset;
-  }
-
-  public int getIconSize() {
-    return iconSize;
-  }
-
-  public void setIconSize(int iconSize) {
-    this.iconSize = iconSize;
-  }
-
-  public int getGridSize() {
-    return gridSize;
-  }
-
-  public void setGridSize(int gridSize) {
-    this.gridSize = gridSize;
-  }
-
-  public Rectangle getSelectionRectangle() {
-    return selectionRectangle;
-  }
-
-  public void setSelectionRectangle(Rectangle selectionRectangle) {
-    this.selectionRectangle = selectionRectangle;
-  }
-
-  public int getLineWidth() {
-    return lineWidth;
-  }
-
-  public void setLineWidth(int lineWidth) {
-    this.lineWidth = lineWidth;
-  }
-
-  public Object getSubject() {
-    return subject;
-  }
+  protected void drawOriginBoundary() {
+    if (!showOriginBoundary) {
+      return;
+    }
+    double visW = area.x / Math.max(0.01, magnification);
+    double visH = area.y / Math.max(0.01, magnification);
+    int ox = (int) Math.round(offset.x);
+    int oy = (int) Math.round(offset.y);
 
-  public void setSubject(Object subject) {
-    this.subject = subject;
-  }
+    int alpha = gc.getAlpha();
+    gc.setAlpha(160);
+
+    // Hatch the negative region as an L-shape so the corner is not 
double-hatched:
+    // 1) Full strip left of origin: [0, ox] x [0, visH]
+    // 2) Top strip above origin only to the right of the left strip: [ox, 
visW] x [0, oy]
+    if (ox > 0 && oy > 0) {
+      drawHatching(0, 0, ox, (int) visH); // left strip (full height)
+      int topW = (int) visW - ox;
+      if (topW > 0 && oy > 0) {
+        drawHatching(ox, 0, topW, oy); // top strip (no overlap)
+      }
+    } else if (ox > 0 && ox < visW) {
+      drawHatching(0, 0, ox, (int) visH);
+    } else if (oy > 0 && oy < visH) {
+      drawHatching(0, 0, (int) visW, oy);
+    }
 
-  public IGc getGc() {
-    return gc;
-  }
+    gc.setAlpha(alpha);
 
-  public void setGc(IGc gc) {
-    this.gc = gc;
-  }
+    // Dashed border on top of the hatching: only the positive side of the 
axes (actionable area),
+    // so the line does not extend into the negative/hatched region
+    gc.setForeground(EColor.BLACK);
+    gc.setLineWidth(1);
+    gc.setLineStyle(ELineStyle.DASH);
 
-  public String getNoteFontName() {
-    return noteFontName;
-  }
+    if (ox >= 0 && ox <= visW && oy <= visH) {
+      gc.drawLine(ox, oy, ox, (int) visH); // vertical segment from origin 
downward
+    }
+    if (oy >= 0 && oy <= visH && ox <= visW) {
+      gc.drawLine(ox, oy, (int) visW, oy); // horizontal segment from origin 
rightward
+    }
 
-  public void setNoteFontName(String noteFontName) {
-    this.noteFontName = noteFontName;
-  }
+    gc.setLineStyle(ELineStyle.SOLID);
 
-  public int getNoteFontHeight() {
-    return noteFontHeight;
+    // "Outside workable area" label on the x-axis and y-axis (both 
normal/horizontal)
+    String originBoundaryLabel = BaseMessages.getString(PKG, 
"BasePainter.OutsideWorkableArea");
+    if (originBoundaryLabel != null && !originBoundaryLabel.isEmpty()) {
+      gc.setForeground(EColor.DARKGRAY);
+      gc.setFont(EFont.SMALL);
+      Point textSize = gc.textExtent(originBoundaryLabel);
+      int tw = textSize.x;
+      int th = textSize.y;
+      if (ox > tw && ox < visW && (int) visH > th) {
+        int tx = Math.max(4, (ox - tw) / 2);
+        int ty = ((int) visH - th) / 2;
+        gc.drawText(originBoundaryLabel, tx, ty);
+      }
+      if (oy > th && oy < visH && (int) visW - ox > tw) {
+        int topW = (int) visW - ox;
+        int tx = ox + (topW - tw) / 2;
+        int ty = Math.max(4, (oy - th) / 2);
+        gc.drawText(originBoundaryLabel, tx, ty);
+      }
+    }
   }
 
-  public void setNoteFontHeight(int noteFontHeight) {
-    this.noteFontHeight = noteFontHeight;
-  }
+  /**
+   * Base spacing between hatching lines at 100% zoom; scaled by zoom so we 
draw fewer when zoomed
+   * out.
+   */
+  private static final int HATCH_STEP_BASE = 24;
 
-  public double getTheta() {
-    return theta;
+  /**
+   * Draws subtle diagonal hatching in the given rectangle. Uses a global 
diagonal grid (lines x+y =
+   * base + n*step) so that when multiple regions are hatched (e.g. left and 
top of origin), the
+   * lines align seamlessly across the boundary.
+   */
+  private void drawHatching(int x, int y, int w, int h) {
+    if (w <= 0 || h <= 0) {
+      return;
+    }
+    gc.setForeground(EColor.DARKGRAY);
+    gc.setLineWidth(1);
+    gc.setLineStyle(ELineStyle.SOLID);
+    float mag = Math.max(0.1f, magnification);
+    int step = Math.max(HATCH_STEP_BASE, (int) (HATCH_STEP_BASE / mag));
+    // Global base so all hatched regions share the same grid (aligns at 
boundaries).
+    int base = (int) (((offset.x + offset.y) % step + step) % step);
+    // Diagonals are lines where x+y = K; they intersect the rect when K is in 
[x+y, x+w+y+h].
+    int kMin = x + y;
+    int kMax = x + w + y + h;
+    int nStart = (int) Math.ceil((double) (kMin - base) / step);
+    int nEnd = (int) Math.floor((double) (kMax - base) / step);
+    for (int n = nStart; n <= nEnd; n++) {
+      int k = base + n * step;
+      // Clip line x+y=k to rect [x,x+w] x [y,y+h]: x in [max(x, k-(y+h)), 
min(x+w, k-y)].
+      int x1 = Math.max(x, k - (y + h));
+      int x2 = Math.min(x + w, k - y);
+      if (x1 <= x2) {
+        gc.drawLine(x1, k - x1, x2, k - x2);
+      }
+    }
   }
 
-  public Hop getCandidate() {
-    return candidate;
+  protected int round(double value) {
+    return (int) Math.round(value);
   }
 
-  public void setCandidate(Hop candidate) {
-    this.candidate = candidate;
+  protected int calcArrowLength() {
+    return 19 + (lineWidth - 1) * 5; // arrowhead length
   }
 
   protected int[] getLine(Part fs, Part ts) {
@@ -435,252 +491,108 @@ public abstract class BasePainter<Hop extends 
BaseHopMeta<?>, Part extends IBase
       Object endObject)
       throws HopException;
 
+  /** Fixed width of the navigation viewport (minimap) in pixels. */
+  private static final int VIEWPORT_WIDTH = 200;
+
+  /** Maximum height of the minimap; height follows content aspect ratio up to 
this cap. */
+  private static final int VIEWPORT_HEIGHT_MAX = 200;
+
   /**
    * Draw a small rectangle at the bottom right of the screen which depicts 
the viewport as a part
-   * of the total size of the metadata graph. If the graph fits completely on 
the screen, the
-   * navigation view is not shown.
+   * of the total size of the metadata graph. Width is fixed; height follows 
content aspect ratio
+   * (capped) so the minimap represents the graph without spurious empty 
space. Content is top-left
+   * aligned so "at the top" of the canvas matches the top of the minimap.
    */
   protected void drawNavigationView() {
     if (!showingNavigationView || maximum == null) {
-      // Disabled or no maximum size available
       return;
     }
+    double contentMaxX = Math.max(1, maximum.x);
+    double contentMaxY = Math.max(1, maximum.y);
 
-    // Compensate the screen size for the current magnification
+    // 1) Visible rectangle in graph coordinates (unclamped so we can show 
panned-outside view).
     //
-    int areaWidth = (int) (area.x / magnification);
-    int areaHeight = (int) (area.y / magnification);
-
-    // We want to show a rectangle depicting the total area of the 
pipeline/workflow graph.
-    // This area must be adjusted to a maximum of 200 if it is too large.
+    double mag = Math.max(0.01, magnification);
+    double visibleLeft = -offset.x;
+    double visibleTop = -offset.y;
+    double visibleWidthGraph = area.x / mag;
+    double visibleHeightGraph = area.y / mag;
+    double visibleRightGraph = visibleLeft + visibleWidthGraph;
+    double visibleBottomGraph = visibleTop + visibleHeightGraph;
+
+    // 2) Minimap bounds = union of content and visible view so the overlay 
always fits inside.
     //
-    double graphWidth = maximum.x;
-    double graphHeight = maximum.y;
-    if (graphWidth > 200 || graphHeight > 200) {
-      double coefficient = 200 / Math.max(graphWidth, graphHeight);
-      graphWidth *= coefficient;
-      graphHeight *= coefficient;
-    }
-
-    // Position it in the bottom right corner of the screen
+    double minGraphX = Math.min(0, visibleLeft);
+    double minGraphY = Math.min(0, visibleTop);
+    double maxGraphX = Math.max(contentMaxX, visibleRightGraph);
+    double maxGraphY = Math.max(contentMaxY, visibleBottomGraph);
+    double graphRangeX = Math.max(1, maxGraphX - minGraphX);
+    double graphRangeY = Math.max(1, maxGraphY - minGraphY);
+
+    // 3) Fixed width; height follows content aspect ratio (capped) so the 
minimap fits the graph.
     //
-    double graphX = area.x - graphWidth - 10.0;
-    double graphY = area.y - graphHeight - 10.0;
+    int viewportHeight =
+        Math.min(
+            VIEWPORT_HEIGHT_MAX,
+            Math.max(20, (int) Math.round(VIEWPORT_WIDTH * graphRangeY / 
graphRangeX)));
+    double scale = Math.min(VIEWPORT_WIDTH / graphRangeX, viewportHeight / 
graphRangeY);
+    double contentWidth = graphRangeX * scale;
+    double contentHeight = graphRangeY * scale;
+    double contentLeft = area.x - VIEWPORT_WIDTH - 10.0;
+    double contentTop = area.y - viewportHeight - 10.0;
+    // Top-left align so "at the top" of the canvas matches the top of the 
minimap
 
     int alpha = gc.getAlpha();
     gc.setAlpha(75);
 
     gc.setForeground(EColor.DARKGRAY);
     gc.setBackground(EColor.LIGHTBLUE);
-    gc.drawRectangle((int) graphX, (int) graphY, (int) graphWidth, (int) 
graphHeight);
-    gc.fillRectangle((int) graphX, (int) graphY, (int) graphWidth, (int) 
graphHeight);
-
-    // Now draw a darker area inside showing the size of the view-screen in 
relation to the graph
-    // surface. The view size is a fraction of the total graph area outlined 
above.
-    //
-    double viewWidth = (graphWidth * areaWidth) / Math.max(areaWidth, 
maximum.x);
-    double viewHeight = (graphHeight * areaHeight) / Math.max(areaHeight, 
maximum.y);
-
-    // The offset is a part of the screen size.  The maximum offset is the 
graph size minus the area
-    // size.
-    // The offset horizontally is [0, -maximum.x+areaWidth]
-    // The idea is that if the right side of the pipeline or workflow is shown 
you don't need to
-    // scroll further.
-    // The offset fractions calculated below are in the range 0-1 (there about)
-    //
-    double offsetXFraction = (double) (-offset.x) / ((double) maximum.x);
-    double offsetYFraction = (double) (-offset.y) / ((double) maximum.y);
+    gc.drawRectangle((int) contentLeft, (int) contentTop, VIEWPORT_WIDTH, 
viewportHeight);
+    gc.fillRectangle((int) contentLeft, (int) contentTop, VIEWPORT_WIDTH, 
viewportHeight);
 
-    // We shift the view rectangle to the right or down based on the offset 
fraction and the wiggle
-    // room of the inner rectangle.
+    // 3) Origin for graph (0,0) in minimap pixels; content and overlay use 
same scale.
     //
-    double viewX = graphX + (graphWidth) * offsetXFraction;
-    double viewY = graphY + (graphHeight) * offsetYFraction;
+    double graphOriginX = contentLeft - minGraphX * scale;
+    double graphOriginY = contentTop - minGraphY * scale;
+    drawNavigationViewContent(graphOriginX, graphOriginY, scale, scale);
+
+    double viewX = graphOriginX + visibleLeft * scale;
+    double viewY = graphOriginY + visibleTop * scale;
+    double viewWidth = visibleWidthGraph * scale;
+    double viewHeight = visibleHeightGraph * scale;
+
+    // Clamp overlay to the drawn content area (avoid drawing outside the 
light blue)
+    viewX = Math.max(contentLeft, Math.min(contentLeft + contentWidth - 1, 
viewX));
+    viewY = Math.max(contentTop, Math.min(contentTop + contentHeight - 1, 
viewY));
+    viewWidth = Math.min(viewWidth, contentLeft + contentWidth - viewX);
+    viewHeight = Math.min(viewHeight, contentTop + contentHeight - viewY);
+    viewWidth = Math.max(0, viewWidth);
+    viewHeight = Math.max(0, viewHeight);
 
     gc.setForeground(EColor.BLACK);
     gc.setBackground(EColor.BLUE);
     gc.drawRectangle((int) viewX, (int) viewY, (int) viewWidth, (int) 
viewHeight);
     gc.fillRectangle((int) viewX, (int) viewY, (int) viewWidth, (int) 
viewHeight);
 
-    // We remember the rectangles so that we can navigate in it when the user 
drags it around.
-    //
-    graphPort = new Rectangle((int) graphX, (int) graphY, (int) graphWidth, 
(int) graphHeight);
+    graphPort = new Rectangle((int) contentLeft, (int) contentTop, 
VIEWPORT_WIDTH, viewportHeight);
     viewPort = new Rectangle((int) viewX, (int) viewY, (int) viewWidth, (int) 
viewHeight);
 
     gc.setAlpha(alpha);
   }
 
   /**
-   * Gets zoomFactor
-   *
-   * @return value of zoomFactor
-   */
-  public double getZoomFactor() {
-    return zoomFactor;
-  }
-
-  /**
-   * @param zoomFactor The zoomFactor to set
-   */
-  public void setZoomFactor(double zoomFactor) {
-    this.zoomFactor = zoomFactor;
-  }
-
-  /**
-   * Gets drawingEditIcons
-   *
-   * @return value of drawingBorderAroundName
-   */
-  public boolean isDrawingBorderAroundName() {
-    return drawingBorderAroundName;
-  }
-
-  /**
-   * @param drawingBorderAroundName The option to set
-   */
-  public void setDrawingBorderAroundName(boolean drawingBorderAroundName) {
-    this.drawingBorderAroundName = drawingBorderAroundName;
-  }
-
-  /**
-   * Gets miniIconSize
-   *
-   * @return value of miniIconSize
-   */
-  public int getMiniIconSize() {
-    return miniIconSize;
-  }
-
-  /**
-   * @param miniIconSize The miniIconSize to set
-   */
-  public void setMiniIconSize(int miniIconSize) {
-    this.miniIconSize = miniIconSize;
-  }
-
-  /**
-   * Gets showingNavigationView
-   *
-   * @return value of showingNavigationView
-   */
-  public boolean isShowingNavigationView() {
-    return showingNavigationView;
-  }
-
-  /**
-   * Sets showingNavigationView
-   *
-   * @param showingNavigationView value of showingNavigationView
-   */
-  public void setShowingNavigationView(boolean showingNavigationView) {
-    this.showingNavigationView = showingNavigationView;
-  }
-
-  /**
-   * Gets maximum
-   *
-   * @return value of maximum
-   */
-  public Point getMaximum() {
-    return maximum;
-  }
-
-  /**
-   * Sets maximum
+   * Override to draw rectangles and lines inside the navigation viewport 
representing the graph
+   * elements (e.g. transforms/actions and hops). Coordinates are in graph 
space; convert to
+   * viewport pixels with: screenX = graphX + graphCoordX * scaleX, screenY = 
graphY + graphCoordY *
+   * scaleY.
    *
-   * @param maximum value of maximum
+   * @param graphX left of the viewport rectangle in screen pixels
+   * @param graphY top of the viewport rectangle in screen pixels
+   * @param scaleX scale from graph X to viewport width
+   * @param scaleY scale from graph Y to viewport height
    */
-  public void setMaximum(Point maximum) {
-    this.maximum = maximum;
-  }
-
-  /**
-   * Gets variables
-   *
-   * @return value of variables
-   */
-  public IVariables getVariables() {
-    return variables;
-  }
-
-  /**
-   * Sets variables
-   *
-   * @param variables value of variables
-   */
-  public void setVariables(IVariables variables) {
-    this.variables = variables;
-  }
-
-  /**
-   * Gets screenMagnification
-   *
-   * @return value of screenMagnification
-   */
-  public float getScreenMagnification() {
-    return screenMagnification;
-  }
-
-  /**
-   * Sets screenMagnification
-   *
-   * @param screenMagnification value of screenMagnification
-   */
-  public void setScreenMagnification(float screenMagnification) {
-    this.screenMagnification = screenMagnification;
-  }
-
-  /**
-   * Gets graphPort
-   *
-   * @return value of graphPort
-   */
-  public Rectangle getGraphPort() {
-    return graphPort;
-  }
-
-  /**
-   * Sets graphPort
-   *
-   * @param graphPort value of graphPort
-   */
-  public void setGraphPort(Rectangle graphPort) {
-    this.graphPort = graphPort;
-  }
-
-  /**
-   * Gets viewPort
-   *
-   * @return value of viewPort
-   */
-  public Rectangle getViewPort() {
-    return viewPort;
-  }
-
-  /**
-   * Sets viewPort
-   *
-   * @param viewPort value of viewPort
-   */
-  public void setViewPort(Rectangle viewPort) {
-    this.viewPort = viewPort;
-  }
-
-  /**
-   * Gets mouseOverName
-   *
-   * @return value of mouseOverName
-   */
-  public String getMouseOverName() {
-    return mouseOverName;
-  }
-
-  /**
-   * Sets mouseOverName
-   *
-   * @param mouseOverName value of mouseOverName
-   */
-  public void setMouseOverName(String mouseOverName) {
-    this.mouseOverName = mouseOverName;
+  protected void drawNavigationViewContent(
+      double graphX, double graphY, double scaleX, double scaleY) {
+    // Default: nothing. PipelinePainter and WorkflowPainter draw 
transforms/actions and hops.
   }
 }
diff --git a/engine/src/main/java/org/apache/hop/pipeline/PipelinePainter.java 
b/engine/src/main/java/org/apache/hop/pipeline/PipelinePainter.java
index 80dd852873..1854417411 100644
--- a/engine/src/main/java/org/apache/hop/pipeline/PipelinePainter.java
+++ b/engine/src/main/java/org/apache/hop/pipeline/PipelinePainter.java
@@ -211,10 +211,54 @@ public class PipelinePainter extends 
BasePainter<PipelineHopMeta, TransformMeta>
     gc.dispose();
   }
 
+  @Override
+  protected void drawNavigationViewContent(
+      double graphX, double graphY, double scaleX, double scaleY) {
+    if (pipelineMeta == null || maximum == null) {
+      return;
+    }
+    // Minimum size in viewport pixels so transforms remain visible
+    int minSize = 2;
+    // Draw hops as lines first (behind transforms)
+    gc.setForeground(EColor.DARKGRAY);
+    gc.setLineWidth(1);
+    for (PipelineHopMeta hop : pipelineMeta.getPipelineHops()) {
+      if (hop.getFromTransform() == null || hop.getToTransform() == null) {
+        continue;
+      }
+      Point fromLoc = hop.getFromTransform().getLocation();
+      Point toLoc = hop.getToTransform().getLocation();
+      if (fromLoc == null || toLoc == null) {
+        continue;
+      }
+      int fromCenterX = (int) (graphX + (fromLoc.x + iconSize / 2) * scaleX);
+      int fromCenterY = (int) (graphY + (fromLoc.y + iconSize / 2) * scaleY);
+      int toCenterX = (int) (graphX + (toLoc.x + iconSize / 2) * scaleX);
+      int toCenterY = (int) (graphY + (toLoc.y + iconSize / 2) * scaleY);
+      gc.drawLine(fromCenterX, fromCenterY, toCenterX, toCenterY);
+    }
+    // Draw transforms as small rectangles
+    gc.setForeground(EColor.BLACK);
+    gc.setBackground(EColor.WHITE);
+    for (TransformMeta transform : pipelineMeta.getTransforms()) {
+      Point loc = transform.getLocation();
+      if (loc == null) {
+        continue;
+      }
+      int w = Math.max(minSize, (int) Math.ceil(iconSize * scaleX));
+      int h = Math.max(minSize, (int) Math.ceil(iconSize * scaleY));
+      int x = (int) (graphX + loc.x * scaleX);
+      int y = (int) (graphY + loc.y * scaleY);
+      gc.fillRectangle(x, y, w, h);
+      gc.drawRectangle(x, y, w, h);
+    }
+  }
+
   private void drawPipeline() throws HopException {
     if (gridSize > 1) {
       drawGrid();
     }
+    drawOriginBoundary();
 
     try {
       ExtensionPointHandler.callExtensionPoint(
diff --git a/engine/src/main/java/org/apache/hop/workflow/WorkflowPainter.java 
b/engine/src/main/java/org/apache/hop/workflow/WorkflowPainter.java
index 3cfce6792d..bbc04c8aea 100644
--- a/engine/src/main/java/org/apache/hop/workflow/WorkflowPainter.java
+++ b/engine/src/main/java/org/apache/hop/workflow/WorkflowPainter.java
@@ -111,10 +111,54 @@ public class WorkflowPainter extends 
BasePainter<WorkflowHopMeta, ActionMeta> {
     gc.dispose();
   }
 
+  @Override
+  protected void drawNavigationViewContent(
+      double graphX, double graphY, double scaleX, double scaleY) {
+    if (workflowMeta == null || maximum == null) {
+      return;
+    }
+    // Minimum size in viewport pixels so actions remain visible
+    int minSize = 2;
+    // Draw hops as lines first (behind actions)
+    gc.setForeground(EColor.DARKGRAY);
+    gc.setLineWidth(1);
+    for (WorkflowHopMeta hop : workflowMeta.getWorkflowHops()) {
+      if (hop.getFromAction() == null || hop.getToAction() == null) {
+        continue;
+      }
+      Point fromLoc = hop.getFromAction().getLocation();
+      Point toLoc = hop.getToAction().getLocation();
+      if (fromLoc == null || toLoc == null) {
+        continue;
+      }
+      int fromCenterX = (int) (graphX + (fromLoc.x + iconSize / 2) * scaleX);
+      int fromCenterY = (int) (graphY + (fromLoc.y + iconSize / 2) * scaleY);
+      int toCenterX = (int) (graphX + (toLoc.x + iconSize / 2) * scaleX);
+      int toCenterY = (int) (graphY + (toLoc.y + iconSize / 2) * scaleY);
+      gc.drawLine(fromCenterX, fromCenterY, toCenterX, toCenterY);
+    }
+    // Draw actions as small rectangles
+    gc.setForeground(EColor.BLACK);
+    gc.setBackground(EColor.WHITE);
+    for (ActionMeta action : workflowMeta.getActions()) {
+      Point loc = action.getLocation();
+      if (loc == null) {
+        continue;
+      }
+      int w = Math.max(minSize, (int) Math.ceil(iconSize * scaleX));
+      int h = Math.max(minSize, (int) Math.ceil(iconSize * scaleY));
+      int x = (int) (graphX + loc.x * scaleX);
+      int y = (int) (graphY + loc.y * scaleY);
+      gc.fillRectangle(x, y, w, h);
+      gc.drawRectangle(x, y, w, h);
+    }
+  }
+
   private void drawActions() throws HopException {
     if (gridSize > 1) {
       drawGrid();
     }
+    drawOriginBoundary();
 
     try {
       ExtensionPointHandler.callExtensionPoint(
diff --git 
a/engine/src/main/resources/org/apache/hop/core/gui/messages/messages_en_US.properties
 
b/engine/src/main/resources/org/apache/hop/core/gui/messages/messages_en_US.properties
new file mode 100644
index 0000000000..591601afe9
--- /dev/null
+++ 
b/engine/src/main/resources/org/apache/hop/core/gui/messages/messages_en_US.properties
@@ -0,0 +1,18 @@
+#
+# Licensed to the Apache Software Foundation (ASF) under one or more
+# contributor license agreements.  See the NOTICE file distributed with
+# this work for additional information regarding copyright ownership.
+# The ASF licenses this file to You under the Apache License, Version 2.0
+# (the "License"); you may not use this file except in compliance with
+# the License.  You may obtain a copy of the License at
+#
+#       http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+#
+
+BasePainter.OutsideWorkableArea=Outside workable area
diff --git a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java 
b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
index ffb34f25d7..0a352e0726 100644
--- a/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
+++ b/ui/src/main/java/org/apache/hop/ui/core/PropsUi.java
@@ -83,6 +83,7 @@ public class PropsUi extends Props {
   private static final String GRAPH_EXTRA_VIEW_VERTICAL_ORIENTATION =
       "GraphExtraViewVerticalOrientation";
   private static final String DISABLE_ZOOM_SCROLLING = "DisableZoomScrolling";
+  private static final String ENABLE_INFINITE_CANVAS_MOVE = 
"EnableInfiniteCanvasMove";
 
   public static final int DEFAULT_MAX_EXECUTION_LOGGING_TEXT_SIZE = 2000000;
   private Map<RGB, RGB> contrastingColors;
@@ -1173,4 +1174,12 @@ public class PropsUi extends Props {
   public void setZoomScrollingDisabled(boolean disabled) {
     setProperty(DISABLE_ZOOM_SCROLLING, disabled ? YES : NO);
   }
+
+  public boolean isInfiniteCanvasMoveEnabled() {
+    return YES.equalsIgnoreCase(getProperty(ENABLE_INFINITE_CANVAS_MOVE, NO));
+  }
+
+  public void setInfiniteCanvasMoveEnabled(boolean enabled) {
+    setProperty(ENABLE_INFINITE_CANVAS_MOVE, enabled ? YES : NO);
+  }
 }
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
index c4554a0185..7819fa8b51 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/pipeline/HopGuiPipelineGraph.java
@@ -1423,9 +1423,13 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       String message = null;
       switch (fSingleClickType) {
         case Pipeline:
-          message =
-              BaseMessages.getString(PKG, 
"PipelineGraph.ContextualActionDialog.Pipeline.Header");
-          contextHandler = new HopGuiPipelineContext(pipelineMeta, this, real);
+          // Do not show context menu in negative coordinate space (transforms 
cannot be created
+          // there)
+          if (real.x >= 0 && real.y >= 0) {
+            message =
+                BaseMessages.getString(PKG, 
"PipelineGraph.ContextualActionDialog.Pipeline.Header");
+            contextHandler = new HopGuiPipelineContext(pipelineMeta, this, 
real);
+          }
           break;
         case Transform:
           message =
@@ -3584,6 +3588,7 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
       pipelinePainter.setShowingNavigationView(true);
       pipelinePainter.setScreenMagnification(magnification);
       
pipelinePainter.setShowingNavigationView(!PropsUi.getInstance().isHideViewportEnabled());
+      
pipelinePainter.setShowOriginBoundary(PropsUi.getInstance().isInfiniteCanvasMoveEnabled());
 
       try {
         pipelinePainter.drawPipelineImage();
@@ -3599,11 +3604,13 @@ public class HopGuiPipelineGraph extends 
HopGuiAbstractGraph
                   BasePropertyHandler.getProperty("PipelineCanvas_image"),
                   getClass().getClassLoader());
           gc.setTransform(0.0f, 0.0f, (float) (magnification * 
PropsUi.getNativeZoomFactor()));
-          gc.drawImage(svgFile, 150, 150, 32, 40, gc.getMagnification(), 0);
+          int iconX = 150 + (int) Math.round(offset.x);
+          int iconY = 150 + (int) Math.round(offset.y);
+          gc.drawImage(svgFile, iconX, iconY, 32, 40, gc.getMagnification(), 
0);
           gc.drawText(
               BaseMessages.getString(PKG, 
"PipelineGraph.NewPipelineBackgroundMessage"),
-              155,
-              125,
+              155 + (int) Math.round(offset.x),
+              125 + (int) Math.round(offset.y),
               true);
         }
 
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
index 5d7b66b7b0..2ec438a9ac 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/file/workflow/HopGuiWorkflowGraph.java
@@ -1197,10 +1197,13 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       String message = null;
       switch (fSingleClickType) {
         case Workflow:
-          message =
-              BaseMessages.getString(
-                  PKG, 
"HopGuiWorkflowGraph.ContextualActionDialog.Workflow.Header");
-          contextHandler = new HopGuiWorkflowContext(workflowMeta, this, real);
+          // Do not show context menu in negative coordinate space (actions 
cannot be created there)
+          if (real.x >= 0 && real.y >= 0) {
+            message =
+                BaseMessages.getString(
+                    PKG, 
"HopGuiWorkflowGraph.ContextualActionDialog.Workflow.Header");
+            contextHandler = new HopGuiWorkflowContext(workflowMeta, this, 
real);
+          }
           break;
         case Action:
           message =
@@ -3142,6 +3145,7 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
       workflowPainter.setShowingNavigationView(true);
       workflowPainter.setScreenMagnification(magnification);
       
workflowPainter.setShowingNavigationView(!PropsUi.getInstance().isHideViewportEnabled());
+      
workflowPainter.setShowOriginBoundary(PropsUi.getInstance().isInfiniteCanvasMoveEnabled());
 
       try {
         workflowPainter.drawWorkflow();
@@ -3160,11 +3164,13 @@ public class HopGuiWorkflowGraph extends 
HopGuiAbstractGraph
                   BasePropertyHandler.getProperty("WorkflowCanvas_image"),
                   getClass().getClassLoader());
           gc.setTransform(0.0f, 0.0f, (float) (magnification * 
PropsUi.getNativeZoomFactor()));
-          gc.drawImage(svgFile, 150, 150, 32, 40, gc.getMagnification(), 0);
+          int iconX = 150 + (int) Math.round(offset.x);
+          int iconY = 150 + (int) Math.round(offset.y);
+          gc.drawImage(svgFile, iconX, iconY, 32, 40, gc.getMagnification(), 
0);
           gc.drawText(
               BaseMessages.getString(PKG, 
"HopGuiWorkflowGraph.NewWorkflowBackgroundMessage"),
-              155,
-              125,
+              155 + (int) Math.round(offset.x),
+              125 + (int) Math.round(offset.y),
               true);
         }
       } catch (Exception e) {
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
index d87104f259..70b587183e 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/configuration/tabs/ConfigGuiOptionsTab.java
@@ -90,6 +90,7 @@ public class ConfigGuiOptionsTab {
   private Button wHideViewport;
   private Button wUseDoubleClick;
   private Button wDrawBorderAroundCanvasNames;
+  private Button wEnableInfiniteMove;
   private Button wDisableZoomScrolling;
   private Button wHideMenuBar;
   private Button wShowTableViewToolbar;
@@ -160,6 +161,7 @@ public class ConfigGuiOptionsTab {
       wHideViewport.setSelection(!props.isHideViewportEnabled()); // Inverted 
logic
       wUseDoubleClick.setSelection(props.useDoubleClick());
       
wDrawBorderAroundCanvasNames.setSelection(props.isBorderDrawnAroundCanvasNames());
+      wEnableInfiniteMove.setSelection(props.isInfiniteCanvasMoveEnabled());
       wHideMenuBar.setSelection(props.isHidingMenuBar());
       wShowTableViewToolbar.setSelection(props.isShowTableViewToolbar());
       wDarkMode.setSelection(props.isDarkMode());
@@ -598,6 +600,17 @@ public class ConfigGuiOptionsTab {
             margin);
     lastCanvasControl = wDrawBorderAroundCanvasNames;
 
+    // Enable infinite move
+    wEnableInfiniteMove =
+        createCheckbox(
+            canvasContent,
+            "EnterOptionsDialog.EnableInfiniteMove.Label",
+            "EnterOptionsDialog.EnableInfiniteMove.ToolTip",
+            props.isInfiniteCanvasMoveEnabled(),
+            lastCanvasControl,
+            margin);
+    lastCanvasControl = wEnableInfiniteMove;
+
     // Disable zoom scrolling
     wDisableZoomScrolling =
         createCheckbox(
@@ -919,6 +932,7 @@ public class ConfigGuiOptionsTab {
         !wHideViewport.getSelection()); // Inverted: checkbox is "show", 
property is "hide"
     props.setUseDoubleClickOnCanvas(wUseDoubleClick.getSelection());
     
props.setDrawBorderAroundCanvasNames(wDrawBorderAroundCanvasNames.getSelection());
+    props.setInfiniteCanvasMoveEnabled(wEnableInfiniteMove.getSelection());
     props.setZoomScrollingDisabled(wDisableZoomScrolling.getSelection());
     props.setDarkMode(wDarkMode.getSelection());
     props.setHidingMenuBar(wHideMenuBar.getSelection());
diff --git 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
index d69d6eee4a..0a65d4898d 100644
--- 
a/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
+++ 
b/ui/src/main/java/org/apache/hop/ui/hopgui/perspective/execution/DragViewZoomBase.java
@@ -49,12 +49,16 @@ public abstract class DragViewZoomBase extends Composite {
   @Override
   public abstract void redraw();
 
+  /**
+   * Convert canvas/screen coordinates to graph coordinates. Must match the 
drawing transform:
+   * canvas = magnification * (graph + offset), so graph = canvas / 
magnification - offset.
+   */
   public Point screen2real(int x, int y) {
     float correctedMagnification = calculateCorrectedMagnification();
     DPoint real =
         new DPoint(
-            ((double) x - offset.x) / correctedMagnification - offset.x,
-            ((double) y - offset.y) / correctedMagnification - offset.y);
+            (double) x / correctedMagnification - offset.x,
+            (double) y / correctedMagnification - offset.y);
     return real.toPoint();
   }
 
@@ -197,8 +201,10 @@ public abstract class DragViewZoomBase extends Composite {
 
     offset.x = offset.x + ((double) mouseEvent.x / area.x) * (viewWidth - 
oldViewWidth);
     offset.y = offset.y + ((double) mouseEvent.y / area.y) * (viewHeight - 
oldViewHeight);
-    offset.x = offset.x > 0 ? 0 : offset.x;
-    offset.y = offset.y > 0 ? 0 : offset.y;
+    if (!PropsUi.getInstance().isInfiniteCanvasMoveEnabled()) {
+      offset.x = offset.x > 0 ? 0 : offset.x;
+      offset.y = offset.y > 0 ? 0 : offset.y;
+    }
 
     validateOffset();
     setZoomLabel();
@@ -281,21 +287,20 @@ public abstract class DragViewZoomBase extends Composite {
         canvas.setData("panStartOffset", new Point((int) offset.x, (int) 
offset.y));
         canvas.setData("panCurrentOffset", new Point((int) offset.x, (int) 
offset.y));
 
-        // Pass boundary information to client for constraint validation
+        // Pass boundary information to client; when infinite move is off, do 
not allow panning past
+        // origin (0,0)
         double zoomFactor = PropsUi.getNativeZoomFactor() * Math.max(0.1, 
magnification);
         Point area = getArea();
         double viewWidth = area.x / zoomFactor;
         double viewHeight = area.y / zoomFactor;
-
-        // Calculate min/max offset boundaries (same as validateOffset())
-        double minX = -maximum.x + viewWidth;
-        double minY = -maximum.y + viewHeight;
-        double maxX = 0;
-        double maxY = 0;
-
+        Point effective = getEffectiveMaximum();
+        double minX = -effective.x + viewWidth;
+        double minY = -effective.y + viewHeight;
+        int maxX = PropsUi.getInstance().isInfiniteCanvasMoveEnabled() ? 
Integer.MAX_VALUE : 0;
+        int maxY = PropsUi.getInstance().isInfiniteCanvasMoveEnabled() ? 
Integer.MAX_VALUE : 0;
         canvas.setData(
             "panBoundaries",
-            new org.apache.hop.core.gui.Rectangle((int) minX, (int) minY, 
(int) maxX, (int) maxY));
+            new org.apache.hop.core.gui.Rectangle((int) minX, (int) minY, 
maxX, maxY));
 
         // Force immediate redraw to sync pan data to client BEFORE mouse move 
events
         // This ensures the client has the pan data when the first MouseMove 
arrives
@@ -324,48 +329,46 @@ public abstract class DragViewZoomBase extends Composite {
   }
 
   protected void dragViewPort(Point clickLocation) {
-    // The delta is calculated
-    //
     double deltaX = clickLocation.x - viewPortStart.x;
     double deltaY = clickLocation.y - viewPortStart.y;
 
-    // What's the wiggle room for the little rectangle in the bigger one?
-    //
-    int wiggleX = viewPort.width;
-    int wiggleY = viewPort.height;
-
-    // What's that in percentages?  We draw the little rectangle at 25% size.
+    // Convert pixel delta (in minimap/canvas space) to graph coordinates 
using the same
+    // scale as the minimap: overlay size in pixels = visible size in graph * 
scale.
     //
-    double deltaXPct = wiggleX == 0 ? 0 : deltaX / (wiggleX / 0.25);
-    double deltaYPct = wiggleY == 0 ? 0 : deltaY / (wiggleY / 0.25);
-
-    // The offset is then a matter of setting a percentage of the graph size
-    //
-    double deltaOffSetX = deltaXPct * maximum.x;
-    double deltaOffSetY = deltaYPct * maximum.y;
+    double mag = Math.max(0.01, magnification);
+    Point area = getArea();
+    double visibleWidthGraph = area.x / mag;
+    double visibleHeightGraph = area.y / mag;
+    if (viewPort.width <= 0 || viewPort.height <= 0) {
+      return;
+    }
+    double scaleX = (double) viewPort.width / visibleWidthGraph;
+    double scaleY = (double) viewPort.height / visibleHeightGraph;
+    double deltaGraphX = deltaX / scaleX;
+    double deltaGraphY = deltaY / scaleY;
 
-    offset = new DPoint(viewDragBaseOffset.x - deltaOffSetX, 
viewDragBaseOffset.y - deltaOffSetY);
+    offset = new DPoint(viewDragBaseOffset.x - deltaGraphX, 
viewDragBaseOffset.y - deltaGraphY);
 
-    // Make sure we don't catapult the view somewhere we can't find the graph 
anymore.
-    //
     validateOffset();
     redraw();
   }
 
   public void validateOffset() {
+    if (maximum == null) {
+      return;
+    }
     double zoomFactor = PropsUi.getNativeZoomFactor() * Math.max(0.1, 
magnification);
 
-    // What's the size of the graph when painted on screen?
-    //
-    double graphWidth = maximum.x;
-    double graphHeight = maximum.y;
-
-    // We need to know the size of the screen.
-    //
     Point area = getArea();
     double viewWidth = area.x / zoomFactor;
     double viewHeight = area.y / zoomFactor;
 
+    // Use effective graph size: when panning right/down we allow the canvas 
to expand
+    // so the user can drag into empty space (same as transforming against the 
side).
+    //
+    double graphWidth = getEffectiveMaximum().x;
+    double graphHeight = getEffectiveMaximum().y;
+
     // Let's not move the graph off the screen to the top/left
     //
     double minX = -graphWidth + viewWidth;
@@ -377,16 +380,35 @@ public abstract class DragViewZoomBase extends Composite {
       offset.y = minY;
     }
 
-    // Are we moving the graph too far down/right?
+    // Do not allow panning past the origin (0,0) unless "infinite move" is 
enabled.
+    // Panning right/down (negative offset) can extend the visible view past 
the content.
     //
-    double maxX = 0;
-    if (offset.x > maxX) {
-      offset.x = maxX;
+    if (!PropsUi.getInstance().isInfiniteCanvasMoveEnabled()) {
+      if (offset.x > 0) {
+        offset.x = 0;
+      }
+      if (offset.y > 0) {
+        offset.y = 0;
+      }
     }
-    double maxY = 0;
-    if (offset.y > maxY) {
-      offset.y = maxY;
+  }
+
+  /**
+   * Returns the effective canvas size used for pan validation and painting. 
When the user pans
+   * right or down, the effective size grows so that empty space can be 
revealed (similar to
+   * resizing a note against the edge).
+   */
+  public Point getEffectiveMaximum() {
+    if (maximum == null) {
+      return new Point(0, 0);
     }
+    double zoomFactor = PropsUi.getNativeZoomFactor() * Math.max(0.1, 
magnification);
+    Point area = getArea();
+    double viewWidth = area.x / zoomFactor;
+    double viewHeight = area.y / zoomFactor;
+    double effectiveX = Math.max(maximum.x, viewWidth - offset.x);
+    double effectiveY = Math.max(maximum.y, viewHeight - offset.y);
+    return new Point((int) Math.ceil(effectiveX), (int) Math.ceil(effectiveY));
   }
 
   protected Point getArea() {
diff --git a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SwtGc.java 
b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SwtGc.java
index fed8bd8545..51b5317bc5 100644
--- a/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SwtGc.java
+++ b/ui/src/main/java/org/apache/hop/ui/hopgui/shared/SwtGc.java
@@ -389,7 +389,6 @@ public class SwtGc implements IGc {
       transform.dispose();
     }
     transform = new Transform(gc.getDevice());
-    transform.translate(translationX, translationY);
     transform.scale(magnification, magnification);
     gc.setTransform(transform);
     currentMagnification = magnification;
diff --git 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
index 68b90b923b..d8cdfb2a91 100644
--- 
a/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
+++ 
b/ui/src/main/resources/org/apache/hop/ui/core/dialog/messages/messages_en_US.properties
@@ -116,6 +116,8 @@ 
EnterOptionsDialog.DrawBorderAroundCanvasNamesOnCanvas.Label=Draw border around
 EnterOptionsDialog.DisableZoomScrolling.Label=Disable zoom scrolling
 EnterOptionsDialog.DisableZoomScrolling.ToolTip=When enabled, the mouse scroll 
wheel will not zoom in/out on the canvas
 EnterOptionsDialog.EnableAutoCollapseCoreObjectTree.Label=Auto collapse 
palette tree
+EnterOptionsDialog.EnableInfiniteMove.Label=Enable infinite move
+EnterOptionsDialog.EnableInfiniteMove.ToolTip=When enabled, you can pan the 
pipeline and workflow canvas in all directions without being limited by the 
origin.
 EnterOptionsDialog.FixedWidthFont.Label=Fixed width font:
 EnterOptionsDialog.General.Label=General
 EnterOptionsDialog.GlobalZoom.Label=UI zoom level:

Reply via email to