diff --git a/webview-ui/src/diagnostics_panel/App.css b/webview-ui/src/diagnostics_panel/App.css index 7e98e13..5702ef9 100644 --- a/webview-ui/src/diagnostics_panel/App.css +++ b/webview-ui/src/diagnostics_panel/App.css @@ -217,3 +217,116 @@ main { #multi-column-grid vscode-data-grid-cell:not([grid-column="1"]) { text-align: right; } + +.minecraft-profiler-flame-stream-root { + width: 100%; + display: flex; + flex-direction: column; + gap: 10px; +} + +.minecraft-profiler-flame-stream-toolbar { + display: flex; + flex-wrap: wrap; + gap: 12px; +} + +.minecraft-profiler-flame-stream-toolbar-group { + display: flex; + flex-direction: column; + gap: 4px; + min-width: 360px; +} + +.minecraft-profiler-flame-stream-toolbar-group-scale { + min-width: 280px; +} + +.minecraft-profiler-flame-stream-toolbar-group-unit { + min-width: 220px; +} + +.minecraft-profiler-flame-stream-toolbar-group-lane-display { + min-width: 260px; +} + +.minecraft-profiler-flame-stream-range-row { + display: flex; + flex-direction: row; + align-items: center; + gap: 8px; +} + +.minecraft-profiler-flame-stream-range-row input[type="range"] { + flex: 1; +} + +.minecraft-profiler-flame-stream-select { + min-width: 180px; + height: 28px; + padding: 2px 8px; + color: var(--vscode-input-foreground); + background: var(--vscode-dropdown-background); + border: 1px solid var(--vscode-dropdown-border); +} + +.minecraft-profiler-flame-stream-toolbar-caption { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.minecraft-profiler-flame-stream-surface { + width: 100%; + border: 1px solid var(--vscode-editorGroup-border); + border-radius: 6px; + display: grid; + grid-template-columns: minmax(220px, 320px) 8px minmax(0, 1fr); + overflow: visible; + min-height: 260px; +} + +.minecraft-profiler-flame-stream-label-pane { + overflow: visible; + max-height: none; +} + +.minecraft-profiler-flame-stream-pane-resizer { + cursor: col-resize; + background: color-mix(in srgb, var(--vscode-editorGroup-border) 72%, transparent); + border-left: 1px solid var(--vscode-editorGroup-border); + border-right: 1px solid var(--vscode-editorGroup-border); +} + +.minecraft-profiler-flame-stream-pane-resizer:hover { + background: color-mix(in srgb, var(--vscode-focusBorder) 55%, transparent); +} + +.minecraft-profiler-flame-stream-label-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + box-sizing: border-box; + border-bottom: 1px solid var(--vscode-editorGroup-border); + font-size: 12px; +} + +.minecraft-profiler-flame-stream-label-text { + flex: 1; + min-width: 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.minecraft-profiler-flame-stream-chart-pane { + position: relative; + min-width: 0; + overflow: visible; + max-height: none; +} + +.minecraft-profiler-flame-stream-empty { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} diff --git a/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx b/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx new file mode 100644 index 0000000..ea1e103 --- /dev/null +++ b/webview-ui/src/diagnostics_panel/controls/MinecraftProfilerFlameStreamChart.tsx @@ -0,0 +1,1300 @@ +// Copyright (C) Microsoft Corporation. All rights reserved. + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import * as Plot from '@observablehq/plot'; +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; +import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../StatisticProvider'; +import { removeAllStyleElements } from '../../util/CSPUtilities'; + +type ScopeDescriptor = { + pathKey: string; + label: string; + displayPath: string; + depth: number; + order: number; + rawIndent: string; +}; + +type ScopeSample = { + time: number; + low: number; + mid: number; + high: number; +}; + +type ProfilerState = { + scopesByPath: Record; + order: string[]; + historyByPath: Record; + latestTick: number; + lastIndentTick: number; + indentCursor: number; +}; + +type TimeRange = { + start: number; + end: number; +}; + +type ValueScaleMode = 'normalized' | 'absolute'; +type TimeUnit = 'ms' | 'us' | 'ns'; +type LaneDisplayMode = 'range-and-midline' | 'midline-only'; + +type FlameChartPoint = { + time: number; + pathKey: string; + label: string; + displayPath: string; + depth: number; + yLow: number; + yMid: number; + yHigh: number; + depthBand: string; + laneFill: string; + midStroke: string; + laneRelativeRatio: number; +}; + +type LaneMetric = { + pathKey: string; + laneMaxValue: number; + latestLowValue: number; + latestMidValue: number; + latestHighValue: number; + relativeRatio: number; + contributionToParentRatio: number; + childrenBurdenRatio: number; + dominantChildLabel?: string; +}; + +type MinecraftProfilerFlameStreamChartProps = { + title: string; + statisticDataProvider: MultipleStatisticProvider; + tickRange?: number; + defaultWindowTicks?: number; +}; + +const DEFAULT_TICK_RANGE = 20 * 60; +const DEFAULT_WINDOW_TICKS = 20 * 15; +const ROW_HEIGHT = 72; +const ROW_PADDING = 10; +const MAX_DEPTH_ALL = Number.MAX_SAFE_INTEGER; +const LANE_TICK_MIN_GAP = 12; +const MIN_NORMALIZED_LANE_SPAN = 1; +const DEFAULT_LABEL_PANE_WIDTH = 300; +const MIN_LABEL_PANE_WIDTH = 220; +const MIN_CHART_PANE_WIDTH = 460; + +const DEPTH_FILL_PALETTE = [ + 'var(--vscode-charts-blue)', + 'var(--vscode-charts-green)', + 'var(--vscode-charts-orange)', + 'var(--vscode-charts-purple)', + 'var(--vscode-charts-red)', +]; + +// Using a lighter variant of the base color for the midline to ensure visibility +// when the low and high values are close together +const DEPTH_MIDLINE_PALETTE = [ + 'color-mix(in srgb, var(--vscode-charts-blue) 50%, white)', + 'color-mix(in srgb, var(--vscode-charts-green) 50%, white)', + 'color-mix(in srgb, var(--vscode-charts-orange) 50%, white)', + 'color-mix(in srgb, var(--vscode-charts-purple) 50%, white)', + 'color-mix(in srgb, var(--vscode-charts-red) 50%, white)', +]; + +const INITIAL_STATE: ProfilerState = { + scopesByPath: {}, + order: [], + historyByPath: {}, + latestTick: 0, + lastIndentTick: -1, + indentCursor: 0, +}; + +function parseIndentDepth(rawIndent: string): number { + const normalizedIndent = rawIndent.replace(/\t/g, ' '); + const trimmedIndent = normalizedIndent.trim(); + + if (/^-?\d+$/.test(trimmedIndent)) { + return Math.max(0, Number.parseInt(trimmedIndent, 10)); + } + + return Math.max(0, normalizedIndent.length); +} + +function splitLeadingIndent(label: string): { label: string; impliedDepth: number | undefined } { + const match = label.match(/^(\s+)(.*)$/); + if (!match) { + return { label, impliedDepth: undefined }; + } + + return { + label: match[2], + impliedDepth: parseIndentDepth(match[1]), + }; +} + +function parseMetricValue(rawValue: string | undefined): number | undefined { + if (rawValue === undefined) { + return undefined; + } + + const parsedValue = Number.parseFloat(rawValue); + if (!Number.isFinite(parsedValue)) { + return undefined; + } + + return parsedValue; +} + +function createFallbackPathKey(label: string, index: number): string { + return `${label}#fallback_${index}`; +} + +function buildScopeDescriptorsFromIndents(rows: string[][]): ScopeDescriptor[] { + const descriptors: ScopeDescriptor[] = []; + const siblingCountsByParent = new Map>(); + const pathStack: string[] = []; + const displayPathStack: string[] = []; + + rows.forEach((row, index) => { + const label = row[0] || `Scope ${index + 1}`; + const rawIndent = row[1] ?? ''; + const depth = rawIndent.length; + + while (pathStack.length > depth) { + pathStack.pop(); + displayPathStack.pop(); + } + + const parentPath = depth > 0 ? (pathStack[depth - 1] ?? '') : ''; + const parentDisplayPath = depth > 0 ? (displayPathStack[depth - 1] ?? '') : ''; + + let siblingCounts = siblingCountsByParent.get(parentPath); + if (siblingCounts === undefined) { + siblingCounts = new Map(); + siblingCountsByParent.set(parentPath, siblingCounts); + } + + const nextCount = (siblingCounts.get(label) ?? 0) + 1; + siblingCounts.set(label, nextCount); + + const pathSegment = `${label}#${nextCount}`; + const pathKey = parentPath === '' ? pathSegment : `${parentPath}/${pathSegment}`; + const displayPath = parentDisplayPath === '' ? label : `${parentDisplayPath} > ${label}`; + + pathStack[depth] = pathKey; + displayPathStack[depth] = displayPath; + + descriptors.push({ + pathKey, + label, + displayPath, + depth, + order: index, + rawIndent, + }); + }); + + return descriptors; +} + +function clampTimeRange(range: TimeRange, minTime: number, maxTime: number): TimeRange { + if (maxTime <= minTime) { + return { start: minTime, end: maxTime }; + } + + const clampedStart = Math.max(minTime, Math.min(range.start, maxTime - 1)); + const clampedEnd = Math.min(maxTime, Math.max(range.end, clampedStart + 1)); + + return { + start: clampedStart, + end: clampedEnd, + }; +} + +type EventTypes = 'low' | 'mid' | 'high' | 'indents'; +function resolveEventType(groupName: string): EventTypes | undefined { + const normalizedGroup = groupName.toLowerCase(); + if ( + normalizedGroup === 'low' || + normalizedGroup === 'mid' || + normalizedGroup === 'high' || + normalizedGroup === 'indents' + ) { + return normalizedGroup as EventTypes; + } + return undefined; +} + +function updateSeriesValue( + series: ScopeSample[], + eventTime: number, + group: Exclude, + value: number, +) { + const latestEntry = series.length > 0 ? series[series.length - 1] : undefined; + + if (latestEntry && latestEntry.time === eventTime) { + latestEntry[group] = value; + return; + } + + const nextEntry: ScopeSample = { + time: eventTime, + low: latestEntry?.low ?? 0, + mid: latestEntry?.mid ?? 0, + high: latestEntry?.high ?? 0, + }; + nextEntry[group] = value; + series.push(nextEntry); +} + +function trimHistory(historyByPath: Record, cutoffTick: number): Record { + let nextHistoryByPath = historyByPath; + + Object.keys(historyByPath).forEach(pathKey => { + const currentSeries = historyByPath[pathKey]; + const trimmedSeries = currentSeries.filter(sample => sample.time >= cutoffTick); + if (trimmedSeries.length !== currentSeries.length) { + if (nextHistoryByPath === historyByPath) { + nextHistoryByPath = { ...historyByPath }; + } + nextHistoryByPath[pathKey] = trimmedSeries; + } + }); + + return nextHistoryByPath; +} + +function rebuildDisplayPaths( + scopesByPath: Record, + order: string[], +): Record { + const labelStack: string[] = []; + let nextScopesByPath = scopesByPath; + + order.forEach(pathKey => { + const currentScope = nextScopesByPath[pathKey]; + if (!currentScope) { + return; + } + + const normalizedDepth = Math.max(0, currentScope.depth); + labelStack.length = normalizedDepth; + const displayPath = [...labelStack, currentScope.label].join(' > '); + labelStack[normalizedDepth] = currentScope.label; + + if (currentScope.displayPath !== displayPath) { + if (nextScopesByPath === scopesByPath) { + nextScopesByPath = { ...scopesByPath }; + } + + nextScopesByPath[pathKey] = { + ...currentScope, + displayPath, + }; + } + }); + + return nextScopesByPath; +} + +function ensureScopePathKey( + previousState: ProfilerState, + nextScopesByPath: Record, + nextOrder: string[], + label: string, + fallbackIndex: number, +): { + pathKey: string; + nextScopesByPath: Record; + nextOrder: string[]; +} { + const indexedPathKey = nextOrder[fallbackIndex]; + + if (indexedPathKey !== undefined) { + return { pathKey: indexedPathKey, nextScopesByPath, nextOrder }; + } + + const matchedPathKey = nextOrder.find(candidatePathKey => nextScopesByPath[candidatePathKey]?.label === label); + if (matchedPathKey !== undefined) { + return { pathKey: matchedPathKey, nextScopesByPath, nextOrder }; + } + + if (nextOrder === previousState.order) { + nextOrder = [...previousState.order]; + } + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + const pathKey = createFallbackPathKey(label, fallbackIndex); + nextOrder.push(pathKey); + nextScopesByPath[pathKey] = { + pathKey, + label, + displayPath: label, + depth: 0, + order: nextOrder.length - 1, + rawIndent: '', + }; + + return { pathKey, nextScopesByPath, nextOrder }; +} + +function applyEvent(previousState: ProfilerState, event: StatisticUpdatedMessage, tickRange: number): ProfilerState { + if (event.children_string_values.length === 0 && event.values.length === 0) { + return previousState; + } + + const group = resolveEventType(event.group); + if (group === undefined) { + return previousState; + } + + const nextLatestTick = Math.max(previousState.latestTick, event.time); + + let nextScopesByPath = previousState.scopesByPath; + let nextOrder = previousState.order; + let nextHistoryByPath = previousState.historyByPath; + let nextLastIndentTick = previousState.lastIndentTick; + let nextIndentCursor = previousState.indentCursor; + let didChange = false; + + if (group === 'indents') { + if (event.children_string_values.length > 0) { + const nextDescriptors = buildScopeDescriptorsFromIndents(event.children_string_values); + nextScopesByPath = { ...previousState.scopesByPath }; + nextOrder = []; + nextDescriptors.forEach((descriptor, index) => { + nextScopesByPath[descriptor.pathKey] = { ...descriptor, order: index }; + nextOrder.push(descriptor.pathKey); + }); + nextLastIndentTick = event.time; + nextIndentCursor = nextDescriptors.length; + didChange = true; + } else { + const rawIndentValue = event.values.length > 0 ? String(event.values[event.values.length - 1]) : ''; + const label = event.name || event.id; + + if (event.time !== nextLastIndentTick) { + nextLastIndentTick = event.time; + nextIndentCursor = 0; + } + + const ensuredScope = ensureScopePathKey( + previousState, + nextScopesByPath, + nextOrder, + label, + nextIndentCursor, + ); + + const scopeDescriptor = ensuredScope.nextScopesByPath[ensuredScope.pathKey]; + nextScopesByPath = ensuredScope.nextScopesByPath; + nextOrder = ensuredScope.nextOrder; + nextIndentCursor += 1; + + if (scopeDescriptor !== undefined) { + const parsedDepth = parseIndentDepth(rawIndentValue); + if (scopeDescriptor.depth !== parsedDepth || scopeDescriptor.rawIndent !== rawIndentValue) { + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + nextScopesByPath[ensuredScope.pathKey] = { + ...scopeDescriptor, + depth: parsedDepth, + rawIndent: rawIndentValue, + }; + didChange = true; + } + } + } + } else if (event.children_string_values.length > 0) { + event.children_string_values.forEach((row, index) => { + const labelWithIndent = row[0] || `Scope ${index + 1}`; + const parsedLabel = splitLeadingIndent(labelWithIndent); + const label = parsedLabel.label; + const value = parseMetricValue(row[1]); + if (value === undefined) { + return; + } + + const ensuredScope = ensureScopePathKey(previousState, nextScopesByPath, nextOrder, label, index); + const pathKey = ensuredScope.pathKey; + nextScopesByPath = ensuredScope.nextScopesByPath; + nextOrder = ensuredScope.nextOrder; + + const scopeDescriptor = nextScopesByPath[pathKey]; + if (scopeDescriptor !== undefined && scopeDescriptor.label !== label) { + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + nextScopesByPath[pathKey] = { + ...scopeDescriptor, + label, + }; + } + + if (parsedLabel.impliedDepth !== undefined) { + const currentScopeDescriptor = nextScopesByPath[pathKey]; + if (currentScopeDescriptor !== undefined && currentScopeDescriptor.depth !== parsedLabel.impliedDepth) { + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + nextScopesByPath[pathKey] = { + ...currentScopeDescriptor, + depth: parsedLabel.impliedDepth, + }; + } + } + + if (nextHistoryByPath === previousState.historyByPath) { + nextHistoryByPath = { ...previousState.historyByPath }; + } + + const currentSeries = nextHistoryByPath[pathKey] ?? []; + const nextSeries = [...currentSeries]; + updateSeriesValue(nextSeries, event.time, group, value); + + if ( + nextSeries.length >= 2 && + nextSeries[nextSeries.length - 1].time < nextSeries[nextSeries.length - 2].time + ) { + nextSeries.sort((left, right) => left.time - right.time); + } + + nextHistoryByPath[pathKey] = nextSeries; + didChange = true; + }); + } else { + const parsedLabel = splitLeadingIndent(event.name || event.id); + const label = parsedLabel.label; + const ensuredScope = ensureScopePathKey(previousState, nextScopesByPath, nextOrder, label, nextOrder.length); + const pathKey = ensuredScope.pathKey; + nextScopesByPath = ensuredScope.nextScopesByPath; + nextOrder = ensuredScope.nextOrder; + + if (nextHistoryByPath === previousState.historyByPath) { + nextHistoryByPath = { ...previousState.historyByPath }; + } + + const currentSeries = nextHistoryByPath[pathKey] ?? []; + const nextSeries = [...currentSeries]; + + event.values.forEach((rawValue, valueIndex) => { + const value = Number(rawValue); + if (!Number.isFinite(value)) { + return; + } + + const tickOffset = event.values.length - valueIndex - 1; + const eventTime = event.time - tickOffset; + updateSeriesValue(nextSeries, eventTime, group, value); + }); + + if (parsedLabel.impliedDepth !== undefined) { + const scopeDescriptor = nextScopesByPath[pathKey]; + if (scopeDescriptor !== undefined && scopeDescriptor.depth !== parsedLabel.impliedDepth) { + if (nextScopesByPath === previousState.scopesByPath) { + nextScopesByPath = { ...previousState.scopesByPath }; + } + + nextScopesByPath[pathKey] = { + ...scopeDescriptor, + depth: parsedLabel.impliedDepth, + }; + } + } + + if (nextSeries.length >= 2 && nextSeries[nextSeries.length - 1].time < nextSeries[nextSeries.length - 2].time) { + nextSeries.sort((left, right) => left.time - right.time); + } + + nextHistoryByPath[pathKey] = nextSeries; + didChange = true; + } + + const cutoffTick = nextLatestTick - tickRange; + const trimmedHistory = trimHistory(nextHistoryByPath, cutoffTick); + const scopesWithDisplayPaths = rebuildDisplayPaths(nextScopesByPath, nextOrder); + + if ( + !didChange && + trimmedHistory === previousState.historyByPath && + scopesWithDisplayPaths === previousState.scopesByPath && + nextLatestTick === previousState.latestTick && + nextLastIndentTick === previousState.lastIndentTick && + nextIndentCursor === previousState.indentCursor + ) { + return previousState; + } + + return { + scopesByPath: scopesWithDisplayPaths, + order: nextOrder, + historyByPath: trimmedHistory, + latestTick: nextLatestTick, + lastIndentTick: nextLastIndentTick, + indentCursor: nextIndentCursor, + }; +} + +function formatTickDifference(tick: number, latestTick: number): string { + const tickDifference = latestTick - tick; + if (tickDifference < 20) { + return 'now'; + } + + return `${Math.floor(tickDifference / 20)}s`; +} + +function ceilToThreeDecimalPlaces(value: number): number { + return Math.ceil(value * 1000) / 1000; +} + +function clampRatio(value: number): number { + return Math.max(0, Math.min(value, 1)); +} + +function getParentPath(pathKey: string): string | undefined { + const lastSeparatorIndex = pathKey.lastIndexOf('/'); + if (lastSeparatorIndex === -1) { + return undefined; + } + + return pathKey.substring(0, lastSeparatorIndex); +} + +function formatTimingValue(value: number, unit: TimeUnit): string { + if (!Number.isFinite(value)) { + return unit === 'ns' ? '0 ns' : `0.000 ${unit}`; + } + + if (unit === 'ms') { + return `${ceilToThreeDecimalPlaces(value / 1_000_000).toFixed(3)} ms`; + } + + if (unit === 'us') { + return `${ceilToThreeDecimalPlaces(value / 1_000).toFixed(3)} us`; + } + + return `${value} ns`; +} + +function getDepthPaletteColor(depth: number, palette: readonly string[]): string { + return palette[Math.abs(depth) % palette.length]; +} + +function enforceOrderedTickPositions( + lowY: number, + midY: number, + highY: number, + laneBottomY: number, + laneTopY: number, +): { low: number; mid: number; high: number } { + const clampedLow = Math.max(laneBottomY, Math.min(lowY, laneTopY)); + const clampedMid = Math.max(laneBottomY, Math.min(midY, laneTopY)); + const clampedHigh = Math.max(laneBottomY, Math.min(highY, laneTopY)); + + let low = clampedLow; + let mid = Math.max(clampedMid, low + LANE_TICK_MIN_GAP); + let high = Math.max(clampedHigh, mid + LANE_TICK_MIN_GAP); + + if (high > laneTopY) { + const overflow = high - laneTopY; + high -= overflow; + mid -= overflow; + low -= overflow; + } + + if (low < laneBottomY) { + const underflow = laneBottomY - low; + low += underflow; + mid += underflow; + high += underflow; + } + + return { + low, + mid, + high, + }; +} + +function minecraftProfilerFlameStreamChart({ + title, + statisticDataProvider, + tickRange = DEFAULT_TICK_RANGE, + defaultWindowTicks = DEFAULT_WINDOW_TICKS, +}: MinecraftProfilerFlameStreamChartProps): JSX.Element { + const [state, setState] = useState(INITIAL_STATE); + const [maxVisibleDepth, setMaxVisibleDepth] = useState(MAX_DEPTH_ALL); + const [followLatest, setFollowLatest] = useState(true); + const [selectedRange, setSelectedRange] = useState(undefined); + const [chartWidth, setChartWidth] = useState(900); + const [valueScaleMode, setValueScaleMode] = useState('normalized'); + const [timeUnit, setTimeUnit] = useState('us'); + const [laneDisplayMode, setLaneDisplayMode] = useState('midline-only'); + const [labelPaneWidth, setLabelPaneWidth] = useState(DEFAULT_LABEL_PANE_WIDTH); + + const chartHostRef = useRef(null); + const plotContainerRef = useRef(null); + const chartPaneRef = useRef(null); + + useEffect(() => { + const eventHandler = (event: StatisticUpdatedMessage): void => { + setState(previousState => applyEvent(previousState, event, tickRange)); + }; + + statisticDataProvider.registerWindowListener(window); + statisticDataProvider.addSubscriber(eventHandler); + + return () => { + statisticDataProvider.removeSubscriber(eventHandler); + statisticDataProvider.unregisterWindowListener(window); + }; + }, [statisticDataProvider, tickRange]); + + useEffect(() => { + if (typeof ResizeObserver === 'undefined') { + return; + } + + const chartWidthTarget = chartPaneRef.current ?? chartHostRef.current; + if (chartWidthTarget === null) { + return; + } + + const observer = new ResizeObserver(entries => { + const nextWidth = Math.floor(entries[0].contentRect.width); + if (nextWidth > 0) { + setChartWidth(nextWidth); + } + }); + + observer.observe(chartWidthTarget); + + return () => { + observer.disconnect(); + }; + }, []); + + const allScopesInOrder = useMemo(() => { + return state.order + .map(pathKey => state.scopesByPath[pathKey]) + .filter((scope): scope is ScopeDescriptor => scope !== undefined) + .sort((left, right) => left.order - right.order); + }, [state.order, state.scopesByPath]); + + const maxDepth = useMemo(() => { + return allScopesInOrder.reduce((currentMax, scope) => Math.max(currentMax, scope.depth), 0); + }, [allScopesInOrder]); + + const effectiveDepthLimit = useMemo(() => { + if (maxVisibleDepth === MAX_DEPTH_ALL) { + return maxDepth; + } + + return Math.min(maxVisibleDepth, maxDepth); + }, [maxDepth, maxVisibleDepth]); + + const visibleScopes = useMemo(() => { + return allScopesInOrder.filter(scope => scope.depth <= effectiveDepthLimit); + }, [allScopesInOrder, effectiveDepthLimit]); + + const timeDomain = useMemo(() => { + let minTime = Number.POSITIVE_INFINITY; + let maxTime = Number.NEGATIVE_INFINITY; + + visibleScopes.forEach(scope => { + const series = state.historyByPath[scope.pathKey] ?? []; + series.forEach(sample => { + minTime = Math.min(minTime, sample.time); + maxTime = Math.max(maxTime, sample.time); + }); + }); + + if (!Number.isFinite(minTime) || !Number.isFinite(maxTime)) { + return undefined; + } + + return { + min: minTime, + max: maxTime, + }; + }, [state.historyByPath, visibleScopes]); + + useEffect(() => { + if (timeDomain === undefined) { + setSelectedRange(undefined); + return; + } + + const defaultRange: TimeRange = { + start: Math.max(timeDomain.min, timeDomain.max - defaultWindowTicks), + end: timeDomain.max, + }; + + setSelectedRange(previousRange => { + if (previousRange === undefined || followLatest) { + return defaultRange; + } + + return clampTimeRange(previousRange, timeDomain.min, timeDomain.max); + }); + }, [defaultWindowTicks, followLatest, timeDomain]); + + const plotModel = useMemo(() => { + if (selectedRange === undefined || timeDomain === undefined || visibleScopes.length === 0) { + return undefined; + } + + const resolvedRange = clampTimeRange(selectedRange, timeDomain.min, timeDomain.max); + + const filteredSeriesByPath: Record = {}; + const laneMinByPath: Record = {}; + const laneMaxByPath: Record = {}; + const laneNormalizedSpanByPath: Record = {}; + const latestValuesByPath: Record = {}; + const laneLabelByPath: Record = {}; + const useMidlineOnlyNormalization = laneDisplayMode === 'midline-only' && valueScaleMode === 'normalized'; + let globalLaneMaxValue = 0; + + visibleScopes.forEach(scope => { + const series = (state.historyByPath[scope.pathKey] ?? []).filter( + sample => sample.time >= resolvedRange.start && sample.time <= resolvedRange.end, + ); + filteredSeriesByPath[scope.pathKey] = series; + laneLabelByPath[scope.pathKey] = scope.label; + + let laneMin = Number.POSITIVE_INFINITY; + let laneMax = Number.NEGATIVE_INFINITY; + series.forEach(sample => { + if (useMidlineOnlyNormalization) { + laneMin = Math.min(laneMin, sample.mid); + laneMax = Math.max(laneMax, sample.mid); + } else { + const sampleLow = Math.min(sample.low, sample.mid, sample.high); + const sampleHigh = Math.max(sample.low, sample.mid, sample.high); + laneMin = Math.min(laneMin, sampleLow); + laneMax = Math.max(laneMax, sampleHigh); + } + }); + + const resolvedLaneMin = Number.isFinite(laneMin) ? laneMin : 0; + const resolvedLaneMax = Number.isFinite(laneMax) ? laneMax : 0; + + const boundedLaneMax = resolvedLaneMax > 0 ? resolvedLaneMax : 1; + const laneSpan = Math.max(resolvedLaneMax - resolvedLaneMin, MIN_NORMALIZED_LANE_SPAN); + laneMinByPath[scope.pathKey] = resolvedLaneMin; + laneMaxByPath[scope.pathKey] = boundedLaneMax; + laneNormalizedSpanByPath[scope.pathKey] = laneSpan; + globalLaneMaxValue = Math.max(globalLaneMaxValue, boundedLaneMax); + + const latestSample = series.length > 0 ? series[series.length - 1] : undefined; + const latestLow = latestSample ? Math.min(latestSample.low, latestSample.mid, latestSample.high) : 0; + const latestHigh = latestSample ? Math.max(latestSample.low, latestSample.mid, latestSample.high) : 0; + const latestMid = latestSample ? Math.min(latestHigh, Math.max(latestLow, latestSample.mid)) : 0; + + latestValuesByPath[scope.pathKey] = { + low: latestLow, + mid: latestMid, + high: latestHigh, + }; + }); + + const normalizedMaxValue = globalLaneMaxValue > 0 ? globalLaneMaxValue : 1; + const isAbsoluteScale = valueScaleMode === 'absolute'; + const yAxisTimingBands = laneDisplayMode === 'midline-only' ? 'M' : 'L/M/H'; + const rowUsableHeight = ROW_HEIGHT - ROW_PADDING * 2; + const rowCount = visibleScopes.length; + const rowsHeight = rowCount * ROW_HEIGHT; + const childLatestMidSumByParent: Record = {}; + const topChildByParent: Record = {}; + + visibleScopes.forEach(scope => { + const parentPath = getParentPath(scope.pathKey); + if (parentPath === undefined) { + return; + } + + const childLatestMid = latestValuesByPath[scope.pathKey]?.mid ?? 0; + childLatestMidSumByParent[parentPath] = (childLatestMidSumByParent[parentPath] ?? 0) + childLatestMid; + + const currentTopChild = topChildByParent[parentPath]; + if (currentTopChild === undefined || childLatestMid > currentTopChild.mid) { + topChildByParent[parentPath] = { + label: laneLabelByPath[scope.pathKey] ?? scope.label, + mid: childLatestMid, + }; + } + }); + + const laneMetricsByPath: Record = {}; + visibleScopes.forEach(scope => { + const parentPath = getParentPath(scope.pathKey); + const latestValues = latestValuesByPath[scope.pathKey] ?? { low: 0, mid: 0, high: 0 }; + const parentLatestMid = parentPath ? (latestValuesByPath[parentPath]?.mid ?? 0) : 0; + const relativeRatio = clampRatio((laneMaxByPath[scope.pathKey] ?? 1) / normalizedMaxValue); + + laneMetricsByPath[scope.pathKey] = { + pathKey: scope.pathKey, + laneMaxValue: laneMaxByPath[scope.pathKey] ?? 0, + latestLowValue: latestValues.low, + latestMidValue: latestValues.mid, + latestHighValue: latestValues.high, + relativeRatio, + contributionToParentRatio: parentLatestMid > 0 ? clampRatio(latestValues.mid / parentLatestMid) : 0, + childrenBurdenRatio: + latestValues.mid > 0 + ? clampRatio((childLatestMidSumByParent[scope.pathKey] ?? 0) / latestValues.mid) + : 0, + dominantChildLabel: topChildByParent[scope.pathKey]?.label, + }; + }); + + const points: FlameChartPoint[] = []; + const rowTicks: { y: number; label: string }[] = []; + visibleScopes.forEach((scope, rowIndex) => { + const series = filteredSeriesByPath[scope.pathKey] ?? []; + const rowLowerEdge = (rowCount - rowIndex - 1) * ROW_HEIGHT; + const rowBase = rowLowerEdge + ROW_PADDING; + const laneScaleMin = isAbsoluteScale ? 0 : (laneMinByPath[scope.pathKey] ?? 0); + const laneScaleSpan = isAbsoluteScale + ? normalizedMaxValue + : (laneNormalizedSpanByPath[scope.pathKey] ?? MIN_NORMALIZED_LANE_SPAN); + const valueScale = rowUsableHeight / laneScaleSpan; + const laneBottom = rowBase; + const laneTop = rowBase + rowUsableHeight; + const laneFill = getDepthPaletteColor(scope.depth, DEPTH_FILL_PALETTE); + const midStroke = getDepthPaletteColor(scope.depth, DEPTH_MIDLINE_PALETTE); + const latestSample = series.length > 0 ? series[series.length - 1] : undefined; + + const latestLow = latestSample ? Math.min(latestSample.low, latestSample.mid, latestSample.high) : 0; + const latestHigh = latestSample ? Math.max(latestSample.low, latestSample.mid, latestSample.high) : 0; + const latestMid = latestSample ? Math.min(latestHigh, Math.max(latestLow, latestSample.mid)) : 0; + + // Pin L/M/H label anchors to stable lane positions so text doesn't jump as values move. + const pinnedLowY = laneBottom + LANE_TICK_MIN_GAP; + const pinnedMidY = rowBase + rowUsableHeight / 2; + const pinnedHighY = laneTop - LANE_TICK_MIN_GAP; + const orderedTickPositions = enforceOrderedTickPositions( + pinnedLowY, + pinnedMidY, + pinnedHighY, + laneBottom, + laneTop, + ); + + if (laneDisplayMode === 'range-and-midline') { + rowTicks.push({ y: orderedTickPositions.low, label: `L ${formatTimingValue(latestLow, timeUnit)}` }); + rowTicks.push({ y: orderedTickPositions.high, label: `H ${formatTimingValue(latestHigh, timeUnit)}` }); + } + rowTicks.push({ y: orderedTickPositions.mid, label: `M ${formatTimingValue(latestMid, timeUnit)}` }); + + const laneRelativeRatio = laneMetricsByPath[scope.pathKey]?.relativeRatio ?? 0; + + series.forEach(sample => { + const sortedLow = Math.min(sample.low, sample.mid, sample.high); + const sortedHigh = Math.max(sample.low, sample.mid, sample.high); + const sortedMid = Math.min(sortedHigh, Math.max(sortedLow, sample.mid)); + + points.push({ + time: sample.time, + pathKey: scope.pathKey, + label: scope.label, + displayPath: scope.displayPath, + depth: scope.depth, + yLow: rowBase + (sortedLow - laneScaleMin) * valueScale, + yMid: rowBase + (sortedMid - laneScaleMin) * valueScale, + yHigh: rowBase + (sortedHigh - laneScaleMin) * valueScale, + depthBand: `Depth ${scope.depth}`, + laneFill, + midStroke, + laneRelativeRatio, + }); + }); + }); + + return { + points, + rowsHeight, + range: resolvedRange, + rowGuides: Array.from({ length: rowCount + 1 }, (_, index) => index * ROW_HEIGHT), + rowTicks, + yAxisLabel: isAbsoluteScale + ? `Lane timings (absolute global scale, ${yAxisTimingBands}, ${timeUnit})` + : `Lane timings (normalized per lane, ${yAxisTimingBands}, ${timeUnit})`, + yAxisWidth: Math.min( + 220, + Math.max( + 96, + rowTicks.reduce((maxWidth, tick) => Math.max(maxWidth, tick.label.length * 7 + 16), 96), + ), + ), + }; + }, [selectedRange, state.historyByPath, timeDomain, timeUnit, valueScaleMode, laneDisplayMode, visibleScopes]); + + useEffect(() => { + const plotContainer = plotContainerRef.current; + if (plotContainer === null) { + return; + } + + if (plotModel === undefined || plotModel.points.length === 0) { + plotContainer.innerHTML = ''; + return; + } + + const rowTickLabelByY = new Map(); + plotModel.rowTicks.forEach(tick => { + const key = Math.round(tick.y * 1000) / 1000; + const existingLabels = rowTickLabelByY.get(key) ?? []; + if (existingLabels.indexOf(tick.label) === -1) { + existingLabels.push(tick.label); + rowTickLabelByY.set(key, existingLabels); + } + }); + + const plot = Plot.plot({ + className: 'minecraft-profiler-flame-stream-chart', + width: Math.max(600, chartWidth), + height: Math.max(220, plotModel.rowsHeight + 48), + marginTop: 8, + marginBottom: 40, + marginLeft: plotModel.yAxisWidth, + marginRight: 12, + x: { + label: 'Time', + domain: [plotModel.range.start, plotModel.range.end], + grid: true, + tickFormat: (tickValue: number) => formatTickDifference(tickValue, state.latestTick), + }, + y: { + axis: 'left', + domain: [0, plotModel.rowsHeight], + ticks: plotModel.rowTicks.map(tick => tick.y), + tickSize: 0, + label: plotModel.yAxisLabel, + tickFormat: (tickValue: number) => { + const key = Math.round(Number(tickValue) * 1000) / 1000; + return (rowTickLabelByY.get(key) ?? []).join(' '); + }, + }, + marks: [ + Plot.ruleY(plotModel.rowGuides, { + y: guide => guide, + stroke: 'var(--vscode-editorGroup-border)', + strokeOpacity: 0.35, + }), + ...(laneDisplayMode === 'range-and-midline' + ? [ + Plot.areaY(plotModel.points, { + x: 'time', + y: 'yHigh', + y1: 'yLow', + z: 'pathKey', + fill: (point: FlameChartPoint) => point.laneFill, + fillOpacity: (point: FlameChartPoint) => 0.1 + Math.sqrt(point.laneRelativeRatio) * 0.5, + }), + ] + : []), + Plot.lineY(plotModel.points, { + x: 'time', + y: 'yMid', + z: 'pathKey', + stroke: 'var(--vscode-editor-background)', + strokeOpacity: 1, + strokeWidth: 3, + }), + Plot.lineY(plotModel.points, { + x: 'time', + y: 'yMid', + z: 'pathKey', + stroke: (point: FlameChartPoint) => point.midStroke, + strokeOpacity: (point: FlameChartPoint) => 0.5 + point.laneRelativeRatio * 0.5, + strokeWidth: 3, + }), + ], + }); + + removeAllStyleElements(plot); + plotContainer.replaceChildren(plot); + + return () => { + plot.remove(); + }; + }, [chartWidth, laneDisplayMode, plotModel, state.latestTick]); + + const onDepthSliderChanged = useCallback((event: React.FormEvent) => { + const depthValue = Number.parseInt(event.currentTarget.value, 10); + setMaxVisibleDepth(Number.isFinite(depthValue) ? depthValue : MAX_DEPTH_ALL); + }, []); + + const onRangeStartChanged = useCallback( + (event: React.FormEvent) => { + if (timeDomain === undefined) { + return; + } + + const startValue = Number.parseInt(event.currentTarget.value, 10); + if (!Number.isFinite(startValue)) { + return; + } + + setFollowLatest(false); + setSelectedRange(previousRange => { + const baseline = previousRange ?? { start: timeDomain.min, end: timeDomain.max }; + return clampTimeRange({ start: startValue, end: baseline.end }, timeDomain.min, timeDomain.max); + }); + }, + [timeDomain], + ); + + const onRangeEndChanged = useCallback( + (event: React.FormEvent) => { + if (timeDomain === undefined) { + return; + } + + const endValue = Number.parseInt(event.currentTarget.value, 10); + if (!Number.isFinite(endValue)) { + return; + } + + setFollowLatest(false); + setSelectedRange(previousRange => { + const baseline = previousRange ?? { start: timeDomain.min, end: timeDomain.max }; + return clampTimeRange({ start: baseline.start, end: endValue }, timeDomain.min, timeDomain.max); + }); + }, + [timeDomain], + ); + + const onFollowLatestClicked = useCallback(() => { + setFollowLatest(true); + }, []); + + const onShowAllDepthsClicked = useCallback(() => { + setMaxVisibleDepth(MAX_DEPTH_ALL); + }, []); + + const onShowRootDepthClicked = useCallback(() => { + setMaxVisibleDepth(0); + }, []); + + const onNormalizedScaleClicked = useCallback(() => { + setValueScaleMode('normalized'); + }, []); + + const onAbsoluteScaleClicked = useCallback(() => { + setValueScaleMode('absolute'); + }, []); + + const onTimeUnitChanged = useCallback((event: React.ChangeEvent) => { + const nextUnit = event.currentTarget.value; + if (nextUnit === 'ms' || nextUnit === 'us' || nextUnit === 'ns') { + setTimeUnit(nextUnit); + } + }, []); + + const onLaneDisplayModeChanged = useCallback((event: React.ChangeEvent) => { + const nextMode = event.currentTarget.value; + if (nextMode === 'range-and-midline' || nextMode === 'midline-only') { + setLaneDisplayMode(nextMode); + } + }, []); + + const onLabelPaneResizePointerDown = useCallback( + (event: React.PointerEvent) => { + const hostElement = chartHostRef.current; + if (hostElement === null) { + return; + } + + event.preventDefault(); + + const surfaceRect = hostElement.getBoundingClientRect(); + const startX = event.clientX; + const startWidth = labelPaneWidth; + const maxLabelPaneWidth = Math.max(MIN_LABEL_PANE_WIDTH, surfaceRect.width - MIN_CHART_PANE_WIDTH); + + document.body.style.userSelect = 'none'; + document.body.style.cursor = 'col-resize'; + + const onPointerMove = (moveEvent: PointerEvent): void => { + const deltaX = moveEvent.clientX - startX; + const nextWidth = Math.max( + MIN_LABEL_PANE_WIDTH, + Math.min(startWidth + deltaX, Math.floor(maxLabelPaneWidth)), + ); + setLabelPaneWidth(nextWidth); + }; + + const onPointerUp = (): void => { + document.body.style.userSelect = ''; + document.body.style.cursor = ''; + window.removeEventListener('pointermove', onPointerMove); + }; + + window.addEventListener('pointermove', onPointerMove); + window.addEventListener('pointerup', onPointerUp, { once: true }); + }, + [labelPaneWidth], + ); + + const surfaceColumns = useMemo(() => { + return `${Math.round(labelPaneWidth)}px 8px minmax(0, 1fr)`; + }, [labelPaneWidth]); + + return ( +
+

{title}

+
+
+ +
+ + + Follow Latest +
+ + {selectedRange === undefined + ? 'Waiting for profiler scope data' + : `Ticks ${selectedRange.start} - ${selectedRange.end}${followLatest ? ' (auto)' : ''}`} + +
+ +
+ +
+ + All + Root +
+ + {`Depth 0 - ${effectiveDepthLimit} of ${maxDepth}`} + +
+ +
+ +
+ + Normalized + + + Absolute + +
+ + {valueScaleMode === 'normalized' + ? 'Each lane scales to its visible min-max window.' + : 'All lanes share one global vertical scale.'} + +
+ +
+ +
+ +
+
+ +
+ +
+ +
+
+
+ +
+
+ {visibleScopes.map(scope => ( +
+ {scope.label} +
+ ))} +
+
+
+
+
+
+ + {visibleScopes.length === 0 && ( +
+ Waiting for profiler scopes from the selected client. +
+ )} +
+ ); +} + +export default minecraftProfilerFlameStreamChart; diff --git a/webview-ui/src/diagnostics_panel/prefabs/index.tsx b/webview-ui/src/diagnostics_panel/prefabs/index.tsx index e41baff..7781210 100644 --- a/webview-ui/src/diagnostics_panel/prefabs/index.tsx +++ b/webview-ui/src/diagnostics_panel/prefabs/index.tsx @@ -12,6 +12,7 @@ import serverScriptSubscriberCountsTab from './tabs/ServerScriptSubscriberCounts import clientTimingTab from './tabs/ClientTiming'; import clientMemoryTab from './tabs/ClientMemory'; import clientEntitySystemTab from './tabs/ClientEntitySystems'; +import clientProfilerScopesTab from './tabs/ClientCPUProfiler'; import editorNetworkStatsTab from './tabs/EditorNetworkStats'; @@ -26,6 +27,7 @@ export default [ clientTimingTab, clientMemoryTab, clientEntitySystemTab, + clientProfilerScopesTab, dynamicPropertyTab, editorNetworkStatsTab, ]; diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx new file mode 100644 index 0000000..8be956f --- /dev/null +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientCPUProfiler.tsx @@ -0,0 +1,100 @@ +import { useMemo, useState } from 'react'; +import { MultipleStatisticProvider, StatisticUpdatedMessage } from '../../StatisticProvider'; +import { TabPrefab, TabPrefabDataSource, TabPrefabParams } from '../TabPrefab'; +import MinecraftProfilerFlameStreamChart from '../../controls/MinecraftProfilerFlameStreamChart'; +import { + DebuggerRequestResultMessage, + getDebuggerRequestResult, + isDebuggerRequestInFlight, + sendDebuggerRequest, + useDebuggerRequestUpdates, +} from '../../utilities/useDebuggerRequests'; +import { VSCodeButton } from '@vscode/webview-ui-toolkit/react'; + +const DEBUGGER_REQUEST_COMMANDS = [ + { command: 'Start CPU Profiler', label: 'Start' }, + { command: 'Stop CPU Profiler', label: 'Stop' }, +]; + +function lastResultToUserFriendlyString(lastResult: DebuggerRequestResultMessage): string { + if (lastResult.error) { + return `Error: ${lastResult.error}`; + } else if (lastResult.response) { + if (lastResult.response.success) { + return `${lastResult.response.response_message}`; + } else { + return `Failed: ${lastResult.response.response_message}`; + } + } else { + return 'Press Start to Begin Profiling'; + } +} + +function isWhiskerEvent(event: StatisticUpdatedMessage): boolean { + return event.group === 'low' || event.group === 'mid' || event.group === 'high' || event.group === 'indents'; +} + +const StatsTab: TabPrefab = { + name: 'Client - CPU Profiler', + dataSource: TabPrefabDataSource.Client, + content: ({ selectedClient }: TabPrefabParams) => { + useDebuggerRequestUpdates(); + const [lastRequestedCommand, setLastRequestedCommand] = useState(''); + const lastResult: DebuggerRequestResultMessage | undefined = lastRequestedCommand + ? getDebuggerRequestResult(lastRequestedCommand) + : undefined; + + const statisticDataProvider = useMemo( + () => + new MultipleStatisticProvider({ + statisticParentId: new RegExp(`.*${selectedClient}.*whisker.*`), + valuesFilter: event => + isWhiskerEvent(event) && (event.children_string_values.length > 0 || event.values.length > 0), + }), + [selectedClient], + ); + + return ( +
+
+
+

CPU Profiler Controls

+ {DEBUGGER_REQUEST_COMMANDS.map(command => { + const inFlight = isDebuggerRequestInFlight(command.command); + return ( + { + setLastRequestedCommand(command.command); + sendDebuggerRequest(command.command); + }} + style={{ margin: '5px' }} + > + {command.label} + + ); + })} +
+ + {lastResult + ? lastResultToUserFriendlyString(lastResult) + : 'Press Start to Begin Profiling'} + +
+
+
+
+ +
+
+ ); + }, +}; + +export default StatsTab;