diff --git a/changelog/45.improvement.md b/changelog/45.improvement.md new file mode 100644 index 0000000..55d380f --- /dev/null +++ b/changelog/45.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;