Line data Source code
1 : import * as React from 'react';
2 : import { ZoomInIcon, ZoomOutIcon } from '../../ui/foundations/symbol_icon.js';
3 : import { color as parseColor, interpolateBrBG, interpolateOrRd, interpolatePuOr, interpolateRdBu, interpolateRdPu, select, zoom, zoomIdentity, ZoomBehavior, ZoomTransform } from 'd3';
4 :
5 : import * as dashql from '@ankoh/dashql-core';
6 : import { JsonView } from '../../ui/json/json_view.js';
7 : import { IconButton, ButtonSize, ButtonVariant } from '../../ui/foundations/button.js';
8 : import { ButtonGroup } from '../../ui/foundations/button_group.js';
9 : import { SegmentedControl, SegmentedControlSize } from '../../ui/foundations/segmented_control.js';
10 : import { AnchoredOverlay } from '../../ui/foundations/anchored_overlay.js';
11 : import { AnchorAlignment, AnchorSide } from '../../ui/foundations/anchored_position.js';
12 : import { OverlaySize } from '../../ui/foundations/overlay.js';
13 : import { SymbolIcon } from '../../ui/foundations/symbol_icon.js';
14 : import { computeProfileFitTransform, ProfileFitBounds } from './profile_fit.js';
15 : import { createProfileLayoutConfig, PROFILE_OPERATOR_ANNOTATION_WIDTH } from './profile_layout_config.js';
16 : import { ProfileExecutionController } from './profile_execution_controller.js';
17 : import { isStatReported, ProfileExecutionState, type ProfileEntityState } from '../../query/profile_execution_state.js';
18 : import { formatCompactBytes, formatCompactCount, formatElapsed, stat, workOf } from '../query_progress/query_progress_model.js';
19 : import { findProfileOperatorInDirection } from './profile_navigation.js';
20 : import type { ProfileNavigationDirection } from './profile_navigation.js';
21 : import { getProfileOperatorSymbol, PROFILE_OPERATOR_SYMBOL_SIZE, shouldRenderProfileOperatorSymbol } from './profile_operator_symbol.js';
22 : import { estimateRelativeDifference, materializeProfileScene, PROFILE_OPERATOR_PORT_INPUT, PROFILE_OPERATOR_PORT_OUTPUT, ProfileScene, ProfileSceneEdge, ProfileSceneOperator } from './profile_scene.js';
23 : import * as styles from './query_profile_view.module.css';
24 : import * as graphStyles from '../../ui/graph/graph.module.css';
25 : import { GraphPort } from '../../ui/graph/graph_port.js';
26 :
27 : /** The default zoom limits of the plan, a fit of a large plan lowers the minimum */
28 1 : const PROFILE_MIN_ZOOM = 0.1;
29 1 : const PROFILE_MAX_ZOOM = 4;
30 1 : const FitIcon = SymbolIcon('focus_center_16');
31 1 : const RotateIcon = SymbolIcon('rotate_16');
32 1 : const PaintbrushIcon = SymbolIcon('paintbrush_16');
33 :
34 : /**
35 : * Measure the drawn plan in scene coordinates. Operators drawn as symbols are narrower than their layout, and the
36 : * fragment contours and badges reach beyond it, so the layout bounds are only the fallback when nothing is drawn.
37 : * The bounding box of the scene group excludes its own zoom transform.
38 : */
39 1 : function measurePlanBounds(group: SVGGElement | null, scene: Pick<ProfileScene, 'width' | 'height'>): ProfileFitBounds {
40 1 : try {
41 1 : const box = group?.getBBox();
42 1 : if (box != null && box.width > 0 && box.height > 0) {
43 1 : return { left: box.x, top: box.y, width: box.width, height: box.height };
44 : }
45 1 : } catch {
46 : // getBBox throws for a group that is not rendered
47 0 : }
48 0 : return { left: 0, top: 0, width: scene.width, height: scene.height };
49 1 : }
50 :
51 1 : const EDGE_WIDTH = 2;
52 : /// The size of the status icon in the annotation slot of an operator
53 1 : const STATUS_ICON_SIZE = 14;
54 : /// The row annotation of an edge is a pill in the annotation slot of its child operator, whose output flows over it
55 1 : const OPERATOR_ANNOTATION_MIN_WIDTH = 20;
56 1 : const OPERATOR_ANNOTATION_HEIGHT = 16;
57 : /// The width of a character of the annotation (9px monospace) and the padding of the pill
58 1 : const OPERATOR_ANNOTATION_CHAR_WIDTH = 5.5;
59 1 : const OPERATOR_ANNOTATION_PADDING = 8;
60 1 : const ESTIMATE_APPROXIMATE_ROW_THRESHOLD = 100;
61 : /// The colors of the badges and the highlighted edge, tokens of the theme.
62 : /// SVG attributes do not resolve var(), the badges set them as style properties.
63 1 : const EDGE_BADGE_BORDER = 'var(--color_border_strong)';
64 1 : const EDGE_HIGHLIGHT_COLOR = 'var(--color_accent_emphasis)';
65 1 : const EDGE_HIGHLIGHT_TEXT_COLOR = 'var(--color_fg_on_emphasis)';
66 : /// What the annotation slot of the operators shows: the status icon, nothing, the actual rows, the estimated rows of
67 : /// the planner, the accuracy of the estimate, the CPU share or the memory of the operator. Every mode shows one kind of
68 : /// value only, an operator without that value (no actual rows yet, no estimate, no statistics) shows nothing. The status
69 : /// only exists with progress. Rows, estimates and accuracy annotate the edge to the parent, CPU and memory the operator.
70 : type ProfileAnnotationMode = 'status' | 'plain' | 'rows' | 'estimates' | 'accuracy' | 'cpu' | 'memory';
71 1 : const PROFILE_ANNOTATION_MODES: readonly { mode: ProfileAnnotationMode; label: string }[] = [
72 1 : { mode: 'status', label: 'Status' },
73 1 : { mode: 'plain', label: 'Plain' },
74 1 : { mode: 'rows', label: 'Rows' },
75 1 : { mode: 'estimates', label: 'Estimates' },
76 1 : { mode: 'accuracy', label: 'Accuracy' },
77 1 : { mode: 'cpu', label: 'CPU' },
78 1 : { mode: 'memory', label: 'Memory' },
79 1 : ];
80 : /// The border of the memory annotation of an operator that spilled to disk
81 1 : const SPILLED_BADGE_BORDER = 'var(--color_attention_fg)';
82 : /// The status icons of an operator in a 16x16 box, the status of the operator picks one (see the controller).
83 : /// The icons look like the status indicators of the app: a dot while pending, a spinner or the progress while
84 : /// running, a check, a cross. The check, the cross and the slash are cut out of their circles, they need the
85 : /// even-odd rule.
86 1 : function StatusIcons(props: { progress: number | null }) {
87 1 : const status = dashql.buffers.profile.ProfileExecutionStatus;
88 1 : return (
89 1 : <>
90 1 : <g data-status-icon={status.UNKNOWN}><circle cx={8} cy={8} r={3.5} /></g>
91 1 : <g data-status-icon={status.PENDING}><circle cx={8} cy={8} r={3.5} /></g>
92 1 : <g data-status-icon={status.RUNNING}>
93 1 : <circle className={styles.status_track} cx={8} cy={8} r={6.5} />
94 1 : <circle cx={8} cy={8} r={3.5} />
95 1 : {props.progress == null
96 1 : ? <circle className={styles.status_spinner} cx={8} cy={8} r={6.5} strokeDasharray="12 88" pathLength={100} />
97 : : (
98 : // The completed share of the work, clockwise from the top
99 1 : <circle
100 1 : className={styles.status_progress}
101 1 : cx={8}
102 1 : cy={8}
103 1 : r={6.5}
104 1 : pathLength={100}
105 1 : strokeDasharray={`${props.progress} 100`}
106 1 : transform="rotate(-90 8 8)"
107 1 : data-testid="operator-progress"
108 1 : />
109 : )}
110 1 : </g>
111 1 : <g data-status-icon={status.SUCCEEDED}>
112 1 : <path fillRule="evenodd" d="M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16Zm3.78-9.72a.75.75 0 0 0-1.06-1.06L6.75 9.19 5.28 7.72a.75.75 0 0 0-1.06 1.06l2 2a.75.75 0 0 0 1.06 0l4.5-4.5Z" />
113 1 : </g>
114 1 : <g data-status-icon={status.FAILED}>
115 1 : <path fillRule="evenodd" d="M2.343 13.657A8 8 0 1 1 13.657 2.343 8 8 0 0 1 2.343 13.657ZM6.03 4.97a.75.75 0 0 0-1.06 1.06L6.94 8 4.97 9.97a.75.75 0 1 0 1.06 1.06L8 9.06l1.97 1.97a.75.75 0 1 0 1.06-1.06L9.06 8l1.97-1.97a.75.75 0 1 0-1.06-1.06L8 6.94 6.03 4.97Z" />
116 1 : </g>
117 1 : <g data-status-icon={status.SKIPPED}>
118 1 : <path fillRule="evenodd" d="M0 8a8 8 0 1 1 16 0A8 8 0 0 1 0 8Zm11.333-2.167a.825.825 0 0 0-1.166-1.166l-5.5 5.5a.825.825 0 0 0 1.166 1.166Z" />
119 1 : </g>
120 1 : </>
121 : );
122 1 : }
123 :
124 : export { createProfileLayoutConfig };
125 :
126 : /// The progress of a fragment on its contour: the completed share of its work while it runs. Null without known work.
127 1 : function fragmentProgress(execution: ProfileExecutionState, fragment: number): { percent: number | null } | null {
128 1 : const state = execution.fragments[fragment];
129 1 : const work = workOf(state);
130 1 : if (state == null || work == null) return null;
131 1 : const running = state.status === dashql.buffers.profile.ProfileExecutionStatus.RUNNING;
132 1 : return { percent: running ? Math.round((work.done / work.total) * 100) : null };
133 1 : }
134 :
135 : /// The metrics of a fragment next to its contour, e.g. `7.53M rows · 242 kB · 4.17 s CPU · 12/37 splits`: the
136 : /// processed rows and bytes and the CPU time as far as reported, and the splits until the fragment succeeded.
137 : /// A metric that is still 0 says nothing yet and is left out, Trino reports no bytes for some connectors at all. The
138 : /// rows of a succeeded fragment stay, it read none. Null without any metric.
139 1 : export function formatFragmentMetrics(state: ProfileEntityState | null | undefined): { text: string; description: string } | null {
140 1 : if (state == null) return null;
141 1 : const field = dashql.buffers.profile.ProfileStatsField;
142 1 : const succeeded = state.status === dashql.buffers.profile.ProfileExecutionStatus.SUCCEEDED;
143 1 : const text: string[] = [];
144 1 : const description: string[] = [];
145 1 : const rows = stat(state, field.TUPLES_IN, s => s.tuplesIn);
146 1 : if (rows != null && (rows > 0 || succeeded)) {
147 1 : text.push(`${formatCompactCount(rows)} rows`);
148 1 : description.push(`${rows} rows`);
149 1 : }
150 1 : const bytes = stat(state, field.BYTES_IN, s => s.bytesIn);
151 1 : if (bytes != null && bytes > 0) {
152 1 : text.push(formatCompactBytes(bytes));
153 1 : description.push(formatCompactBytes(bytes));
154 1 : }
155 1 : const cpu = stat(state, field.CPU_TIME, s => s.cpuTimeUs);
156 1 : if (cpu != null && cpu > 0) {
157 1 : text.push(`${formatElapsed(cpu / 1000)} CPU`);
158 1 : description.push(`${formatElapsed(cpu / 1000)} CPU time`);
159 1 : }
160 1 : const work = workOf(state);
161 1 : if (work != null && !succeeded) {
162 1 : text.push(`${formatCompactCount(work.done)}/${formatCompactCount(work.total)} splits`);
163 1 : description.push(`${work.done} of ${work.total} splits`);
164 1 : }
165 1 : return text.length === 0 ? null : { text: text.join(' · '), description: description.join(', ') };
166 1 : }
167 :
168 : /// The progress of an operator: the completed share of the work of its fragment while it runs, null without
169 : /// completed work. Trino completes the splits of a stage that reads other stages only at its end, the operator
170 : /// spins until then.
171 1 : function operatorProgress(execution: ProfileExecutionState, fragment: number): number | null {
172 1 : const percent = fragmentProgress(execution, fragment)?.percent ?? null;
173 1 : return percent == null || percent === 0 ? null : percent;
174 1 : }
175 :
176 : /// The status of a fragment as attribute value for the contour, undefined without execution state
177 1 : function fragmentStatus(execution: ProfileExecutionState, fragment: number): string | undefined {
178 1 : const status = execution.fragments[fragment]?.status;
179 1 : switch (status) {
180 1 : case dashql.buffers.profile.ProfileExecutionStatus.PENDING: return 'pending';
181 1 : case dashql.buffers.profile.ProfileExecutionStatus.RUNNING: return 'running';
182 1 : case dashql.buffers.profile.ProfileExecutionStatus.SUCCEEDED: return 'succeeded';
183 0 : case dashql.buffers.profile.ProfileExecutionStatus.FAILED: return 'failed';
184 0 : case dashql.buffers.profile.ProfileExecutionStatus.SKIPPED: return 'skipped';
185 1 : default: return undefined;
186 1 : }
187 1 : }
188 :
189 : export interface QueryProfileViewProps {
190 : /// The plan structure
191 : plan: dashql.FlatBufferPtr<dashql.buffers.profile.QueryProfile>;
192 : /// The execution state (the rows of an analyzed plan, the progress of a running query)
193 : execution?: ProfileExecutionState;
194 : showProgress?: boolean;
195 : controllerRef?: React.RefObject<ProfileExecutionController | null>;
196 : autoFocus?: boolean;
197 : /// Fill the height of the container instead of picking a height of its own
198 : fill?: boolean;
199 : edgeRendering?: ProfileEdgeRenderingConfig;
200 : /// Lay out the plan again with the other orientation, the view shows a rotate button with it
201 : onRotate?: () => void;
202 : dependencies?: QueryProfileViewDependencies;
203 : }
204 :
205 : export interface QueryProfileViewDependencies {
206 : materializeProfileScene: typeof materializeProfileScene;
207 : }
208 :
209 1 : const DEFAULT_DEPENDENCIES: QueryProfileViewDependencies = {
210 1 : materializeProfileScene,
211 1 : };
212 :
213 : export type ProfileEdgeColorScheme = (value: number) => string;
214 : export const PROFILE_EDGE_COLOR_SCHEME_PU_OR: ProfileEdgeColorScheme = interpolatePuOr;
215 : export const PROFILE_EDGE_COLOR_SCHEME_BR_BG: ProfileEdgeColorScheme = interpolateBrBG;
216 1 : export const PROFILE_EDGE_COLOR_SCHEME_RD_BU: ProfileEdgeColorScheme = value => interpolateRdBu(1 - value);
217 1 : export const PROFILE_EDGE_COLOR_SCHEME_RD_PU: ProfileEdgeColorScheme = interpolateRdPu;
218 1 : export const PROFILE_COST_COLOR_SCHEME_OR_RD: ProfileEdgeColorScheme = interpolateOrRd;
219 :
220 : export interface ProfileEdgeRenderingConfig {
221 : colorScheme?: ProfileEdgeColorScheme;
222 : rowColorScheme?: ProfileEdgeColorScheme;
223 : /// The scale of the CPU and memory annotations, from none to the most of all operators
224 : costColorScheme?: ProfileEdgeColorScheme;
225 : }
226 :
227 : interface ProfileEdgeBadge {
228 : x: number;
229 : y: number;
230 : width: number;
231 : text: string;
232 : background: string;
233 : foreground: string;
234 : /// The border, the default border if null
235 : border: string | null;
236 : description: string;
237 : }
238 :
239 : /// An annotation in the slot of an operator: the badge of the edge to its parent or of the operator itself
240 : interface ProfileAnnotation {
241 : operator: number;
242 : edge: ProfileSceneEdge | null;
243 : badge: ProfileEdgeBadge;
244 : }
245 :
246 1 : function contrastingTextColor(background: string): string {
247 1 : const rgb = parseColor(background)?.rgb();
248 1 : if (rgb == null) return '#000000';
249 1 : const linear = [rgb.r, rgb.g, rgb.b].map(channel => {
250 1 : const value = channel / 255;
251 1 : return value <= 0.04045 ? value / 12.92 : Math.pow((value + 0.055) / 1.055, 2.4);
252 1 : });
253 1 : const luminance = 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
254 1 : return luminance > 0.179 ? '#000000' : '#ffffff';
255 1 : }
256 :
257 1 : function formatRelativeDifference(difference: number): string {
258 1 : const percentage = Math.round(difference * 100);
259 1 : if (percentage > 200) return '>200%';
260 1 : if (percentage < -200) return '<-200%';
261 1 : return `${percentage > 0 ? '+' : ''}${percentage}%`;
262 1 : }
263 :
264 1 : export function formatEstimateSymbol(difference: number, absoluteDifference = Number.POSITIVE_INFINITY): string {
265 1 : if (difference === 0) return '=';
266 1 : if (absoluteDifference <= ESTIMATE_APPROXIMATE_ROW_THRESHOLD) return '~';
267 1 : if (difference <= -0.5) return '>>';
268 1 : if (difference <= -0.1) return '>';
269 1 : if (difference < 0) return '~';
270 1 : if (difference < 0.1) return '~';
271 1 : if (difference < 0.5) return '<';
272 1 : return '<<';
273 1 : }
274 :
275 : /// The CPU share of an operator in the annotation, e.g. `38%`, `<1%`
276 1 : export function formatCpuShare(share: number): string {
277 1 : if (share === 0) return '0%';
278 1 : if (share < 0.01) return '<1%';
279 1 : return `${Math.round(share * 100)}%`;
280 1 : }
281 :
282 : /// The bytes of an operator in the annotation, at most 4 characters, e.g. `980B`, `12K`, `1.2M`, `340G`
283 1 : export function formatShortBytes(bytes: number): string {
284 1 : if (bytes < 1000) return `${Math.round(bytes)}B`;
285 1 : const units = ['K', 'M', 'G', 'T', 'P'];
286 1 : let value = bytes / 1000;
287 1 : let unit = 0;
288 : // Round first, 999.6K is 1.0M
289 1 : while (Number(value.toPrecision(2)) >= 1000 && unit < units.length - 1) {
290 1 : value /= 1000;
291 1 : unit += 1;
292 1 : }
293 1 : return `${Number(value.toPrecision(2))}${units[unit]}`;
294 1 : }
295 :
296 1 : const ROW_COUNT_FORMAT = new Intl.NumberFormat('en-US');
297 :
298 1 : function formatRowCount(value: number): string {
299 1 : return ROW_COUNT_FORMAT.format(value);
300 1 : }
301 :
302 : /// The left end of the annotation slot of an operator, vertically centered. The slot follows the left padding of the
303 : /// operator, in place of the status icon.
304 1 : export function placeOperatorAnnotation(
305 1 : operator: Pick<ProfileSceneOperator, 'rect'>,
306 1 : input: Pick<dashql.buffers.profile.ProfileLayoutConfigT, 'nodePaddingLeft'>,
307 1 : ): { x: number; y: number } {
308 1 : return { x: operator.rect.x - operator.rect.width / 2 + input.nodePaddingLeft, y: operator.rect.y };
309 1 : }
310 :
311 1 : export function QueryProfileView({ plan, execution = ProfileExecutionState.EMPTY, showProgress = false, controllerRef, autoFocus = false, fill = false, edgeRendering, onRotate, dependencies = DEFAULT_DEPENDENCIES }: QueryProfileViewProps) {
312 1 : const scene = React.useMemo(() => dependencies.materializeProfileScene(plan), [dependencies, plan]);
313 1 : const ownController = React.useRef<ProfileExecutionController | null>(null);
314 1 : ownController.current ??= new ProfileExecutionController();
315 1 : const controller = controllerRef?.current ?? ownController.current;
316 1 : const viewportRef = React.useRef<HTMLDivElement | null>(null);
317 1 : const svgRef = React.useRef<SVGSVGElement | null>(null);
318 1 : const sceneRef = React.useRef<SVGGElement | null>(null);
319 1 : const zoomRef = React.useRef<ZoomBehavior<SVGSVGElement, unknown> | null>(null);
320 1 : const transformRef = React.useRef<ZoomTransform>(zoomIdentity);
321 1 : const [selection, setSelection] = React.useState<{ operator: ProfileSceneOperator; anchor: SVGGElement } | null>(null);
322 1 : const [inspectorOpen, setInspectorOpen] = React.useState(false);
323 1 : const [selectedAnnotationMode, setAnnotationMode] = React.useState<ProfileAnnotationMode | null>(null);
324 : /// The fragments are light grey, unless the colors tell them apart
325 1 : const [fragmentColors, setFragmentColors] = React.useState(false);
326 1 : const operatorRefs = React.useRef(new Map<number, SVGGElement>());
327 1 : const [positionRevision, setPositionRevision] = React.useState(0);
328 1 : const edgeColorScheme = edgeRendering?.colorScheme ?? PROFILE_EDGE_COLOR_SCHEME_RD_BU;
329 1 : const rowColorScheme = edgeRendering?.rowColorScheme ?? PROFILE_EDGE_COLOR_SCHEME_RD_PU;
330 1 : const costColorScheme = edgeRendering?.costColorScheme ?? PROFILE_COST_COLOR_SCHEME_OR_RD;
331 1 : const edgeDifferenceColor = React.useCallback(
332 1 : (difference: number) => edgeColorScheme(difference < 0
333 1 : ? Math.max(0, (difference + 1) / 2)
334 1 : : Math.min(1, 0.5 + difference / 4)),
335 1 : [edgeColorScheme],
336 1 : );
337 1 : const estimateLegendGradient = React.useMemo(
338 1 : () => Array.from({ length: 11 }, (_, index) => edgeColorScheme(1 - index / 10)).join(', '),
339 1 : [edgeColorScheme],
340 1 : );
341 1 : const rowLegendGradient = React.useMemo(
342 1 : () => Array.from({ length: 11 }, (_, index) => rowColorScheme(index / 10)).join(', '),
343 1 : [rowColorScheme],
344 1 : );
345 1 : const costLegendGradient = React.useMemo(
346 1 : () => Array.from({ length: 11 }, (_, index) => costColorScheme(index / 10)).join(', '),
347 1 : [costColorScheme],
348 1 : );
349 1 : const actualRows = React.useMemo(() => scene.edges.map(edge => {
350 1 : const rows = execution.edgeRows(edge.slot, edge.childOperator);
351 1 : return rows == null ? null : Number(rows);
352 1 : }), [scene, execution]);
353 : // A mode is offered only if some operator has its value, a plan without estimates has no Estimates and no Accuracy
354 1 : const annotationModes = React.useMemo(() => {
355 1 : const field = dashql.buffers.profile.ProfileStatsField;
356 1 : const hasActual = actualRows.some(rows => rows != null);
357 1 : const hasEstimate = scene.edges.some(edge => edge.outputCardinalityEstimated != null);
358 1 : const hasBoth = scene.edges.some((edge, index) => edge.outputCardinalityEstimated != null && actualRows[index] != null);
359 1 : const reports = (stat: dashql.buffers.profile.ProfileStatsField) =>
360 1 : scene.operators.some(operator => {
361 1 : const state = execution.operators[operator.id];
362 1 : return state != null && isStatReported(state.stats, stat);
363 1 : });
364 1 : const available: Record<ProfileAnnotationMode, boolean> = {
365 1 : status: showProgress,
366 1 : plain: true,
367 1 : rows: hasActual,
368 1 : estimates: hasEstimate,
369 1 : accuracy: hasBoth,
370 1 : cpu: reports(field.CPU_TIME),
371 1 : memory: reports(field.MEMORY_BYTES),
372 1 : };
373 1 : return PROFILE_ANNOTATION_MODES.filter(({ mode }) => available[mode]);
374 1 : }, [scene, execution, actualRows, showProgress]);
375 : // Without a choice, or if the chosen mode is gone, the view shows the status with progress, else the actual rows,
376 : // else the estimates
377 1 : const offered = (mode: ProfileAnnotationMode) => annotationModes.some(m => m.mode === mode);
378 1 : const annotationMode: ProfileAnnotationMode = selectedAnnotationMode != null && offered(selectedAnnotationMode)
379 1 : ? selectedAnnotationMode
380 1 : : (['status', 'rows', 'estimates'] as const).find(offered) ?? 'plain';
381 1 : const annotations: ProfileAnnotation[] = React.useMemo(() => {
382 1 : const field = dashql.buffers.profile.ProfileStatsField;
383 1 : const badge = (operator: ProfileSceneOperator, text: string, background: string, description: string, border: string | null = null): ProfileEdgeBadge => ({
384 1 : ...placeOperatorAnnotation(operator, scene.layoutConfig.input!),
385 1 : width: Math.min(
386 : PROFILE_OPERATOR_ANNOTATION_WIDTH,
387 1 : Math.max(OPERATOR_ANNOTATION_MIN_WIDTH, text.length * OPERATOR_ANNOTATION_CHAR_WIDTH + OPERATOR_ANNOTATION_PADDING),
388 1 : ),
389 1 : text,
390 1 : background,
391 1 : foreground: contrastingTextColor(background),
392 1 : border,
393 1 : description,
394 1 : });
395 1 : switch (annotationMode) {
396 1 : case 'rows':
397 1 : case 'estimates':
398 1 : case 'accuracy':
399 1 : break;
400 1 : case 'cpu': {
401 : // The share of the CPU time of all operators, colored relative to the operator with the most
402 1 : const cpu = scene.operators.map(operator => stat(execution.operators[operator.id], field.CPU_TIME, s => s.cpuTimeUs));
403 1 : const total = cpu.reduce<number>((sum, value) => sum + (value ?? 0), 0);
404 1 : const max = cpu.reduce<number>((maximum, value) => Math.max(maximum, value ?? 0), 0);
405 1 : return scene.operators.flatMap((operator, index) => {
406 1 : const value = cpu[index];
407 1 : if (value == null) return [];
408 1 : const share = total === 0 ? 0 : value / total;
409 1 : return [{
410 1 : operator: operator.id,
411 1 : edge: null,
412 1 : badge: badge(
413 1 : operator,
414 1 : formatCpuShare(share),
415 1 : costColorScheme(max === 0 ? 0 : value / max),
416 1 : `CPU ${formatElapsed(value / 1000)}, ${formatCpuShare(share)} of the CPU time of all operators`,
417 1 : ),
418 1 : }];
419 1 : });
420 1 : }
421 1 : case 'memory': {
422 1 : const memory = scene.operators.map(operator => stat(execution.operators[operator.id], field.MEMORY_BYTES, s => s.memoryBytes));
423 1 : const max = memory.reduce<number>((maximum, value) => Math.max(maximum, value ?? 0), 0);
424 1 : return scene.operators.flatMap((operator, index) => {
425 1 : const value = memory[index];
426 1 : if (value == null) return [];
427 1 : const spilledBytes = stat(execution.operators[operator.id], field.SPILLED_BYTES, s => s.spilledBytes) ?? 0;
428 1 : return [{
429 1 : operator: operator.id,
430 1 : edge: null,
431 1 : badge: badge(
432 1 : operator,
433 1 : formatShortBytes(value),
434 1 : costColorScheme(max === 0 ? 0 : value / max),
435 1 : `Memory ${formatCompactBytes(value)}${spilledBytes > 0 ? `, spilled ${formatCompactBytes(spilledBytes)} to disk` : ''}`,
436 1 : spilledBytes > 0 ? SPILLED_BADGE_BORDER : null,
437 1 : ),
438 1 : }];
439 1 : });
440 1 : }
441 1 : default:
442 1 : return [];
443 1 : }
444 : // Actual and estimated rows share one color scale, the same color is the same row count in both modes
445 1 : const maxRowValue = scene.edges.reduce(
446 1 : (maximum, edge, edgeIndex) => Math.max(maximum, actualRows[edgeIndex] ?? 0, edge.outputCardinalityEstimated ?? 0),
447 1 : 0,
448 1 : );
449 1 : const rowScale = (rows: number) => maxRowValue === 0 ? 0 : Math.log1p(rows) / Math.log1p(maxRowValue);
450 1 : return scene.edges.flatMap((edge, edgeIndex) => {
451 1 : const child = scene.operators[edge.childOperator];
452 1 : if (child == null || scene.operators[edge.parentOperator] == null) return [];
453 1 : const actual = actualRows[edgeIndex];
454 1 : const estimated = edge.outputCardinalityEstimated;
455 1 : let annotation: ProfileEdgeBadge | null = null;
456 1 : if (annotationMode === 'rows' && actual != null) {
457 1 : annotation = badge(child, formatCompactCount(actual), rowColorScheme(rowScale(actual)), `Actual rows ${formatRowCount(actual)}`);
458 1 : } else if (annotationMode === 'estimates' && estimated != null) {
459 1 : annotation = badge(child, formatCompactCount(estimated), rowColorScheme(rowScale(estimated)), `Estimated rows ${formatRowCount(Math.round(estimated))}`);
460 1 : } else if (annotationMode === 'accuracy' && actual != null && estimated != null) {
461 1 : const difference = estimateRelativeDifference(estimated, actual);
462 1 : annotation = badge(
463 1 : child,
464 1 : formatEstimateSymbol(difference, Math.abs(actual - estimated)),
465 1 : edgeDifferenceColor(difference),
466 1 : `Actual rows ${formatRowCount(actual)}, estimated rows ${formatRowCount(Math.round(estimated))}, difference ${formatRelativeDifference(difference)}`,
467 1 : );
468 1 : }
469 1 : return annotation == null ? [] : [{ operator: child.id, edge, badge: annotation }];
470 1 : });
471 1 : }, [scene, execution, actualRows, annotationMode, rowColorScheme, costColorScheme, edgeDifferenceColor]);
472 : /// Did an operator spill to disk? The memory legend explains the border then.
473 1 : const spilled = React.useMemo(
474 1 : () => annotationMode === 'memory' && annotations.some(annotation => annotation.badge.border != null),
475 1 : [annotationMode, annotations],
476 1 : );
477 : /// The width of what the annotation slot of an operator shows, the label is centered in the rest of the operator
478 1 : const annotationWidths = React.useMemo(() => {
479 1 : const widths = new Map<number, number>();
480 1 : for (const annotation of annotations) widths.set(annotation.operator, annotation.badge.width);
481 1 : return widths;
482 1 : }, [annotations]);
483 1 : const highlightedEdges = React.useMemo(() => {
484 1 : const operatorId = selection?.operator.id;
485 1 : return operatorId == null
486 1 : ? []
487 1 : : scene.edges.filter(edge => edge.childOperator === operatorId || edge.parentOperator === operatorId);
488 1 : }, [scene.edges, selection?.operator.id]);
489 1 : const highlightedEdgeSet = React.useMemo(() => new Set(highlightedEdges), [highlightedEdges]);
490 1 : const highlightedCrossEdges = React.useMemo(() => {
491 1 : const operatorId = selection?.operator.id;
492 1 : return operatorId == null
493 1 : ? []
494 1 : : scene.crossEdges.filter(edge => edge.sourceOperator === operatorId || edge.targetOperator === operatorId);
495 1 : }, [scene.crossEdges, selection?.operator.id]);
496 1 : const highlightedOutputPortOperatorIds = React.useMemo(() => new Set([
497 1 : ...highlightedEdges.map(edge => edge.childOperator),
498 1 : ...highlightedCrossEdges.map(edge => edge.sourceOperator),
499 1 : ]), [highlightedCrossEdges, highlightedEdges]);
500 1 : const highlightedInputPortOperatorIds = React.useMemo(() => new Set([
501 1 : ...highlightedEdges.map(edge => edge.parentOperator),
502 1 : ...highlightedCrossEdges.map(edge => edge.targetOperator),
503 1 : ]), [highlightedCrossEdges, highlightedEdges]);
504 1 : const crossEdgeDescriptions = React.useMemo(() => {
505 1 : const relationships = new Map<number, string[]>();
506 1 : const add = (operatorId: number, relationship: string) => {
507 1 : const existing = relationships.get(operatorId);
508 1 : if (existing == null) relationships.set(operatorId, [relationship]);
509 0 : else existing.push(relationship);
510 1 : };
511 1 : for (const edge of scene.crossEdges) {
512 1 : add(edge.sourceOperator, `provides ${edge.kind} to ${scene.operators[edge.targetOperator]?.label ?? 'operator'}`);
513 1 : if (edge.targetOperator !== edge.sourceOperator) add(edge.targetOperator, `uses ${edge.kind} from ${scene.operators[edge.sourceOperator]?.label ?? 'operator'}`);
514 1 : }
515 1 : const descriptions = new Map<number, string>();
516 1 : for (const [operatorId, operatorRelationships] of relationships) {
517 1 : descriptions.set(operatorId, `; ${operatorRelationships.join('; ')}`);
518 : }
519 1 : return descriptions;
520 1 : }, [scene.crossEdges, scene.operators]);
521 :
522 1 : React.useLayoutEffect(() => {
523 1 : controller.reset(scene.operators.length, scene.pipelines.length);
524 1 : controller.applyState(execution, scene.operators.map(operator => operator.fragment));
525 1 : if (controllerRef != null) controllerRef.current = controller;
526 1 : return () => {
527 1 : if (controllerRef != null) controllerRef.current = null;
528 1 : };
529 1 : }, [controller, controllerRef, scene, execution]);
530 :
531 1 : React.useLayoutEffect(() => {
532 1 : if (autoFocus) viewportRef.current?.focus();
533 1 : }, [autoFocus]);
534 :
535 1 : const fit = React.useCallback((animate = false) => {
536 1 : const viewport = viewportRef.current;
537 1 : const svg = svgRef.current;
538 1 : const behavior = zoomRef.current;
539 1 : if (viewport == null || svg == null || behavior == null) return;
540 1 : const bounds = measurePlanBounds(sceneRef.current, scene);
541 1 : if (bounds.width <= 0 || bounds.height <= 0) return;
542 1 : const { x, y, scale } = computeProfileFitTransform(bounds, viewport.clientWidth, viewport.clientHeight);
543 : // A large plan fits below the default zoom limit, allow zooming out as far as the fit
544 1 : behavior.scaleExtent([Math.min(PROFILE_MIN_ZOOM, scale), PROFILE_MAX_ZOOM]);
545 1 : const target = zoomIdentity.translate(x, y).scale(scale);
546 1 : const targetSelection = animate ? select(svg).transition().duration(160) : select(svg);
547 1 : targetSelection.call(behavior.transform as any, target);
548 1 : }, [scene]);
549 :
550 : // The zoom transform is applied to the DOM directly. Only the open inspector depends on the
551 : // operator positions, so bump its position revision at most once per frame and only while it is open.
552 1 : const inspectorVisibleRef = React.useRef(false);
553 1 : inspectorVisibleRef.current = selection != null && inspectorOpen;
554 1 : const positionRevisionFrame = React.useRef<number | null>(null);
555 1 : const schedulePositionRevision = React.useCallback(() => {
556 0 : if (positionRevisionFrame.current != null) return;
557 0 : positionRevisionFrame.current = requestAnimationFrame(() => {
558 0 : positionRevisionFrame.current = null;
559 0 : setPositionRevision(value => value + 1);
560 0 : });
561 1 : }, []);
562 1 : React.useEffect(() => () => {
563 1 : if (positionRevisionFrame.current != null) {
564 0 : cancelAnimationFrame(positionRevisionFrame.current);
565 0 : positionRevisionFrame.current = null;
566 0 : }
567 1 : }, []);
568 :
569 1 : React.useLayoutEffect(() => {
570 1 : const svg = svgRef.current;
571 1 : const viewport = viewportRef.current;
572 1 : if (svg == null || viewport == null) return;
573 1 : const behavior = zoom<SVGSVGElement, unknown>()
574 : // The default extent reads the relative SVG width, which throws while the SVG is not laid out yet
575 1 : .extent((): [[number, number], [number, number]] => [[0, 0], [viewport.clientWidth, viewport.clientHeight]])
576 1 : .scaleExtent([PROFILE_MIN_ZOOM, PROFILE_MAX_ZOOM])
577 1 : .clickDistance(4)
578 1 : .filter(event => !event.button && !(event.target instanceof Element && event.target.closest('button')))
579 1 : .on('zoom', event => {
580 1 : transformRef.current = event.transform;
581 1 : sceneRef.current?.setAttribute('transform', event.transform.toString());
582 1 : if (inspectorVisibleRef.current) schedulePositionRevision();
583 1 : });
584 1 : zoomRef.current = behavior;
585 1 : select(svg).call(behavior).on('dblclick.zoom', null);
586 : // Re-fit when the viewport is resized, e.g. when the surrounding card is expanded.
587 : // The zoom event of the fit also repositions an open inspector.
588 1 : const observer = new ResizeObserver(() => fit());
589 1 : observer.observe(viewport);
590 1 : fit();
591 1 : return () => {
592 1 : observer.disconnect();
593 1 : select(svg).on('.zoom', null);
594 1 : zoomRef.current = null;
595 1 : };
596 1 : }, [fit, schedulePositionRevision]);
597 :
598 1 : const zoomBy = React.useCallback((factor: number) => {
599 0 : const svg = svgRef.current;
600 0 : const behavior = zoomRef.current;
601 0 : if (svg != null && behavior != null) select(svg).transition().duration(120).call(behavior.scaleBy, factor);
602 1 : }, []);
603 :
604 : // The rotated plan has other operator positions, the selection and the inspector are dropped.
605 : // The new scene fits itself into the viewport.
606 1 : const rotate = React.useMemo(() => onRotate == null ? null : () => {
607 1 : setInspectorOpen(false);
608 1 : setSelection(null);
609 1 : onRotate();
610 1 : }, [onRotate]);
611 :
612 :
613 1 : const onKeyDown = React.useCallback((event: React.KeyboardEvent) => {
614 1 : if (event.key === 'Escape' && inspectorOpen) {
615 1 : setInspectorOpen(false);
616 1 : event.preventDefault();
617 1 : event.stopPropagation();
618 1 : return;
619 1 : }
620 :
621 1 : const targetIsViewport = event.target === viewportRef.current;
622 1 : const targetIsOperator = event.target instanceof Element && event.target.matches('[data-plan-operator-id]');
623 1 : if (!targetIsViewport && !targetIsOperator) return;
624 :
625 1 : const direction: ProfileNavigationDirection | null = event.key === 'ArrowLeft' ? 'left'
626 1 : : event.key === 'ArrowRight' ? 'right'
627 1 : : event.key === 'ArrowUp' ? 'up'
628 1 : : event.key === 'ArrowDown' ? 'down'
629 1 : : null;
630 1 : if (direction != null) {
631 1 : const targetOperatorId = targetIsOperator
632 1 : ? Number((event.target as Element).getAttribute('data-plan-operator-id'))
633 1 : : null;
634 1 : const current = targetOperatorId == null
635 1 : ? selection?.operator ?? null
636 1 : : scene.operators.find(operator => operator.id === targetOperatorId) ?? null;
637 1 : const next = findProfileOperatorInDirection(scene.operators, scene.edges, current, direction, scene.leftToRight);
638 1 : const anchor = next == null ? null : operatorRefs.current.get(next.id) ?? null;
639 1 : if (next != null && anchor != null) {
640 1 : setSelection({ operator: next, anchor });
641 1 : setPositionRevision(value => value + 1);
642 1 : anchor.focus();
643 1 : }
644 1 : } else if (event.key === '+' || event.key === '=') zoomBy(1.25);
645 1 : else if (event.key === '-') zoomBy(0.8);
646 1 : else if (event.key === 'f') fit(true);
647 1 : else if (event.key === 'r' && rotate != null) rotate();
648 0 : else return;
649 1 : event.preventDefault();
650 1 : event.stopPropagation();
651 1 : }, [fit, inspectorOpen, rotate, scene.operators, selection?.operator, zoomBy]);
652 :
653 : // Stable operator callbacks so that the memoized operator nodes only re-render when their own props change
654 1 : const selectionRef = React.useRef(selection);
655 1 : selectionRef.current = selection;
656 1 : const setOperatorAnchor = React.useCallback((operatorId: number, anchor: SVGGElement | null) => {
657 1 : if (anchor == null) operatorRefs.current.delete(operatorId);
658 1 : else operatorRefs.current.set(operatorId, anchor);
659 1 : }, []);
660 1 : const onFocusOperator = React.useCallback((selected: ProfileSceneOperator, anchor: SVGGElement) => {
661 1 : setSelection({ operator: selected, anchor });
662 1 : }, []);
663 1 : const onSelectOperator = React.useCallback((selected: ProfileSceneOperator, anchor: SVGGElement, toggle: boolean) => {
664 1 : const previous = selectionRef.current;
665 1 : setSelection({ operator: selected, anchor });
666 1 : setPositionRevision(value => value + 1);
667 1 : setInspectorOpen(open => toggle && previous?.operator.id === selected.id ? !open : true);
668 1 : }, []);
669 :
670 1 : const anchorRef = React.useMemo(() => ({ current: selection?.anchor ?? null }), [selection?.anchor]);
671 : // The legend goes where a plan leaves space: top left above a top-down plan, bottom left above the
672 : // annotation controls below a left-to-right plan
673 1 : const legend = annotationMode === 'rows' || annotationMode === 'estimates' ? (
674 1 : <div
675 1 : className={styles.edge_legend}
676 1 : role="img"
677 1 : data-testid="plan-rows-legend"
678 1 : aria-label="Badge color encodes row count from fewer rows on the left to more rows on the right"
679 : >
680 1 : <div className={styles.edge_legend_scale}>
681 1 : <span>Fewer rows</span>
682 1 : <span>More rows</span>
683 1 : <i
684 1 : aria-hidden="true"
685 1 : style={{ backgroundImage: `linear-gradient(to right, ${rowLegendGradient})` }}
686 1 : />
687 1 : </div>
688 1 : </div>
689 1 : ) : annotationMode === 'cpu' || annotationMode === 'memory' ? (
690 1 : <div
691 1 : className={styles.edge_legend}
692 1 : role="img"
693 1 : data-testid={`plan-${annotationMode}-legend`}
694 1 : aria-label={annotationMode === 'cpu'
695 1 : ? 'Badges show the share of the CPU time of all operators, the color goes from none on the left to the operator with the most CPU time on the right'
696 1 : : `Badges show the memory of an operator, the color goes from none on the left to the operator with the most memory on the right${spilled ? ', an orange border marks an operator that spilled to disk' : ''}`}
697 : >
698 1 : <div className={styles.edge_legend_scale}>
699 1 : <span>{annotationMode === 'cpu' ? 'Less CPU' : 'Less memory'}</span>
700 1 : <span>{annotationMode === 'cpu' ? 'More CPU' : 'More memory'}</span>
701 1 : <i
702 1 : aria-hidden="true"
703 1 : style={{ backgroundImage: `linear-gradient(to right, ${costLegendGradient})` }}
704 1 : />
705 1 : {spilled && <span className={styles.edge_legend_note} style={{ borderColor: SPILLED_BADGE_BORDER }}>Spilled to disk</span>}
706 1 : </div>
707 1 : </div>
708 1 : ) : annotationMode === 'accuracy' ? (
709 1 : <div
710 1 : className={styles.edge_legend}
711 1 : role="img"
712 1 : data-testid="plan-accuracy-legend"
713 1 : aria-label="Badge color compares actual rows with estimates: red on the left means underestimated, the center means matched, and blue on the right means overestimated; differences are capped at 200 percent"
714 : >
715 1 : <div className={styles.edge_legend_scale}>
716 1 : <span>Underestimated</span>
717 1 : <span>Overestimated</span>
718 1 : <i
719 1 : aria-hidden="true"
720 1 : data-testid="plan-legend-gradient"
721 1 : style={{ backgroundImage: `linear-gradient(to right, ${estimateLegendGradient})` }}
722 1 : />
723 1 : <span className={styles.edge_legend_matched}>Matched</span>
724 1 : </div>
725 1 : </div>
726 1 : ) : null;
727 1 : return (
728 1 : <div
729 1 : ref={viewportRef}
730 1 : className={styles.viewport}
731 1 : data-fill={fill || undefined}
732 1 : role="region"
733 1 : aria-label={`Query execution plan with ${scene.operators.length} operators`}
734 1 : tabIndex={0}
735 1 : onKeyDown={onKeyDown}
736 1 : data-electron-drag-region="false"
737 : >
738 1 : <svg ref={svgRef} className={styles.svg} onClick={event => {
739 0 : if (event.target === svgRef.current) {
740 0 : setSelection(null);
741 0 : setInspectorOpen(false);
742 0 : }
743 0 : }}>
744 1 : <g ref={sceneRef}>
745 1 : <g>
746 1 : {scene.fragments.map((fragment, fragmentIndex) => {
747 1 : const progress = showProgress ? fragmentProgress(execution, fragment.id) : null;
748 : // The metrics of a fragment are only read out, they have no card on the contour for now
749 1 : const metrics = showProgress ? formatFragmentMetrics(execution.fragments[fragment.id]) : null;
750 1 : return (
751 1 : <g
752 1 : key={fragment.id}
753 1 : className={graphStyles.fragment_colors}
754 1 : data-color={fragmentColors ? fragmentIndex % 6 : 'neutral'}
755 1 : role="img"
756 1 : aria-label={`Fragment ${fragment.id + 1}${metrics == null ? '' : `, ${metrics.description}`}, containing operators: ${fragment.operatorIds
757 1 : .map(operatorId => scene.operators[operatorId]?.label)
758 1 : .filter((label): label is string => label != null)
759 1 : .join(', ')}`}
760 : >
761 1 : <path
762 1 : className={`${graphStyles.fragment} ${styles.fragment}`}
763 1 : d={fragment.path}
764 1 : data-status={fragmentStatus(execution, fragment.id)}
765 1 : />
766 1 : {progress?.percent != null && (
767 : // The completed share of the work runs along the contour
768 1 : <path
769 1 : className={styles.fragment_progress}
770 1 : d={fragment.path}
771 1 : pathLength={100}
772 1 : strokeDasharray={`${progress.percent} 100`}
773 1 : data-testid="fragment-progress"
774 1 : />
775 : )}
776 1 : </g>
777 : );
778 1 : })}
779 1 : </g>
780 1 : <g>
781 1 : {scene.pipelines.map(pipeline => (
782 1 : <path
783 1 : key={pipeline.id}
784 1 : ref={path => controller.registerPipeline(pipeline.id, path)}
785 1 : className={styles.pipeline}
786 1 : d={pipeline.path}
787 1 : aria-hidden="true"
788 1 : />
789 1 : ))}
790 1 : </g>
791 1 : <g aria-hidden="true">
792 1 : {scene.edges.map(edge => (
793 1 : <path
794 1 : key={edge.id.toString()}
795 1 : className={graphStyles.edge}
796 1 : d={edge.path}
797 1 : style={{ strokeWidth: EDGE_WIDTH }}
798 1 : data-plan-edge-id={edge.id.toString()}
799 1 : />
800 1 : ))}
801 1 : </g>
802 1 : <g aria-hidden="true">
803 1 : {scene.crossEdges.map(edge => (
804 1 : <path
805 1 : key={edge.id.toString()}
806 1 : className={graphStyles.crossEdge}
807 1 : data-kind={edge.kind}
808 1 : d={edge.path}
809 1 : />
810 1 : ))}
811 1 : </g>
812 1 : <g className={graphStyles.edgeHighlightLayer} aria-hidden="true">
813 1 : {highlightedEdges.map(edge => (
814 1 : <path
815 1 : key={`edge-${edge.id}`}
816 1 : className={graphStyles.edgeHighlight}
817 1 : d={edge.path}
818 1 : style={{ strokeWidth: EDGE_WIDTH }}
819 1 : data-plan-edge-highlight-id={edge.id.toString()}
820 1 : />
821 1 : ))}
822 1 : {highlightedCrossEdges.map(edge => (
823 1 : <path
824 1 : key={`cross-edge-${edge.id}`}
825 1 : className={graphStyles.crossEdgeHighlight}
826 1 : d={edge.path}
827 1 : data-plan-cross-edge-highlight-id={edge.id.toString()}
828 1 : />
829 1 : ))}
830 1 : </g>
831 1 : <g>
832 1 : {scene.operators.map(operator => (
833 1 : <ProfileOperatorNode
834 1 : key={operator.id}
835 1 : operator={operator}
836 1 : scene={scene}
837 1 : showStatus={showProgress && annotationMode === 'status'}
838 1 : annotationWidth={showProgress && annotationMode === 'status'
839 1 : ? STATUS_ICON_SIZE
840 1 : : annotationWidths.get(operator.id) ?? 0}
841 1 : progress={showProgress ? operatorProgress(execution, operator.fragment) : null}
842 1 : selected={selection?.operator.id === operator.id}
843 1 : highlightedOutputPort={highlightedOutputPortOperatorIds.has(operator.id)}
844 1 : highlightedInputPort={highlightedInputPortOperatorIds.has(operator.id)}
845 1 : expanded={inspectorOpen && selection?.operator.id === operator.id}
846 1 : controller={controller}
847 1 : relationshipDescription={crossEdgeDescriptions.get(operator.id) ?? ''}
848 1 : setAnchor={setOperatorAnchor}
849 1 : onFocus={onFocusOperator}
850 1 : onSelect={onSelectOperator}
851 1 : />
852 1 : ))}
853 1 : </g>
854 : {/* The annotations sit in the annotation slots of the operators, above their frames */}
855 1 : <g className={graphStyles.edgeBadgeLayer}>
856 1 : {annotations.map(({ operator, edge, badge }) => {
857 : // An edge annotation is highlighted with its edge, an operator annotation with its operator
858 1 : const highlighted = edge != null ? highlightedEdgeSet.has(edge) : selection?.operator.id === operator;
859 1 : return (
860 1 : <g
861 1 : key={edge != null ? `edge-${edge.id}` : `operator-${operator}`}
862 1 : className={graphStyles.edgeBadge}
863 1 : transform={`translate(${badge.x}, ${badge.y})`}
864 1 : role="img"
865 1 : aria-label={badge.description}
866 1 : data-plan-edge-badge-id={edge?.id.toString()}
867 1 : data-plan-operator-badge-id={edge == null ? operator.toString() : undefined}
868 1 : data-spilled={badge.border != null || undefined}
869 1 : data-highlighted={highlighted || undefined}
870 : >
871 1 : <rect
872 1 : x={0}
873 1 : y={-OPERATOR_ANNOTATION_HEIGHT / 2}
874 1 : width={badge.width}
875 1 : height={OPERATOR_ANNOTATION_HEIGHT}
876 1 : rx={OPERATOR_ANNOTATION_HEIGHT / 2}
877 1 : fill={badge.background}
878 1 : strokeWidth={badge.border != null ? 1.5 : undefined}
879 1 : style={{
880 1 : fill: highlighted ? EDGE_HIGHLIGHT_COLOR : undefined,
881 1 : stroke: highlighted ? EDGE_HIGHLIGHT_COLOR : badge.border ?? EDGE_BADGE_BORDER,
882 1 : }}
883 1 : />
884 1 : <text
885 1 : x={badge.width / 2}
886 1 : fill={badge.foreground}
887 1 : style={highlighted ? { fill: EDGE_HIGHLIGHT_TEXT_COLOR } : undefined}
888 : >
889 1 : {badge.text}
890 1 : </text>
891 1 : </g>
892 : );
893 1 : })}
894 1 : </g>
895 1 : </g>
896 1 : </svg>
897 1 : <div className={styles.metric_controls}>
898 1 : {scene.leftToRight && legend}
899 1 : <div className={styles.view_controls}>
900 1 : <SegmentedControl
901 1 : aria-label="Plan annotations"
902 1 : size={SegmentedControlSize.Tiny}
903 1 : onChange={index => setAnnotationMode(annotationModes[index].mode)}
904 : >
905 1 : {annotationModes.map(({ mode, label }) => (
906 1 : <SegmentedControl.Button key={mode} data-testid={`plan-annotation-${mode}`} selected={annotationMode === mode}>{label}</SegmentedControl.Button>
907 1 : ))}
908 1 : </SegmentedControl>
909 1 : <ButtonGroup className={styles.view_buttons} aria-label="Plan view controls">
910 1 : {rotate != null && (
911 1 : <IconButton
912 1 : variant={ButtonVariant.Default}
913 1 : size={ButtonSize.Tiny}
914 1 : aria-label={scene.leftToRight ? 'Rotate plan to top-down' : 'Rotate plan to left-to-right'}
915 1 : data-testid="plan-rotate"
916 1 : onClick={rotate}
917 : >
918 1 : <RotateIcon size={12} />
919 1 : </IconButton>
920 : )}
921 1 : <IconButton
922 1 : variant={ButtonVariant.Default}
923 1 : size={ButtonSize.Tiny}
924 1 : aria-label="Color the fragments"
925 1 : aria-pressed={fragmentColors}
926 1 : data-testid="plan-fragment-colors"
927 1 : onClick={() => setFragmentColors(value => !value)}
928 : >
929 1 : <PaintbrushIcon size={12} />
930 1 : </IconButton>
931 1 : </ButtonGroup>
932 1 : </div>
933 1 : </div>
934 1 : {!scene.leftToRight && legend}
935 1 : <ButtonGroup className={styles.controls} aria-label="Plan zoom controls">
936 1 : <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Zoom in" onClick={() => zoomBy(1.25)}><ZoomInIcon size={12} /></IconButton>
937 1 : <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Zoom out" onClick={() => zoomBy(0.8)}><ZoomOutIcon size={12} /></IconButton>
938 1 : <IconButton variant={ButtonVariant.Default} size={ButtonSize.Small} aria-label="Fit plan" onClick={() => fit(true)}><FitIcon size={12} /></IconButton>
939 1 : </ButtonGroup>
940 1 : <AnchoredOverlay
941 1 : renderAnchor={null}
942 1 : anchorRef={anchorRef}
943 1 : open={selection != null && inspectorOpen}
944 1 : onClose={() => setInspectorOpen(false)}
945 : // A left-to-right plan continues right of an operator, the inspector goes above it instead
946 1 : side={scene.leftToRight ? AnchorSide.OutsideTop : AnchorSide.OutsideRight}
947 1 : align={AnchorAlignment.Center}
948 1 : anchorOffset={8}
949 1 : width={OverlaySize.L}
950 1 : maxHeight={OverlaySize.L}
951 1 : positionRevision={positionRevision}
952 : >
953 1 : <section className={styles.inspector} data-testid="plan-operator-inspector" aria-label={`${selection?.operator.label ?? 'Operator'} properties`}>
954 1 : <header
955 1 : className={styles.inspector_header}
956 1 : data-single-line={selection?.operator.typeName == null || selection.operator.typeName === selection.operator.label}
957 : >
958 1 : <strong>{selection?.operator.label}</strong>
959 1 : {selection?.operator.typeName != null && selection.operator.typeName !== selection.operator.label && (
960 0 : <span>{selection.operator.typeName}</span>
961 : )}
962 1 : </header>
963 1 : {selection != null && (
964 1 : <JsonView
965 1 : className={styles.inspector_json}
966 1 : value={selection.operator.properties}
967 1 : collapsed={2}
968 1 : shortenTextAfterLength={100}
969 1 : />
970 : )}
971 1 : </section>
972 1 : </AnchoredOverlay>
973 1 : </div>
974 : );
975 1 : }
976 :
977 1 : const ProfileOperatorNode = React.memo(function ProfileOperatorNode(props: {
978 : operator: ProfileSceneOperator;
979 : scene: ProfileScene;
980 : /// Show the status icon in the annotation slot?
981 : showStatus: boolean;
982 : /// The width of what the annotation slot shows, 0 if it is empty
983 : annotationWidth: number;
984 : /// The completed share of the work of the fragment of the operator in percent while it runs, null if unknown
985 : progress: number | null;
986 : selected: boolean;
987 : highlightedOutputPort: boolean;
988 : highlightedInputPort: boolean;
989 : expanded: boolean;
990 : controller: ProfileExecutionController;
991 : relationshipDescription: string;
992 : setAnchor: (operatorId: number, anchor: SVGGElement | null) => void;
993 : onFocus: (operator: ProfileSceneOperator, anchor: SVGGElement) => void;
994 : onSelect: (operator: ProfileSceneOperator, anchor: SVGGElement, toggle: boolean) => void;
995 1 : }) {
996 1 : const { operator, scene } = props;
997 1 : const setAnchor = React.useCallback((anchor: SVGGElement | null) => props.setAnchor(operator.id, anchor), [props.setAnchor, operator.id]);
998 1 : const x = operator.rect.x - operator.rect.width / 2;
999 1 : const y = operator.rect.y - operator.rect.height / 2;
1000 1 : const input = scene.layoutConfig.input!;
1001 : // The label fills the rest of the operator right of the annotation, or all of it without an annotation
1002 1 : const regionStart = input.nodePaddingLeft + (props.annotationWidth > 0 ? props.annotationWidth + input.iconMarginRight : 0);
1003 1 : const regionEnd = operator.rect.width - input.nodePaddingRight;
1004 1 : const renderSymbol = shouldRenderProfileOperatorSymbol(operator.kind, operator.typeName, operator.label);
1005 1 : const symbolName = renderSymbol ? getProfileOperatorSymbol(operator.kind) : null;
1006 1 : const OperatorSymbol = symbolName != null ? SymbolIcon(symbolName) : null;
1007 1 : const activate = (event: React.MouseEvent<SVGGElement> | React.KeyboardEvent<SVGGElement>) => {
1008 1 : if ('key' in event && event.key !== 'Enter' && event.key !== ' ') return;
1009 1 : if ('key' in event) event.preventDefault();
1010 1 : event.stopPropagation();
1011 1 : props.onSelect(operator, event.currentTarget, 'key' in event);
1012 1 : };
1013 1 : return (
1014 1 : <g
1015 1 : ref={setAnchor}
1016 1 : className={styles.operator}
1017 1 : data-selected={props.selected}
1018 1 : data-highlighted-output-port={props.highlightedOutputPort}
1019 1 : data-highlighted-input-port={props.highlightedInputPort}
1020 1 : data-plan-operator-id={operator.id}
1021 1 : transform={`translate(${x}, ${y})`}
1022 1 : role="button"
1023 1 : tabIndex={0}
1024 1 : aria-label={`${operator.label}${props.relationshipDescription}, show properties`}
1025 1 : aria-expanded={props.expanded}
1026 1 : onClick={activate}
1027 1 : onFocus={event => props.onFocus(operator, event.currentTarget)}
1028 1 : onKeyDown={activate}
1029 : >
1030 1 : <rect
1031 1 : className={graphStyles.node_frame}
1032 1 : width={operator.rect.width}
1033 1 : height={operator.rect.height}
1034 1 : rx={6}
1035 1 : ry={6}
1036 1 : data-plan-operator-frame
1037 1 : />
1038 1 : {props.showStatus && (
1039 1 : <g
1040 1 : ref={slot => props.controller.registerOperator(operator.id, slot)}
1041 1 : className={styles.status}
1042 1 : transform={`translate(${input.nodePaddingLeft}, ${(input.nodeHeight - STATUS_ICON_SIZE) / 2})`}
1043 1 : aria-hidden="true"
1044 : >
1045 1 : <g transform={`scale(${STATUS_ICON_SIZE / 16})`}>
1046 1 : <StatusIcons progress={props.progress} />
1047 1 : </g>
1048 1 : </g>
1049 : )}
1050 1 : {OperatorSymbol != null
1051 1 : ? <g className={styles.operator_symbol} transform={`translate(${(regionStart + regionEnd - PROFILE_OPERATOR_SYMBOL_SIZE) / 2}, ${(input.nodeHeight - PROFILE_OPERATOR_SYMBOL_SIZE) / 2})`} aria-hidden="true"><OperatorSymbol size={PROFILE_OPERATOR_SYMBOL_SIZE} /></g>
1052 : // The label is truncated to maxLabelChars and the core sizes the operator for it, no clipping needed
1053 1 : : <text x={(regionStart + regionEnd) / 2} y={input.nodeHeight / 2 + 5}>{operator.displayLabel}</text>}
1054 : {/* The output faces the parent: above, or left if the plan grows left-to-right */}
1055 1 : {(operator.ports & PROFILE_OPERATOR_PORT_OUTPUT) !== 0 && (scene.leftToRight
1056 0 : ? <GraphPort x={0} y={operator.rect.height / 2} side="left" highlighted={props.highlightedOutputPort} data-plan-port="output" />
1057 1 : : <GraphPort x={operator.rect.width / 2} y={0} side="top" highlighted={props.highlightedOutputPort} data-plan-port="output" />
1058 : )}
1059 1 : {(operator.ports & PROFILE_OPERATOR_PORT_INPUT) !== 0 && (scene.leftToRight
1060 0 : ? <GraphPort x={operator.rect.width} y={operator.rect.height / 2} side="right" highlighted={props.highlightedInputPort} data-plan-port="input" />
1061 1 : : <GraphPort x={operator.rect.width / 2} y={operator.rect.height} side="bottom" highlighted={props.highlightedInputPort} data-plan-port="input" />
1062 : )}
1063 1 : </g>
1064 : );
1065 1 : });
|