Skip to content
Closed
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
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,25 +19,32 @@

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();
}

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);
Expand All @@ -56,6 +63,7 @@ function renderPlanViz() {
resizeSvg(svg);
postprocessForAdditionalMetrics();
setupDetailedLabelsToggle();
setupZoomAndPan(svg, zoomLayer);
}

/* -------------------- *
Expand Down Expand Up @@ -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) {
Expand All @@ -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. */
Expand Down Expand Up @@ -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") {
Expand Down Expand Up @@ -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);

Expand Down Expand Up @@ -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);
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that d3.zoomIdentity, what is the difference from invoking updateZoomLevelLabel(1) directly instead of fitToViewport?

Is it for simply invoking event to update label somehow?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

You're right, this is overengineered. Pushed 280e7aa9e77 to drop fitToViewport and call updateZoomLevelLabel(1) directly.

setupZoomAndPan is invoked on a freshly created <svg> (both render paths do selectAll("svg").remove() before appending), so:

  1. svg.call(planVizZoom) auto-initializes svgNode.__zoom to d3.zoomIdentity.
  2. The just-appended zoom-layer <g> has no transform attribute, and the SVG's viewBox with the default preserveAspectRatio="xMidYMid meet" already provides the natural fit.

So svg.call(planVizZoom.transform, d3.zoomIdentity) was firing a zoom event whose only observable effect was setting the toolbar label to 100%. Calling updateZoomLevelLabel(1) directly is equivalent and clearer.


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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 (-)">&#x2212;</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 (+)">&#x2b;</button>
</div>
</div>
</div>
</div>
<div id="plan-viz-details-col" class="col-4 d-none">
Expand Down