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 0659d0c80de0a..dd0c9fc81bd57 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 51fd4ce963116..037877f0dda6e 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 3c8f0c1bec9d1..827e0f92dfc91 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 Show metrics in graph nodes (detailed mode) +
+
+ + + +
+