This is an automated email from the ASF dual-hosted git repository.
yaooqinn pushed a commit to branch branch-4.2
in repository https://gitbox.apache.org/repos/asf/spark.git
The following commit(s) were added to refs/heads/branch-4.2 by this push:
new d0917d6d2f77 [SPARK-56792][SQL] Support pan and zoom for SQL plan
visualization
d0917d6d2f77 is described below
commit d0917d6d2f7777e0c9971c6e7acfc8b645f7b2e5
Author: Kent Yao <[email protected]>
AuthorDate: Sat May 9 12:26:17 2026 +0800
[SPARK-56792][SQL] Support pan and zoom for SQL plan visualization
### What changes were proposed in this pull request?
Add pan and zoom controls to the SQL execution plan visualization rendered
on the SQL tab's execution detail page.
The SVG that hosts the dagre-d3 plan now lives inside a fixed-height
viewport (70vh) and is wrapped in an inner `<g class="zoom-layer">`.
A `d3.zoom()` behavior is attached to the SVG and applies its transform
to the zoom-layer; the viewBox keeps performing the natural fit. A small
floating toolbar in the top-right offers `-` / current % / `+` buttons,
and `+` / `-` / `0` keyboard shortcuts work whenever the cursor is over
the plan area.
Notes:
- Pan/zoom is suppressed when the user interacts with HTML labels
(`<foreignObject>`) so metric tables in detailed mode remain
selectable.
- The SVG download is now insulated from the user's current view: the
cloned SVG resets the zoom-layer transform and uses the viewBox as
its own `width`/`height` so external viewers render at the natural
plan size, matching the previous download behavior.
- Detailed-mode rerender reuses the same setup so zoom/pan keeps working
after toggling "Show metrics in graph nodes".
### Why are the changes needed?
Spark plans frequently grow large enough that the previous fit-the-page
SVG becomes hard to read: clusters wrap, scroll bars chase the cursor,
and zooming the browser distorts the surrounding page. Standard
pan/zoom on the plan itself is the conventional way to navigate large
DAGs and is a building block for further plan-viz work under
SPARK-55760.
### Does this PR introduce _any_ user-facing change?
Yes. The SQL tab's plan visualization has a fixed-height viewport with
new zoom controls (toolbar + wheel + drag-to-pan + keyboard).
### How was this patch tested?
- `build/sbt sql/Test/compile`
- `build/sbt sql/scalastyle sql/Test/scalastyle`
- `dev/lint-js` on the SQL static resources directory
### Was this patch authored or co-authored using generative AI tooling?
Generated-by: GitHub Copilot CLI 1.0.44 with Claude Opus 4.7
Closes #55769 from yaooqinn/SPARK-56792.
Authored-by: Kent Yao <[email protected]>
Signed-off-by: Kent Yao <[email protected]>
(cherry picked from commit ce3144518b60e7e81674e10e8013f04984649c3c)
Signed-off-by: Kent Yao <[email protected]>
---
.../sql/execution/ui/static/spark-sql-viz.css | 35 +++++-
.../spark/sql/execution/ui/static/spark-sql-viz.js | 139 +++++++++++++++++++--
.../spark/sql/execution/ui/ExecutionPage.scala | 12 ++
3 files changed, 174 insertions(+), 12 deletions(-)
diff --git
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
index 0659d0c80de0..dd0c9fc81bd5 100644
---
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
+++
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.css
@@ -138,5 +138,38 @@ svg path.linked {
}
#plan-viz-graph {
- overflow-x: auto;
+ position: relative;
+}
+
+#plan-viz-graph svg {
+ width: 100%;
+ height: 70vh;
+ max-height: 70vh;
+ display: block;
+ cursor: grab;
+ background-color: var(--bs-body-bg);
+ user-select: none;
+}
+
+#plan-viz-graph svg.grabbing {
+ cursor: grabbing;
+}
+
+/* Allow text selection inside HTML labels (detailed mode metrics tables) */
+#plan-viz-graph svg foreignObject,
+#plan-viz-graph svg foreignObject * {
+ user-select: text;
+}
+
+.plan-viz-zoom-toolbar {
+ position: absolute;
+ top: 8px;
+ right: 16px;
+ z-index: 10;
+}
+
+.plan-viz-zoom-toolbar #plan-viz-zoom-level {
+ display: inline-block;
+ min-width: 3.25rem;
+ font-variant-numeric: tabular-nums;
}
diff --git
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
index 51fd4ce96311..037877f0dda6 100644
---
a/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
+++
b/sql/core/src/main/resources/org/apache/spark/sql/execution/ui/static/spark-sql-viz.js
@@ -19,13 +19,19 @@
var PlanVizConstants = {
svgMarginX: 16,
- svgMarginY: 16
+ svgMarginY: 16,
+ zoomMin: 0.1,
+ zoomMax: 16,
+ zoomStep: 1.25
};
// Track selected node for re-rendering the detail panel on checkbox toggle
var selectedNodeId = null;
var cachedNodeDetails = null;
+// d3.zoom behavior for the current SVG; reinitialized on each (re)render.
+var planVizZoom = null;
+
function shouldRenderPlanViz() {
return planVizContainer().selectAll("svg").empty();
}
@@ -33,11 +39,12 @@ function shouldRenderPlanViz() {
function renderPlanViz() {
var svg = planVizContainer()
.append("svg")
- .attr("width", window.innerWidth || 1920)
- .attr("height", 1000);
+ .attr("width", "100%")
+ .attr("height", "70vh");
var metadata = d3.select("#plan-viz-metadata");
var dot = metadata.select(".dot-file").text().trim();
- var graph = svg.append("g");
+ var zoomLayer = svg.append("g").attr("class", "zoom-layer");
+ var graph = zoomLayer.append("g");
var g = graphlibDot.read(dot);
preprocessGraphLayout(g);
@@ -56,6 +63,7 @@ function renderPlanViz() {
resizeSvg(svg);
postprocessForAdditionalMetrics();
setupDetailedLabelsToggle();
+ setupZoomAndPan(svg, zoomLayer);
}
/* -------------------- *
@@ -167,7 +175,9 @@ function preprocessGraphLayout(g) {
}
/*
- * Helper function to size the SVG appropriately such that all elements are
displayed.
+ * Helper function to compute the SVG viewBox so that all elements fit.
+ * The SVG element itself uses CSS sizing (width: 100%, height: 70vh),
+ * so we only set the viewBox here; pan/zoom is applied to the inner
zoom-layer.
* This assumes that all outermost elements are clusters (rectangles).
*/
function resizeSvg(svg) {
@@ -192,9 +202,7 @@ function resizeSvg(svg) {
}));
var width = endX - startX;
var height = endY - startY;
- svg.attr("viewBox", startX + " " + startY + " " + width + " " + height)
- .attr("width", width)
- .attr("height", height);
+ svg.attr("viewBox", startX + " " + startY + " " + width + " " + height);
}
/* Helper function to convert attributes to numeric values. */
@@ -596,6 +604,16 @@
document.getElementById("plan-viz-download-btn").addEventListener("click", async
console.error("Failed to fetch CSS for SVG download", e);
}
d3.select(svg).insert("style", ":first-child").text(css);
+ // Reset any pan/zoom transform on the cloned SVG so the exported file
+ // shows the natural plan, independent of the user's current view state.
+ d3.select(svg).select("g.zoom-layer").attr("transform", null);
+ // Make the standalone SVG self-sized using the viewBox dimensions so
+ // external viewers render at the natural plan size.
+ const viewBox = (svg.getAttribute("viewBox") ||
"").split(/\s+/).map(parseFloat);
+ if (viewBox.length === 4 && viewBox.every((v) => !isNaN(v))) {
+ svg.setAttribute("width", String(viewBox[2]));
+ svg.setAttribute("height", String(viewBox[3]));
+ }
const svgData = new XMLSerializer().serializeToString(svg);
blob = new Blob([svgData], { type: "image/svg+xml" });
} else if (format === "dot") {
@@ -655,9 +673,10 @@ function rerenderWithDetailedLabels() {
var svg = container
.append("svg")
- .attr("width", window.innerWidth || 1920)
- .attr("height", 1000);
- var graph = svg.append("g");
+ .attr("width", "100%")
+ .attr("height", "70vh");
+ var zoomLayer = svg.append("g").attr("class", "zoom-layer");
+ var graph = zoomLayer.append("g");
var g = graphlibDot.read(dot);
@@ -694,12 +713,110 @@ function rerenderWithDetailedLabels() {
setupTooltipForSparkPlanNode(g);
resizeSvg(svg);
postprocessForAdditionalMetrics();
+ setupZoomAndPan(svg, zoomLayer);
+}
+
+/* ---------------------- *
+ * | Zoom and pan | *
+ * ---------------------- */
+
+/*
+ * Wire d3-zoom to the SVG: apply transforms to the inner zoom-layer group,
+ * fit the graph to the viewport on load, and bind toolbar/keyboard controls.
+ */
+function setupZoomAndPan(svg, zoomLayer) {
+ var svgNode = svg.node();
+ if (!svgNode || !zoomLayer || zoomLayer.empty()) return;
+
+ planVizZoom = d3.zoom()
+ .scaleExtent([PlanVizConstants.zoomMin, PlanVizConstants.zoomMax])
+ .filter(function (event) {
+ // Suppress pan/zoom when the user interacts with HTML labels
+ // (foreignObject) so text inside metrics tables remains selectable.
+ // Also ignore right-click to leave the browser context menu intact.
+ if (event.button === 2) return false;
+ var target = event.target;
+ while (target && target !== svgNode) {
+ if (target.nodeName === "foreignObject") return false;
+ target = target.parentNode;
+ }
+ return true;
+ })
+ .on("start", function () { svg.classed("grabbing", true); })
+ .on("zoom", function (event) {
+ zoomLayer.attr("transform", event.transform);
+ updateZoomLevelLabel(event.transform.k);
+ })
+ .on("end", function () { svg.classed("grabbing", false); });
+
+ svg.call(planVizZoom);
+
+ // Initialize the toolbar label; the SVG's viewBox + xMidYMid meet already
+ // provides the natural fit, and the zoom-layer has no transform, so no
+ // explicit transform is required here.
+ updateZoomLevelLabel(1);
+}
+
+function updateZoomLevelLabel(scale) {
+ var el = document.getElementById("plan-viz-zoom-level");
+ if (el) el.textContent = Math.round(scale * 100) + "%";
+}
+
+function planVizZoomBy(factor) {
+ var svg = planVizContainer().select("svg");
+ if (!svg.empty() && planVizZoom) {
+ svg.transition().duration(150).call(planVizZoom.scaleBy, factor);
+ }
+}
+
+function planVizZoomReset() {
+ var svg = planVizContainer().select("svg");
+ if (!svg.empty() && planVizZoom) {
+ svg.transition().duration(150).call(planVizZoom.transform,
d3.zoomIdentity);
+ }
}
+
document.addEventListener("DOMContentLoaded", function () {
if (shouldRenderPlanViz()) {
renderPlanViz();
}
+ var zoomInBtn = document.getElementById("plan-viz-zoom-in");
+ if (zoomInBtn) {
+ zoomInBtn.addEventListener("click", function () {
+ planVizZoomBy(PlanVizConstants.zoomStep);
+ });
+ }
+ var zoomOutBtn = document.getElementById("plan-viz-zoom-out");
+ if (zoomOutBtn) {
+ zoomOutBtn.addEventListener("click", function () {
+ planVizZoomBy(1 / PlanVizConstants.zoomStep);
+ });
+ }
+ var zoomResetBtn = document.getElementById("plan-viz-zoom-reset");
+ if (zoomResetBtn) {
+ zoomResetBtn.addEventListener("click", planVizZoomReset);
+ }
+
+ // Keyboard shortcuts when the SVG is focused or the user hovers over it.
+ document.addEventListener("keydown", function (event) {
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
+ var tag = event.target && event.target.tagName;
+ if (tag === "INPUT" || tag === "TEXTAREA" ||
event.target.isContentEditable) return;
+ var graphEl = document.getElementById("plan-viz-graph");
+ if (!graphEl || !graphEl.matches(":hover")) return;
+ if (event.key === "+" || event.key === "=") {
+ planVizZoomBy(PlanVizConstants.zoomStep);
+ event.preventDefault();
+ } else if (event.key === "-" || event.key === "_") {
+ planVizZoomBy(1 / PlanVizConstants.zoomStep);
+ event.preventDefault();
+ } else if (event.key === "0") {
+ planVizZoomReset();
+ event.preventDefault();
+ }
+ });
+
// Copy physical plan text to clipboard
var copyPlanBtn = document.getElementById("copy-plan-btn");
if (copyPlanBtn) {
diff --git
a/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
b/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
index 3c8f0c1bec9d..827e0f92dfc9 100644
---
a/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
+++
b/sql/core/src/main/scala/org/apache/spark/sql/execution/ui/ExecutionPage.scala
@@ -168,6 +168,18 @@ class ExecutionPage(parent: SQLTab) extends
WebUIPage("execution") with Logging
<input type="checkbox" id="detailed-labels-checkbox"></input>
<span>Show metrics in graph nodes (detailed mode)</span>
</div>
+ <div id="plan-viz-zoom-toolbar" class="plan-viz-zoom-toolbar">
+ <div class="btn-group btn-group-sm" role="group"
aria-label="Zoom controls">
+ <button id="plan-viz-zoom-out" type="button"
+ class="btn btn-light border" title="Zoom out
(-)">−</button>
+ <button id="plan-viz-zoom-reset" type="button"
+ class="btn btn-light border" title="Reset zoom to fit
(0)">
+ <span id="plan-viz-zoom-level">100%</span>
+ </button>
+ <button id="plan-viz-zoom-in" type="button"
+ class="btn btn-light border" title="Zoom in
(+)">+</button>
+ </div>
+ </div>
</div>
</div>
<div id="plan-viz-details-col" class="col-4 d-none">
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]