From 026181b262e78c9a7188c55ab22000e794f4d18d Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Mon, 20 Jul 2026 19:53:47 +1000 Subject: [PATCH 1/2] perf(frontend): memoize ensemble chart statistics Wraps the box-whisker grouping/quartile pipeline in EnsembleChart in a single useMemo, so it only recomputes when the data or grouping inputs change instead of on every render. Drops the window-level mousemove subscription that recalculated tooltip side on every pointer move anywhere on the page. The tooltip now derives its side from Recharts' own chart-local coordinate and viewBox, so the useMousePosition hook has no remaining consumers and is removed. Replaces the linear indexOf scan used to align series values by index value in createChartData with a precomputed Map per series, cutting the alignment step from effectively cubic to linear in the number of rows. Adds createChartData coverage for duplicate index values to lock in the first-occurrence semantics the Map lookup preserves from indexOf. --- .../+chart-transform-perf.improvement.md | 1 + .../components/diagnostics/ensembleChart.tsx | 194 ++++++++++-------- .../execution/values/series/utils.test.ts | 19 ++ .../execution/values/series/utils.ts | 13 +- frontend/src/hooks/useMousePosition.ts | 35 ---- 5 files changed, 136 insertions(+), 126 deletions(-) create mode 100644 changelog/+chart-transform-perf.improvement.md delete mode 100644 frontend/src/hooks/useMousePosition.ts diff --git a/changelog/+chart-transform-perf.improvement.md b/changelog/+chart-transform-perf.improvement.md new file mode 100644 index 0000000..55d380f --- /dev/null +++ b/changelog/+chart-transform-perf.improvement.md @@ -0,0 +1 @@ +Memoizes the ensemble chart statistics pipeline, drops the window-level mousemove subscription, and replaces linear series alignment with map lookups. diff --git a/frontend/src/components/diagnostics/ensembleChart.tsx b/frontend/src/components/diagnostics/ensembleChart.tsx index 37f7d6b..30bed47 100644 --- a/frontend/src/components/diagnostics/ensembleChart.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.tsx @@ -19,7 +19,6 @@ import { extractAvailableDimensions, initializeGroupingConfig, } from "@/components/explorer/grouping"; -import useMousePositionAndWidth from "@/hooks/useMousePosition"; import { createScaledTickFormatter } from "../execution/values/series/utils"; // Well-known category orderings for common climate dimensions @@ -139,7 +138,6 @@ export const EnsembleChart = ({ yMax, categoryOrder, }: EnsembleChartProps) => { - const { mousePosition, windowSize } = useMousePositionAndWidth(); const [highlightedPoint, setHighlightedPoint] = useState<{ groupName: string; point: ScalarValue; @@ -163,98 +161,110 @@ export const EnsembleChart = ({ // This prevents odd spacing where only one bar has data at each x-axis position const isSelfHued = hueDimension === groupByDimension; - // First group by the main dimension (x-axis) - const primaryGroupedData = Object.groupBy( - data, - (d: ScalarValue) => d.dimensions[groupByDimension] ?? metricName, - ); + // Group by the main dimension (x-axis), then by hue within each group, + // and compute box-whisker statistics for each subgroup. + const sortedChartData = useMemo(() => { + const primaryGroupedData = Object.groupBy( + data, + (d: ScalarValue) => d.dimensions[groupByDimension] ?? metricName, + ); + + const chartData = Object.entries(primaryGroupedData).map( + ( + [groupName, values]: [string, ScalarValue[] | undefined], + categoryIndex, + ) => { + if (!values || values.length === 0) { + return { + name: groupName, + groups: {}, + __outliers: {}, + __rawData: [], + __categoryColor: isSelfHued + ? COLORS[categoryIndex % COLORS.length] + : undefined, + }; + } + + // If hue dimension is specified (and different from groupBy), create sub-groups + let subGroups: { [key: string]: ScalarValue[] }; + if (!isSelfHued && hueDimension && hueDimension !== "none") { + // Normal hue: create sub-groups based on hue dimension + subGroups = Object.groupBy( + values, + (d: ScalarValue) => d.dimensions[hueDimension] ?? "Unknown", + ) as { [key: string]: ScalarValue[] }; + } else { + // Single group if no hue dimension or hue === groupBy + subGroups = { ensemble: values }; + } + + const groups: { [key: string]: GroupStatistics | null } = {}; + const outliers: { [key: string]: number } = {}; + const allRawData: ScalarValue[] = []; + + Object.entries(subGroups).forEach(([subGroupName, subGroupValues]) => { + const allValues: number[] = + subGroupValues + ?.map((d: ScalarValue) => Number(d.value)) + ?.filter((v: number) => Number.isFinite(v)) ?? []; + + const filteredValues: number[] = allValues + .filter( + (v: number) => + (clipMin === undefined || v >= clipMin) && + (clipMax === undefined || v <= clipMax), + ) + .sort((a: number, b: number) => a - b); + + allRawData.push(...(subGroupValues || [])); + + if (filteredValues.length === 0) { + groups[subGroupName] = null; + outliers[subGroupName] = 0; + } else { + const min = d3.min(filteredValues)!; + const max = d3.max(filteredValues)!; + const q1 = d3.quantile(filteredValues, 0.25)!; + const median = d3.median(filteredValues)!; + const q3 = d3.quantile(filteredValues, 0.75)!; + + groups[subGroupName] = { + min, + lowerQuartile: q1, + median, + upperQuartile: q3, + max, + values: filteredValues, + }; + outliers[subGroupName] = allValues.length - filteredValues.length; + } + }); - const chartData = Object.entries(primaryGroupedData).map( - ( - [groupName, values]: [string, ScalarValue[] | undefined], - categoryIndex, - ) => { - if (!values || values.length === 0) { return { name: groupName, - groups: {}, - __outliers: {}, - __rawData: [], + groups, + __outliers: outliers, + __rawData: allRawData, __categoryColor: isSelfHued ? COLORS[categoryIndex % COLORS.length] : undefined, }; - } - - // If hue dimension is specified (and different from groupBy), create sub-groups - let subGroups: { [key: string]: ScalarValue[] }; - if (!isSelfHued && hueDimension && hueDimension !== "none") { - // Normal hue: create sub-groups based on hue dimension - subGroups = Object.groupBy( - values, - (d: ScalarValue) => d.dimensions[hueDimension] ?? "Unknown", - ) as { [key: string]: ScalarValue[] }; - } else { - // Single group if no hue dimension or hue === groupBy - subGroups = { ensemble: values }; - } + }, + ); - const groups: { [key: string]: GroupStatistics | null } = {}; - const outliers: { [key: string]: number } = {}; - const allRawData: ScalarValue[] = []; - - Object.entries(subGroups).forEach(([subGroupName, subGroupValues]) => { - const allValues: number[] = - subGroupValues - ?.map((d: ScalarValue) => Number(d.value)) - ?.filter((v: number) => Number.isFinite(v)) ?? []; - - const filteredValues: number[] = allValues - .filter( - (v: number) => - (clipMin === undefined || v >= clipMin) && - (clipMax === undefined || v <= clipMax), - ) - .sort((a: number, b: number) => a - b); - - allRawData.push(...(subGroupValues || [])); - - if (filteredValues.length === 0) { - groups[subGroupName] = null; - outliers[subGroupName] = 0; - } else { - const min = d3.min(filteredValues)!; - const max = d3.max(filteredValues)!; - const q1 = d3.quantile(filteredValues, 0.25)!; - const median = d3.median(filteredValues)!; - const q3 = d3.quantile(filteredValues, 0.75)!; - - groups[subGroupName] = { - min, - lowerQuartile: q1, - median, - upperQuartile: q3, - max, - values: filteredValues, - }; - outliers[subGroupName] = allValues.length - filteredValues.length; - } - }); - - return { - name: groupName, - groups, - __outliers: outliers, - __rawData: allRawData, - __categoryColor: isSelfHued - ? COLORS[categoryIndex % COLORS.length] - : undefined, - }; - }, - ); - - // Sort categories using explicit order or well-known orderings (e.g., seasons) - const sortedChartData = sortCategories(chartData, categoryOrder); + // Sort categories using explicit order or well-known orderings (e.g., seasons) + return sortCategories(chartData, categoryOrder); + }, [ + data, + groupByDimension, + hueDimension, + isSelfHued, + metricName, + clipMin, + clipMax, + categoryOrder, + ]); // Get all unique group names for rendering multiple bars const allGroupNames = useMemo(() => { @@ -378,7 +388,7 @@ export const EnsembleChart = ({ wrapperStyle={{ zIndex: 1000 }} animationDuration={500} offset={20} - content={({ active, payload, label, coordinate }) => { + content={({ active, payload, label, coordinate, viewBox }) => { if (!active || !payload || payload.length === 0) { // Clear highlight when tooltip is not active if (highlightedPoint) { @@ -479,9 +489,13 @@ export const EnsembleChart = ({ } let side = "right"; - - if (mousePosition.x > windowSize.width / 2 + 20) { - // If mouse is on the right half of the screen, show tooltip on the left + const chartWidth = viewBox?.width ?? 0; + if ( + coordinate?.x !== undefined && + chartWidth > 0 && + coordinate.x > chartWidth / 2 + ) { + // If cursor is on the right half of the chart, show tooltip on the left side = "left"; } diff --git a/frontend/src/components/execution/values/series/utils.test.ts b/frontend/src/components/execution/values/series/utils.test.ts index 6c1ca64..262bb04 100644 --- a/frontend/src/components/execution/values/series/utils.test.ts +++ b/frontend/src/components/execution/values/series/utils.test.ts @@ -569,6 +569,25 @@ describe("createChartData index alignment", () => { expect(rowByStep.get(1)?.series_1 ?? null).toBeNull(); }); + it("uses the first occurrence when a series' index has duplicate values", () => { + const seriesWithDuplicateIndex: SeriesValue = { + id: 48, + execution_group_id: 100, + execution_id: 248, + dimensions: { source_id: "ModelA", metric: "rmse" }, + // Index value 2021 appears twice; alignment must resolve to the first + // occurrence (value 2.0), matching Array.prototype.indexOf semantics. + values: [1.0, 2.0, 20.0, 3.0], + index: [2020, 2021, 2021, 2022], + index_name: "year", + }; + const result = createChartData([seriesWithDuplicateIndex], []); + const rowByYear = new Map( + result.chartData.map((row) => [row.year as number, row]), + ); + expect(rowByYear.get(2021)).toMatchObject({ series_0: 2.0 }); + }); + it("still renders an index-less series when mixed with an indexed series", () => { const indexed: SeriesValue = { id: 46, diff --git a/frontend/src/components/execution/values/series/utils.ts b/frontend/src/components/execution/values/series/utils.ts index c141377..5955cf1 100644 --- a/frontend/src/components/execution/values/series/utils.ts +++ b/frontend/src/components/execution/values/series/utils.ts @@ -275,6 +275,17 @@ export function createChartData( }; }); + // Precompute a value-to-position lookup per series so alignment below is a + // map lookup rather than a linear scan of the series' index. Ties keep the + // first occurrence, matching indexOf's semantics. + const positionByIndexValue = allSeries.map((series) => { + const map = new Map(); + series.index?.forEach((v, i) => { + if (!map.has(v)) map.set(v, i); + }); + return map; + }); + // Build chart data: one row per index value, in ascending order. const chartData: ChartDataPoint[] = indexValueOrder.map( (rawIndex, rowIdx) => { @@ -295,7 +306,7 @@ export function createChartData( // when other series in the chart carry an index. const dataIdx = series.index && series.index.length > 0 - ? series.index.indexOf(rawIndex) + ? (positionByIndexValue[seriesIdx].get(rawIndex) ?? -1) : rowIdx; if (dataIdx !== -1 && dataIdx < series.values.length) { dataPoint[`series_${seriesIdx}`] = series.values[dataIdx]; diff --git a/frontend/src/hooks/useMousePosition.ts b/frontend/src/hooks/useMousePosition.ts deleted file mode 100644 index 0964e6e..0000000 --- a/frontend/src/hooks/useMousePosition.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { useEffect, useState } from "react"; - -function useMousePositionAndWidth() { - const [mousePosition, setMousePosition] = useState({ x: 0, y: 0 }); - const [windowSize, setWindowSize] = useState({ width: 0, height: 0 }); - - useEffect(() => { - const handleMouseMove = (event: MouseEvent) => { - setMousePosition({ x: event.clientX, y: event.clientY }); - }; - - const handleResize = () => { - setWindowSize({ width: window.innerWidth, height: window.innerHeight }); - }; - - // Add event listener for mouse movement on the window - window.addEventListener("mousemove", handleMouseMove); - - // Add event listener for window resize to update element width - window.addEventListener("resize", handleResize); - - // Initial width measurement - handleResize(); - - // Cleanup function to remove event listeners - return () => { - window.removeEventListener("mousemove", handleMouseMove); - window.removeEventListener("resize", handleResize); - }; - }, []); - - return { mousePosition, windowSize }; -} - -export default useMousePositionAndWidth; From 826a4949e6f15f667c0d3acd0af4ee527e44ffed Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Mon, 20 Jul 2026 19:55:39 +1000 Subject: [PATCH 2/2] docs(changelog): number the fragment for PR #45 --- .../{+chart-transform-perf.improvement.md => 45.improvement.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename changelog/{+chart-transform-perf.improvement.md => 45.improvement.md} (100%) diff --git a/changelog/+chart-transform-perf.improvement.md b/changelog/45.improvement.md similarity index 100% rename from changelog/+chart-transform-perf.improvement.md rename to changelog/45.improvement.md