Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions changelog/45.improvement.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Memoizes the ensemble chart statistics pipeline, drops the window-level mousemove subscription, and replaces linear series alignment with map lookups.
194 changes: 104 additions & 90 deletions frontend/src/components/diagnostics/ensembleChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -139,7 +138,6 @@ export const EnsembleChart = ({
yMax,
categoryOrder,
}: EnsembleChartProps) => {
const { mousePosition, windowSize } = useMousePositionAndWidth();
const [highlightedPoint, setHighlightedPoint] = useState<{
groupName: string;
point: ScalarValue;
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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) {
Expand Down Expand Up @@ -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";
}

Expand Down
19 changes: 19 additions & 0 deletions frontend/src/components/execution/values/series/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
13 changes: 12 additions & 1 deletion frontend/src/components/execution/values/series/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | number, number>();
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) => {
Expand All @@ -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];
Expand Down
35 changes: 0 additions & 35 deletions frontend/src/hooks/useMousePosition.ts

This file was deleted.

Loading