From 000197b317edf5952c6207eb33861061c107fbf7 Mon Sep 17 00:00:00 2001 From: Cody Ward Date: Fri, 10 Jul 2026 11:39:13 -0600 Subject: [PATCH] Add position and dimension information to the entity views, via a details section that can be expanded --- webview-ui/src/diagnostics_panel/App.css | 65 +++++ .../MinecraftGroupedStatisticTable.tsx | 107 ++++++++- .../controls/SparklineCell.tsx | 55 ++++- .../prefabs/tabs/ClientEntitySystems.tsx | 222 +++++++++++++++++- 4 files changed, 436 insertions(+), 13 deletions(-) diff --git a/webview-ui/src/diagnostics_panel/App.css b/webview-ui/src/diagnostics_panel/App.css index 4804f79..92556e1 100644 --- a/webview-ui/src/diagnostics_panel/App.css +++ b/webview-ui/src/diagnostics_panel/App.css @@ -535,6 +535,71 @@ main { vertical-align: middle; } +.minecraft-grouped-statistic-child-label { + display: flex; + align-items: center; + min-width: 0; +} + +.minecraft-grouped-statistic-child-key-expandable { + padding-left: 0; +} + +.minecraft-grouped-statistic-row-expanded { + border-bottom: none; +} + +.minecraft-grouped-statistic-detail-row { + background: color-mix(in srgb, var(--vscode-list-activeSelectionBackground) 10%, var(--vscode-editor-background)); +} + +.minecraft-grouped-statistic-detail-row td { + padding: 10px 14px !important; +} + +.minecraft-entity-row-details { + display: flex; + flex-direction: column; + gap: 10px; +} + +.minecraft-entity-row-details-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); + gap: 8px; +} + +.minecraft-entity-row-details-item { + display: flex; + flex-direction: column; + gap: 2px; + min-width: 0; +} + +.minecraft-entity-row-details-label { + font-size: 11px; + color: var(--vscode-descriptionForeground); +} + +.minecraft-entity-row-details-value { + font-size: 12px; + color: var(--vscode-foreground); + font-weight: 600; + overflow-wrap: anywhere; +} + +.minecraft-entity-row-details-chart { + display: flex; + flex-direction: column; + gap: 6px; + overflow-x: auto; +} + +.minecraft-entity-row-details-chart-title { + font-size: 12px; + color: var(--vscode-descriptionForeground); +} + .minecraft-grouped-statistic-row-pinned { background: color-mix(in srgb, var(--vscode-list-highlightForeground) 12%, var(--vscode-editor-background)); } diff --git a/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx b/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx index 0e2f262..96bdf2f 100644 --- a/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx +++ b/webview-ui/src/diagnostics_panel/controls/MinecraftGroupedStatisticTable.tsx @@ -60,6 +60,19 @@ type GroupedStatisticTableRowAction = { width?: string; }; +export type GroupedStatisticTableRowDetailsContext = { + sparklineHistory: number[]; + sparklineDisplayedMin: number; + sparklineDisplayedMax: number; + sparklineTickRange: number; +}; + +export type GroupedStatisticTableRowDetailsRenderer = ( + row: GroupedStatisticTableRow, + groupKey: string, + context: GroupedStatisticTableRowDetailsContext, +) => JSX.Element; + type NonConsolidatedColumnResolver = (event: StatisticUpdatedMessage, valueLabels: string[]) => number | undefined; export type MinecraftGroupedStatisticTableSelectionSnapshot = { @@ -109,6 +122,7 @@ type MinecraftGroupedStatisticTableProps = { sparklineColumnIndex?: number; sparklineTickRange?: number; sparklineValueFormatter?: (value: number) => string; + rowDetailsRenderer?: GroupedStatisticTableRowDetailsRenderer; }; const sortOrderOptions = [ @@ -333,6 +347,10 @@ function getGroupExpansionKey(groupKey: string, isPinnedSection: boolean, isSpli return `${groupKey}_${isPinnedSection ? 'pinned' : 'unpinned'}`; } +function getRowDetailExpansionKey(groupKey: string, rowCategory: string): string { + return `detail:${getRowPinKey(groupKey, rowCategory)}`; +} + function buildGroupedStatisticTableGroup( key: string, rows: GroupedStatisticTableRow[], @@ -442,6 +460,7 @@ const MinecraftGroupedStatisticTable = forwardRef< sparklineColumnIndex = 0, sparklineTickRange = 0, sparklineValueFormatter, + rowDetailsRenderer, }: MinecraftGroupedStatisticTableProps, ref, ): JSX.Element { @@ -466,6 +485,7 @@ const MinecraftGroupedStatisticTable = forwardRef< const [selectedGroups, setSelectedGroups] = useState>(new Set()); const [selectedRows, setSelectedRows] = useState>(new Set()); const [deselectedRowsInSelectedGroups, setDeselectedRowsInSelectedGroups] = useState>(new Set()); + const [expandedRows, setExpandedRows] = useState>(new Set()); const [hasInitializedDefaultSelection, setHasInitializedDefaultSelection] = useState(false); const nonConsolidatedTickCounterRef = useRef(0); const nonConsolidatedLastEventTimeRef = useRef(undefined); @@ -706,6 +726,17 @@ const MinecraftGroupedStatisticTable = forwardRef< [selectedGroups], ); + const onToggleExpandedRow = useCallback( + (groupKey: string, rowCategory: string): void => { + const expansionKey = getRowDetailExpansionKey(groupKey, rowCategory); + + setExpandedRows(previousExpandedRows => { + return handleSetToggle(previousExpandedRows, expansionKey); + }); + }, + [handleSetToggle], + ); + const selectionSnapshot = useMemo((): MinecraftGroupedStatisticTableSelectionSnapshot => { const resolvedRowsByKey = new Map(); @@ -995,11 +1026,13 @@ const MinecraftGroupedStatisticTable = forwardRef< useEffect(() => { const validGroupKeys = new Set(); const validRowKeys = new Set(); + const validExpandedRowKeys = new Set(); data.forEach(row => { const groupKey = groupKeyResolver(row.category); validGroupKeys.add(groupKey); validRowKeys.add(getRowPinKey(groupKey, row.category)); + validExpandedRowKeys.add(getRowDetailExpansionKey(groupKey, row.category)); }); const handleUpdateSet = (set: Set, validKeys: Set): Set => { @@ -1038,6 +1071,10 @@ const MinecraftGroupedStatisticTable = forwardRef< return handleUpdateSet(previousDeselectedRows, validRowKeys); }); + setExpandedRows(previousExpandedRows => { + return handleUpdateSet(previousExpandedRows, validExpandedRowKeys); + }); + const validCategories = new Set(data.map(row => row.category)); validCategories.forEach(category => { rowHistoryMissingTickCountRef.current.delete(category); @@ -1319,17 +1356,27 @@ const MinecraftGroupedStatisticTable = forwardRef< }); }, [defaultCollapsed, groupedData, isGroupedMode]); - const renderLeafRow = (row: GroupedStatisticTableRow, rowKey: string, groupKey: string): JSX.Element => { + const totalColumnCount = + 2 + + valueLabels.length + + (selectionEnabled ? 1 : 0) + + (sparklineColumnIndex !== undefined ? 1 : 0) + + (rowAction ? 1 : 0); + + const renderLeafRow = (row: GroupedStatisticTableRow, rowKey: string, groupKey: string): JSX.Element[] => { const rowPinKey = getRowPinKey(groupKey, row.category); + const rowExpansionKey = getRowDetailExpansionKey(groupKey, row.category); const isPinned = pinnedRows.has(rowPinKey); const isSelected = isRowSelected(groupKey, rowPinKey); + const canExpandRow = rowDetailsRenderer !== undefined; + const isRowExpanded = canExpandRow && expandedRows.has(rowExpansionKey); const sparklineHistory = rowHistoryRef.current.get(row.category) ?? []; const sparklineBounds = getSmoothedSparklineBounds(getRowSparklineSeriesKey(row.category), sparklineHistory); - return ( + const rows: JSX.Element[] = [ )} - - {keyFormatter ? keyFormatter(row.category) : row.category} - +
+ {canExpandRow && ( + + )} + + {keyFormatter ? keyFormatter(row.category) : row.category} + +
{row.values.map((value, valueIndex) => ( @@ -1380,8 +1446,31 @@ const MinecraftGroupedStatisticTable = forwardRef< )} - + , + ]; + + if (!isRowExpanded || !rowDetailsRenderer) { + return rows; + } + + rows.push( + + + {rowDetailsRenderer(row, groupKey, { + sparklineHistory, + sparklineDisplayedMin: sparklineBounds.min, + sparklineDisplayedMax: sparklineBounds.max, + sparklineTickRange, + })} + + , ); + + return rows; }; return ( @@ -1599,7 +1688,7 @@ const MinecraftGroupedStatisticTable = forwardRef< return [ ...rows, - ...group.rows.map((row, rowIndex) => + ...group.rows.flatMap((row, rowIndex) => renderLeafRow( row, `group-${group.expansionKey}-${groupIndex}-row-${row.category}-${rowIndex}`, @@ -1608,7 +1697,7 @@ const MinecraftGroupedStatisticTable = forwardRef< ), ]; }) - : sortedFlatData.map((row, rowIndex) => + : sortedFlatData.flatMap((row, rowIndex) => renderLeafRow( row, `flat-row-${row.category}-${rowIndex}`, diff --git a/webview-ui/src/diagnostics_panel/controls/SparklineCell.tsx b/webview-ui/src/diagnostics_panel/controls/SparklineCell.tsx index a299874..90b8dd6 100644 --- a/webview-ui/src/diagnostics_panel/controls/SparklineCell.tsx +++ b/webview-ui/src/diagnostics_panel/controls/SparklineCell.tsx @@ -7,6 +7,10 @@ type SparklineCellProps = { formatValue?: (value: number) => string; displayedMin?: number; displayedMax?: number; + showYAxisTicks?: boolean; + yAxisTickCount?: number; + yAxisLabelFormatter?: (value: number) => string; + lineStrokeWidth?: number; }; function formatSparklineLabelValue(value: number, formatValue?: (value: number) => string): string { @@ -47,6 +51,10 @@ export function SparklineCell({ formatValue, displayedMin, displayedMax, + showYAxisTicks = false, + yAxisTickCount = 4, + yAxisLabelFormatter, + lineStrokeWidth = 1.5, }: SparklineCellProps): JSX.Element { if (values.length === 0) { return ; @@ -60,11 +68,22 @@ export function SparklineCell({ const max = displayedMax ?? rawMax; const min = displayedMin ?? rawMin; const range = max - min || 1; + const yAxisWidth = showYAxisTicks ? 50 : 0; + const chartLeft = yAxisWidth; + const chartRight = width; + const topPadding = showYAxisTicks ? 6 : 2; + const bottomPadding = showYAxisTicks ? 6 : 2; + const chartTop = topPadding; + const chartBottom = height - bottomPadding; + const chartWidth = Math.max(1, chartRight - chartLeft); + const chartHeight = Math.max(1, chartBottom - chartTop); + const horizontalDivisor = Math.max(1, values.length - 1); + const normalizedTickCount = Math.max(2, yAxisTickCount); const points = values .map((v, i) => { - const x = (i / (values.length - 1)) * width; - const y = height - ((v - min) / range) * (height - 4) - 2; + const x = chartLeft + (i / horizontalDivisor) * chartWidth; + const y = chartBottom - ((v - min) / range) * chartHeight; return `${x},${y}`; }) .join(' '); @@ -77,12 +96,42 @@ export function SparklineCell({ return (
+ {showYAxisTicks && + Array.from({ length: normalizedTickCount }).map((_, index) => { + const ratio = index / (normalizedTickCount - 1); + const tickValue = max - ratio * range; + const tickY = chartTop + ratio * chartHeight; + const tickLabel = formatSparklineLabelValue(tickValue, yAxisLabelFormatter || formatValue); + + return ( + + + + {tickLabel} + + + ); + })} {values.length >= 2 && ( diff --git a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx index 8a6b998..95a3dc0 100644 --- a/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx +++ b/webview-ui/src/diagnostics_panel/prefabs/tabs/ClientEntitySystems.tsx @@ -3,6 +3,8 @@ import { ParentNameStatResolver, StatisticType, YAxisType, createStatResolver } from '../../StatisticResolver'; import { TabPrefab, TabPrefabDataSource, TabPrefabParams } from '../TabPrefab'; import MinecraftGroupedStatisticTable, { + GroupedStatisticTableRow, + GroupedStatisticTableRowDetailsContext, MinecraftGroupedStatisticTableExportRow, MinecraftGroupedStatisticTableColumnAggregation, MinecraftGroupedStatisticTableDisplayMode, @@ -25,6 +27,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { VSCodeButton, VSCodeDropdown, VSCodeOption } from '@vscode/webview-ui-toolkit/react'; import { CsvCellValue, TableCsvExporter } from '../../exporters/TableCsvExporter'; import { sendExportDataRequest } from '../../utilities/exportData'; +import { SparklineCell } from '../../controls/SparklineCell'; type DiagnosticsExportTableType = 'entity' | 'system'; @@ -67,6 +70,14 @@ const EXPORT_CSV_HEADERS = [ type ExportCsvHeader = (typeof EXPORT_CSV_HEADERS)[number]; type ExportCsvRow = Record; +type OptionalEntityStats = { + positionX?: number; + positionY?: number; + positionZ?: number; + dimension?: string; +}; + +const OPTIONAL_ENTITY_STAT_IDS = ['position_x', 'position_y', 'position_z', 'dimension'] as const; function convertTimingValueFromNs(value: number, unit: TimingUnit): number { if (!Number.isFinite(value)) { @@ -135,7 +146,7 @@ function buildExportFileName(tableType: DiagnosticsExportTableType, now: Date): } function getFilterSelectionModeTooltip(ecsVersion: number): string { - if (ecsVersion === 2) { + if (ecsVersion === 2 || ecsVersion === 3) { return ( 'Select Filtering Mode:\n' + '\u2022 "No Filter" will display system and entity timings for all entities and systems.\n' + @@ -230,6 +241,72 @@ function extractEntityId(entityCategory: string): string | undefined { return match?.[1]; } +function resolveLatestNumericEventValue(event: StatisticUpdatedMessage): number | undefined { + if (event.values.length > 0) { + const value = event.values[event.values.length - 1]; + return Number.isFinite(value) ? value : undefined; + } + + const rows = event.children_string_values || []; + for (const row of rows) { + for (let index = row.length - 1; index >= 0; index -= 1) { + const numericValue = Number.parseFloat(row[index]); + if (Number.isFinite(numericValue)) { + return numericValue; + } + } + } + + return undefined; +} + +function resolveLatestStringEventValue(event: StatisticUpdatedMessage): string | undefined { + const rows = event.children_string_values || []; + + for (const row of rows) { + for (let index = row.length - 1; index >= 0; index -= 1) { + const value = row[index]?.trim(); + if (value && Number.isNaN(Number.parseFloat(value))) { + return value; + } + } + } + + if (event.values.length > 0) { + return String(event.values[event.values.length - 1]); + } + + return undefined; +} + +function formatOptionalCoordinate(value: number | undefined): string { + if (value === undefined || !Number.isFinite(value)) { + return 'N/A'; + } + + return value.toFixed(2); +} + +function formatOptionalDimension(value: string | undefined): string { + if (!value || value.trim().length === 0) { + return 'N/A'; + } + + return value; +} + +function formatSparklineDurationFromPoints(points: number): string { + const totalSeconds = points * SPARKLINE_SAMPLE_INTERVAL_SECONDS; + + if (totalSeconds >= 60) { + const minutes = Math.floor(totalSeconds / 60); + const seconds = Math.round(totalSeconds % 60); + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; + } + + return totalSeconds < 10 ? `${totalSeconds.toFixed(1)}s` : `${Math.round(totalSeconds)}s`; +} + function formatStatKey(key: string): string { return key.split('(')[0].trim(); } @@ -263,6 +340,9 @@ const StatsTab: TabPrefab = { const [systemCategoryLegendMap, setSystemCategoryLegendMap] = useState>(new Map()); const [filterSelectionMode, setFilterSelectionMode] = useState('no-filter'); const [availableEntities, setAvailableEntities] = useState<{ id: string; fullName: string }[]>([]); + const [optionalEntityStatsById, setOptionalEntityStatsById] = useState>( + new Map(), + ); const [selectedSingleEntityId, setSelectedSingleEntityId] = useState(undefined); const [filteredEntityCount, setFilteredEntityCount] = useState(undefined); const [filteredSystemCount, setFilteredSystemCount] = useState(undefined); @@ -294,6 +374,17 @@ const StatsTab: TabPrefab = { }); }, [selectedClient]); + const optionalEntityStatsProvider = useMemo(() => { + if (!selectedClient) { + return undefined; + } + + return new MultipleStatisticProvider({ + statisticIds: [...OPTIONAL_ENTITY_STAT_IDS], + statisticParentId: new RegExp(`${selectedClient}_client_ecs_entities`), + }); + }, [selectedClient]); + useEffect(() => { setSystemCategoryLegendMap(new Map()); @@ -351,6 +442,73 @@ const StatsTab: TabPrefab = { }; }, [entityIdsProvider]); + useEffect(() => { + setOptionalEntityStatsById(new Map()); + + if (!optionalEntityStatsProvider) { + return; + } + + const eventHandler = (event: StatisticUpdatedMessage): void => { + const entityId = extractEntityId(event.group_name); + if (!entityId) { + return; + } + + setOptionalEntityStatsById(previousStats => { + const nextStats = new Map(previousStats); + const existing = nextStats.get(entityId) ?? {}; + + switch (event.id) { + case 'position_x': { + const numericValue = resolveLatestNumericEventValue(event); + nextStats.set(entityId, { + ...existing, + positionX: numericValue, + }); + break; + } + case 'position_y': { + const numericValue = resolveLatestNumericEventValue(event); + nextStats.set(entityId, { + ...existing, + positionY: numericValue, + }); + break; + } + case 'position_z': { + const numericValue = resolveLatestNumericEventValue(event); + nextStats.set(entityId, { + ...existing, + positionZ: numericValue, + }); + break; + } + case 'dimension': { + const dimensionValue = resolveLatestStringEventValue(event); + nextStats.set(entityId, { + ...existing, + dimension: dimensionValue, + }); + break; + } + default: + break; + } + + return nextStats; + }); + }; + + optionalEntityStatsProvider.registerWindowListener(window); + optionalEntityStatsProvider.addSubscriber(eventHandler); + + return () => { + optionalEntityStatsProvider.removeSubscriber(eventHandler); + optionalEntityStatsProvider.unregisterWindowListener(window); + }; + }, [optionalEntityStatsProvider]); + useEffect(() => { if (isMountRef.current) { isMountRef.current = false; @@ -411,6 +569,7 @@ const StatsTab: TabPrefab = { systemTimingsTableRef.current?.clearSelection(); systemTimingsTableRef.current?.clearTrendData(); + setOptionalEntityStatsById(new Map()); setSelectedSingleEntityId(undefined); setFilteredEntityCount(undefined); setFilteredSystemCount(undefined); @@ -485,6 +644,66 @@ const StatsTab: TabPrefab = { [csvExporter, entityTimingUnit, systemTimingUnit], ); + const renderEntityRowDetails = useCallback( + ( + row: GroupedStatisticTableRow, + _groupKey: string, + context: GroupedStatisticTableRowDetailsContext, + ): JSX.Element => { + const entityId = extractEntityId(row.category); + const optionalStats = entityId ? optionalEntityStatsById.get(entityId) : undefined; + + return ( +
+
+
+ Position X + + {formatOptionalCoordinate(optionalStats?.positionX)} + +
+
+ Position Y + + {formatOptionalCoordinate(optionalStats?.positionY)} + +
+
+ Position Z + + {formatOptionalCoordinate(optionalStats?.positionZ)} + +
+
+ Dimension + + {formatOptionalDimension(optionalStats?.dimension)} + +
+
+
+
+ Trend ({formatSparklineDurationFromPoints(context.sparklineTickRange)}) +
+ formatTimingValue(value, entityTimingUnit)} + displayedMin={context.sparklineDisplayedMin} + displayedMax={context.sparklineDisplayedMax} + showYAxisTicks={true} + yAxisTickCount={5} + yAxisLabelFormatter={value => formatTimingValue(value, entityTimingUnit)} + lineStrokeWidth={2} + /> +
+
+ ); + }, + [entityTimingUnit, optionalEntityStatsById], + ); + return (
@@ -715,6 +934,7 @@ const StatsTab: TabPrefab = { sparklineColumnIndex={0} sparklineTickRange={trendDurationPoints} sparklineValueFormatter={value => formatTimingValue(value, entityTimingUnit)} + rowDetailsRenderer={ecsVersion >= 3 ? renderEntityRowDetails : undefined} valueFormatter={(value, columnIndex) => { // Timing column if (columnIndex === 0) {