From a0ce588ea834dabc2831a34de7b5842f38abe686 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Mon, 7 Sep 2026 17:41:47 +1000 Subject: [PATCH 1/4] fix: correct chart selection and explorer layout --- .../static/collections/1-4_sst-sss-bias.yaml | 4 +- .../ensembleChart.interaction.test.tsx | 101 +++++++++++ .../components/diagnostics/ensembleChart.tsx | 167 ++++++------------ .../execution/values/boxWhiskerShape.test.tsx | 50 ++++++ .../execution/values/boxWhiskerShape.tsx | 67 ++++--- .../src/components/execution/values/types.ts | 1 + .../components/explorer/explorerCardGroup.tsx | 4 +- .../explorer/explorerThemeLayout.tsx | 6 +- 8 files changed, 254 insertions(+), 146 deletions(-) create mode 100644 frontend/src/components/diagnostics/ensembleChart.interaction.test.tsx create mode 100644 frontend/src/components/execution/values/boxWhiskerShape.test.tsx diff --git a/backend/static/collections/1-4_sst-sss-bias.yaml b/backend/static/collections/1-4_sst-sss-bias.yaml index 5a8a93f1..f383b1e8 100644 --- a/backend/static/collections/1-4_sst-sss-bias.yaml +++ b/backend/static/collections/1-4_sst-sss-bias.yaml @@ -101,9 +101,9 @@ explorer_cards: - type: taylor-diagram provider: ilamb diagnostic: so-woa2023-surface - title: "Taylor Diagram" + title: "Sea Surface Salinity (Taylor Diagram)" description: >- - Taylor diagram showing the performance of global surface ocean salinity + Taylor diagram showing the performance of global sea surface salinity against WOA-23 observations. interpretation: >- Points closer to the reference (black square) indicate better model diff --git a/frontend/src/components/diagnostics/ensembleChart.interaction.test.tsx b/frontend/src/components/diagnostics/ensembleChart.interaction.test.tsx new file mode 100644 index 00000000..5e7381c2 --- /dev/null +++ b/frontend/src/components/diagnostics/ensembleChart.interaction.test.tsx @@ -0,0 +1,101 @@ +import { fireEvent, render } from "@testing-library/react"; +import { cloneElement, type ReactElement } from "react"; +import { describe, expect, it, vi } from "vitest"; +import { EnsembleChart } from "./ensembleChart"; + +vi.mock("recharts", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + ResponsiveContainer: ({ + children, + }: { + children: ReactElement<{ width: number; height: number }>; + }) => cloneElement(children, { width: 800, height: 500 }), + }; +}); + +let nextId = 0; +const point = (value: number, category: string, hue: string) => ({ + id: nextId++, + execution_group_id: 1, + execution_id: 1, + value, + dimensions: { category, hue }, +}); +function chart(data: ReturnType[], clipMax?: number) { + return render( + , + ); +} +function markers(container: HTMLElement) { + return [...container.querySelectorAll("[data-box-point]")]; +} +function hover(container: HTMLElement, target: SVGGElement) { + // jsdom does not lay out SVG. Give each rendered marker a screen position. + for (const [index, marker] of markers(container).entries()) { + vi.spyOn(marker, "getBoundingClientRect").mockReturnValue({ + left: marker === target ? 295 : 100 + index * 10, + top: marker === target ? 195 : 100, + width: 10, + height: 10, + } as DOMRect); + } + fireEvent.mouseMove(container.querySelector(".recharts-wrapper")!, { + clientX: 300, + clientY: 200, + }); +} + +describe("box plot selection", () => { + it("selects the actual marker even when another subgroup median is closer", () => { + const { container, getByText } = chart([ + point(10, "one", "A"), + point(10, "one", "A"), + point(90, "one", "A"), + point(80, "one", "B"), + ]); + const target = markers(container).find( + (m) => m.dataset.group === "A" && m.dataset.boxPoint === "2", + )!; + hover(container, target); + expect(target.querySelector("path")).toHaveAttribute("stroke", "#EF4444"); + expect(getByText("Closest Data Point")).toBeInTheDocument(); + expect(container.querySelectorAll('[stroke="#EF4444"]')).toHaveLength(1); + fireEvent.mouseLeave(container.querySelector(".recharts-wrapper")!); + expect(container.querySelectorAll('[stroke="#EF4444"]')).toHaveLength(0); + }); + it("only highlights the selected record across duplicate values and categories", () => { + const { container } = chart([ + point(10, "one", "A"), + point(10, "one", "A"), + point(10, "two", "A"), + ]); + hover(container, markers(container)[1]); + expect(container.querySelectorAll('[stroke="#EF4444"]')).toHaveLength(1); + expect(markers(container)[1].querySelector("path")).toHaveAttribute( + "stroke", + "#EF4444", + ); + }); + it("excludes clipped and non-finite records from selectable markers", () => { + const { container } = chart( + [ + point(50, "one", "A"), + point(101, "one", "A"), + point(Number.NaN, "one", "A"), + ], + 100, + ); + expect(markers(container)).toHaveLength(1); + hover(container, markers(container)[0]); + expect(container.querySelectorAll('[stroke="#EF4444"]')).toHaveLength(1); + expect(container.textContent).not.toContain("101"); + }); +}); diff --git a/frontend/src/components/diagnostics/ensembleChart.tsx b/frontend/src/components/diagnostics/ensembleChart.tsx index edca411f..b08d380b 100644 --- a/frontend/src/components/diagnostics/ensembleChart.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.tsx @@ -119,6 +119,7 @@ interface GroupStatistics { upperQuartile: number; max: number; values: number[]; + points: ScalarValue[]; } export const EmptyEnsembleChart = () => { @@ -155,6 +156,7 @@ export const EnsembleChart = ({ categoryOrder, }: EnsembleChartProps) => { const [highlightedPoint, setHighlightedPoint] = useState<{ + categoryName: string; groupName: string; point: ScalarValue; } | null>(null); @@ -195,7 +197,6 @@ export const EnsembleChart = ({ name: groupName, groups: {}, __outliers: {}, - __rawData: [], __categoryColor: isSelfHued ? groupColor(groupByDimension, groupName, categoryIndex) : undefined, @@ -217,7 +218,6 @@ export const EnsembleChart = ({ const groups: { [key: string]: GroupStatistics | null } = {}; const outliers: { [key: string]: number } = {}; - const allRawData: ScalarValue[] = []; Object.entries(subGroups).forEach(([subGroupName, subGroupValues]) => { const allValues: number[] = @@ -225,15 +225,15 @@ export const EnsembleChart = ({ ?.map((d: ScalarValue) => Number(d.value)) ?.filter((v: number) => Number.isFinite(v)) ?? []; - const filteredValues: number[] = allValues + const points = subGroupValues + .filter((d) => Number.isFinite(Number(d.value))) .filter( - (v: number) => - (clipMin === undefined || v >= clipMin) && - (clipMax === undefined || v <= clipMax), + (d) => + (clipMin === undefined || Number(d.value) >= clipMin) && + (clipMax === undefined || Number(d.value) <= clipMax), ) - .sort((a: number, b: number) => a - b); - - allRawData.push(...(subGroupValues || [])); + .sort((a, b) => Number(a.value) - Number(b.value)); + const filteredValues = points.map((point) => Number(point.value)); if (filteredValues.length === 0) { groups[subGroupName] = null; @@ -252,6 +252,7 @@ export const EnsembleChart = ({ upperQuartile: q3, max, values: filteredValues, + points, }; outliers[subGroupName] = allValues.length - filteredValues.length; } @@ -261,7 +262,6 @@ export const EnsembleChart = ({ name: groupName, groups, __outliers: outliers, - __rawData: allRawData, __categoryColor: isSelfHued ? groupColor(groupByDimension, groupName, categoryIndex) : undefined, @@ -365,6 +365,43 @@ export const EnsembleChart = ({ data={sortedChartData} margin={{ top: marginTop, right: 24, left: 12, bottom: marginBottom }} barCategoryGap={barCategoryGap} + onMouseLeave={() => setHighlightedPoint(null)} + onMouseMove={(state, event) => { + if (!state.isTooltipActive) { + setHighlightedPoint(null); + return; + } + let nearest: typeof highlightedPoint = null; + let distance = Number.POSITIVE_INFINITY; + for (const marker of ( + event.currentTarget as HTMLElement + ).querySelectorAll("[data-box-point]")) { + const bounds = marker.getBoundingClientRect(); + const dx = event.clientX - (bounds.left + bounds.width / 2); + const dy = event.clientY - (bounds.top + bounds.height / 2); + const nextDistance = dx * dx + dy * dy; + if (nextDistance >= distance) continue; + const categoryName = marker.dataset.category!; + const groupName = marker.dataset.group!; + const datum = sortedChartData.find( + (d) => d.name === categoryName, + ); + const point = + datum?.groups[groupName]?.points[ + Number(marker.dataset.boxPoint) + ]; + if (!point) continue; + distance = nextDistance; + nearest = { categoryName, groupName, point }; + } + setHighlightedPoint((previous) => + previous?.point === nearest?.point && + previous?.categoryName === nearest?.categoryName && + previous?.groupName === nearest?.groupName + ? previous + : nearest, + ); + }} > { - if (!active || !payload || payload.length === 0) { - // Clear highlight when tooltip is not active - if (highlightedPoint) { - setHighlightedPoint(null); - } + content={({ active, coordinate, viewBox }) => { + if (!active || !highlightedPoint) return null; + const datum = sortedChartData.find( + (d) => d.name === highlightedPoint.categoryName, + ); + const statsKey = highlightedPoint.groupName; + const groupStats = datum?.groups[statsKey]; + if (!groupStats?.points.includes(highlightedPoint.point)) return null; - } - const datum = payload[0].payload ?? {}; - - // Determine which subgroup we're hovering over - let statsKey: string; - - if ( - allGroupNames.length > 1 && - coordinate && - payload.length > 0 - ) { - // find closest by Y position - let closestBar: string | null = null; - let minDistance = Number.POSITIVE_INFINITY; - - for (const groupName of allGroupNames) { - const groupData = datum?.groups?.[groupName]; - if (groupData) { - const medianY = scale(groupData.median); - const distance = Math.abs((coordinate.y ?? 0) - medianY); - if (distance < minDistance) { - minDistance = distance; - closestBar = groupName; - } - } - } - statsKey = closestBar || "ensemble"; - } else { - // Single bar - use ensemble or first available group - statsKey = "ensemble"; - const groupKeys = Object.keys(datum?.groups || {}); - if (groupKeys.length > 0 && !datum?.groups?.[statsKey]) { - statsKey = groupKeys[0]; - } - } - - const groupStats = datum?.groups?.[ - statsKey - ] as GroupStatistics | null; const outliers = datum?.__outliers; - const allRawData: ScalarValue[] = datum?.__rawData ?? []; - - // Filter raw data to only include points from the hovered subgroup - const rawData = allRawData.filter((d) => { - // For multi-hue charts, filter by the hovered subgroup - if ( - !isSelfHued && - hueDimension && - hueDimension !== "none" && - statsKey !== "ensemble" - ) { - return d.dimensions[hueDimension] === statsKey; - } - // For self-hued or no-hue charts, include all data - return true; - }); - - // Find closest data point to mouse position (within the filtered data) - let closestDataPoint: ScalarValue | null = null; - if (coordinate && rawData.length > 0) { - const mouseY = coordinate.y ?? 0; - let minDistance = Number.POSITIVE_INFINITY; - - // Chart dimensions accounting for margins - for (const dataPoint of rawData) { - const value = Number(dataPoint.value); - if (Number.isFinite(value)) { - // Convert value to pixel position using the same scale as Recharts - const valueY = scale(value); - - const distance = Math.abs(mouseY - valueY); - if (distance < minDistance) { - minDistance = distance; - closestDataPoint = dataPoint; - } - } - } - } - - // Update highlighted point - if ( - closestDataPoint !== highlightedPoint?.point && - closestDataPoint !== null - ) { - setHighlightedPoint({ - groupName: statsKey, - point: closestDataPoint, - }); - } + const closestDataPoint = highlightedPoint.point; + const label = highlightedPoint.categoryName; if (coordinate === undefined) { return null; } @@ -638,11 +591,7 @@ export const EnsembleChart = ({ } /> diff --git a/frontend/src/components/execution/values/boxWhiskerShape.test.tsx b/frontend/src/components/execution/values/boxWhiskerShape.test.tsx new file mode 100644 index 00000000..a05b25b4 --- /dev/null +++ b/frontend/src/components/execution/values/boxWhiskerShape.test.tsx @@ -0,0 +1,50 @@ +import { render } from "@testing-library/react"; +import { scaleLinear } from "d3-scale"; +import { describe, expect, it } from "vitest"; +import { BoxWhiskerShape } from "./boxWhiskerShape"; + +describe("box whiskers", () => { + it("extends outward to observations within the fences, leaving outliers as markers", () => { + const values = [0, 10, 20, 30, 40, 50, 100]; + const scale = scaleLinear().domain([0, 100]).range([500, 0]); + const { container } = render( + + Box plot + ({ + id: value, + execution_id: 1, + execution_group_id: 1, + value, + dimensions: {}, + })), + }, + }, + }} + /> + , + ); + const lines = container.querySelectorAll("line"); + // Q1=15 and Q3=45 give fences -30 and 90. Whiskers end at 0 and 50. + expect(Number(lines[1].getAttribute("y1"))).toBe(scale(0)); + expect(Number(lines[1].getAttribute("y2"))).toBe(scale(15)); + expect(Number(lines[2].getAttribute("y1"))).toBe(scale(45)); + expect(Number(lines[2].getAttribute("y2"))).toBe(scale(50)); + expect(container.querySelectorAll("[data-box-point]")).toHaveLength(7); + }); +}); diff --git a/frontend/src/components/execution/values/boxWhiskerShape.tsx b/frontend/src/components/execution/values/boxWhiskerShape.tsx index 7ba0181b..253464ff 100644 --- a/frontend/src/components/execution/values/boxWhiskerShape.tsx +++ b/frontend/src/components/execution/values/boxWhiskerShape.tsx @@ -6,7 +6,11 @@ import type { ProcessedGroupedDataEntry } from "./types"; interface BoxWhiskerShapeProps { prefix: string; scale: ScaleLinear; - highlightedPoint?: ScalarValue | null; + highlightedPoint?: { + categoryName: string; + groupName: string; + point: ScalarValue; + } | null; // Standard Recharts props provided to shapes x?: number; @@ -74,17 +78,20 @@ export function BoxWhiskerShape({ const effectiveFill = (payload as any).__categoryColor || fill; const effectiveStroke = stroke ? stroke : darkenHex(effectiveFill, 50); - const { lowerQuartile, median, upperQuartile, values } = + const categoryName = payload.name; + const { lowerQuartile, median, upperQuartile, values, points } = payload.groups[prefix]; // Calculate pixel coordinates for each value const yQ1 = scale(lowerQuartile) as number; const yMedian = scale(median) as number; const yQ3 = scale(upperQuartile) as number; - const iqr = yQ3 - yQ1; - - const yUpperBar = yQ3 - iqr * 1.5; - const yLowerBar = yQ1 + iqr * 1.5; + const iqr = upperQuartile - lowerQuartile; + const lowerFence = lowerQuartile - 1.5 * iqr; + const upperFence = upperQuartile + 1.5 * iqr; + const inliers = values.filter((v) => v >= lowerFence && v <= upperFence); + const yUpperBar = scale(Math.max(...inliers)); + const yLowerBar = scale(Math.min(...inliers)); const whiskerX = x + width / 2; // Center X for vertical lines const crossWidth = 10; // Center X for cross lines @@ -96,14 +103,11 @@ export function BoxWhiskerShape({ ? darkenHex(color, 30) : color; - // Get the highlighted value if it exists and matches this group - const highlightedValue = highlightedPoint - ? Number(highlightedPoint.value) - : null; - return values.map((v: number, idx: number) => { const isHighlighted = - highlightedValue !== null && Math.abs(v - highlightedValue) < 0.0001; + highlightedPoint?.categoryName === categoryName && + highlightedPoint?.groupName === prefix && + highlightedPoint?.point === points[idx]; const crossSize = isHighlighted ? crossWidth * 1.5 : crossWidth; const crossStroke = isHighlighted ? "#EF4444" : crossColor; const crossStrokeWidth = isHighlighted ? strokeWidth * 2 : strokeWidth; @@ -112,23 +116,28 @@ export function BoxWhiskerShape({ if (scaleV === undefined || !Number.isFinite(scaleV)) return null; // Skip non-finite values return ( - + + + ); }); } diff --git a/frontend/src/components/execution/values/types.ts b/frontend/src/components/execution/values/types.ts index d885e680..5a218e3f 100644 --- a/frontend/src/components/execution/values/types.ts +++ b/frontend/src/components/execution/values/types.ts @@ -28,6 +28,7 @@ export type BoxPlot = { upperQuartile: number; max: number; values: number[]; + points: ScalarValue[]; }; export type GroupedRawDataEntry = { diff --git a/frontend/src/components/explorer/explorerCardGroup.tsx b/frontend/src/components/explorer/explorerCardGroup.tsx index e00a5cb1..53068b6c 100644 --- a/frontend/src/components/explorer/explorerCardGroup.tsx +++ b/frontend/src/components/explorer/explorerCardGroup.tsx @@ -12,7 +12,7 @@ interface ExplorerCardGroupProps { export function ExplorerCardGroup({ card }: ExplorerCardGroupProps) { return ( -
+ <> {card.content.map((contentItem) => ( ))} -
+ ); } diff --git a/frontend/src/components/explorer/explorerThemeLayout.tsx b/frontend/src/components/explorer/explorerThemeLayout.tsx index 13b09522..7c854804 100644 --- a/frontend/src/components/explorer/explorerThemeLayout.tsx +++ b/frontend/src/components/explorer/explorerThemeLayout.tsx @@ -46,11 +46,9 @@ export const ExplorerThemeLayout = ({ plainLanguage={plainLanguage} /> -
+
{group.cards.map((card) => ( -
- -
+ ))}
From ca69b2ddb5f8a117aff9aefed1b0fce83cbeea62 Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Mon, 7 Sep 2026 17:42:49 +1000 Subject: [PATCH 2/4] docs: add changelog for chart fixes --- changelog/109.fix.md | 3 +++ 1 file changed, 3 insertions(+) create mode 100644 changelog/109.fix.md diff --git a/changelog/109.fix.md b/changelog/109.fix.md new file mode 100644 index 00000000..a18e49c3 --- /dev/null +++ b/changelog/109.fix.md @@ -0,0 +1,3 @@ +Fixed nearest-value selection and record highlighting in box-and-whisker charts, excluded clipped values from selection, and corrected whisker endpoints. + +Corrected the sea surface salinity Taylor diagram title and removed empty grid slots between explorer card groups. From 85972ab774ca5bf69c15f973c517e99ac028f6ac Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Mon, 7 Sep 2026 17:45:28 +1000 Subject: [PATCH 3/4] fix: handle deferred chart pointer events --- .../ensembleChart.interaction.test.tsx | 45 ++++++++++++++++++- .../components/diagnostics/ensembleChart.tsx | 14 +++--- 2 files changed, 52 insertions(+), 7 deletions(-) diff --git a/frontend/src/components/diagnostics/ensembleChart.interaction.test.tsx b/frontend/src/components/diagnostics/ensembleChart.interaction.test.tsx index 5e7381c2..a7a37f2a 100644 --- a/frontend/src/components/diagnostics/ensembleChart.interaction.test.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.interaction.test.tsx @@ -1,4 +1,4 @@ -import { fireEvent, render } from "@testing-library/react"; +import { act, fireEvent, render } from "@testing-library/react"; import { cloneElement, type ReactElement } from "react"; import { describe, expect, it, vi } from "vitest"; import { EnsembleChart } from "./ensembleChart"; @@ -84,6 +84,49 @@ describe("box plot selection", () => { "#EF4444", ); }); + it("updates selection after a throttled mouse move", () => { + vi.useFakeTimers(); + try { + const { container } = chart([ + point(10, "one", "A"), + point(20, "one", "A"), + ]); + hover(container, markers(container)[0]); + fireEvent.mouseMove(container.querySelector(".recharts-wrapper")!, { + clientX: 115, + clientY: 105, + }); + act(() => vi.advanceTimersByTime(50)); + expect(markers(container)[1].querySelector("path")).toHaveAttribute( + "stroke", + "#EF4444", + ); + } finally { + vi.useRealTimers(); + } + }); + it("selects a point during touch movement", () => { + vi.useFakeTimers(); + try { + const { container } = chart([ + point(10, "one", "A"), + point(20, "one", "A"), + ]); + hover(container, markers(container)[0]); + fireEvent.touchMove(container.querySelector(".recharts-wrapper")!, { + changedTouches: [ + { clientX: 115, clientY: 105, pageX: 115, pageY: 105 }, + ], + }); + act(() => vi.advanceTimersByTime(50)); + expect(markers(container)[1].querySelector("path")).toHaveAttribute( + "stroke", + "#EF4444", + ); + } finally { + vi.useRealTimers(); + } + }); it("excludes clipped and non-finite records from selectable markers", () => { const { container } = chart( [ diff --git a/frontend/src/components/diagnostics/ensembleChart.tsx b/frontend/src/components/diagnostics/ensembleChart.tsx index b08d380b..72cd3b9b 100644 --- a/frontend/src/components/diagnostics/ensembleChart.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.tsx @@ -1,6 +1,6 @@ import * as d3 from "d3-array"; import { scaleLinear } from "d3-scale"; -import { useMemo, useState } from "react"; +import { useMemo, useRef, useState } from "react"; import { Bar, CartesianGrid, @@ -155,6 +155,7 @@ export const EnsembleChart = ({ yMax, categoryOrder, }: EnsembleChartProps) => { + const chartRef = useRef(null); const [highlightedPoint, setHighlightedPoint] = useState<{ categoryName: string; groupName: string; @@ -359,7 +360,7 @@ export const EnsembleChart = ({ : "20%"; return ( -
+
setHighlightedPoint(null)} onMouseMove={(state, event) => { - if (!state.isTooltipActive) { + if (!state.isTooltipActive || !chartRef.current) { setHighlightedPoint(null); return; } let nearest: typeof highlightedPoint = null; let distance = Number.POSITIVE_INFINITY; - for (const marker of ( - event.currentTarget as HTMLElement - ).querySelectorAll("[data-box-point]")) { + // Recharts can deliver throttled mouse events or Touch objects. + for (const marker of chartRef.current.querySelectorAll( + "[data-box-point]", + )) { const bounds = marker.getBoundingClientRect(); const dx = event.clientX - (bounds.left + bounds.width / 2); const dy = event.clientY - (bounds.top + bounds.height / 2); From 97231b2ff3ae943a2a9a3cc116fa4a3b024e628d Mon Sep 17 00:00:00 2001 From: Jared Lewis Date: Mon, 7 Sep 2026 17:48:02 +1000 Subject: [PATCH 4/4] refactor: simplify selected-point rendering --- .../components/diagnostics/ensembleChart.tsx | 105 +++++++----------- 1 file changed, 40 insertions(+), 65 deletions(-) diff --git a/frontend/src/components/diagnostics/ensembleChart.tsx b/frontend/src/components/diagnostics/ensembleChart.tsx index 72cd3b9b..1e8caf55 100644 --- a/frontend/src/components/diagnostics/ensembleChart.tsx +++ b/frontend/src/components/diagnostics/ensembleChart.tsx @@ -283,6 +283,11 @@ export const EnsembleChart = ({ categoryOrder, ]); + const categoriesByName = useMemo( + () => new Map(sortedChartData.map((datum) => [datum.name, datum])), + [sortedChartData], + ); + // Get all unique group names for rendering multiple bars const allGroupNames = useMemo(() => { const names = new Set(); @@ -385,9 +390,7 @@ export const EnsembleChart = ({ if (nextDistance >= distance) continue; const categoryName = marker.dataset.category!; const groupName = marker.dataset.group!; - const datum = sortedChartData.find( - (d) => d.name === categoryName, - ); + const datum = categoriesByName.get(categoryName); const point = datum?.groups[groupName]?.points[ Number(marker.dataset.boxPoint) @@ -446,9 +449,7 @@ export const EnsembleChart = ({ offset={20} content={({ active, coordinate, viewBox }) => { if (!active || !highlightedPoint) return null; - const datum = sortedChartData.find( - (d) => d.name === highlightedPoint.categoryName, - ); + const datum = categoriesByName.get(highlightedPoint.categoryName); const statsKey = highlightedPoint.groupName; const groupStats = datum?.groups[statsKey]; if (!groupStats?.points.includes(highlightedPoint.point)) @@ -495,34 +496,15 @@ export const EnsembleChart = ({
Statistics
- {renderKV( - "Min", - groupStats ? fmt(Number(groupStats.min)) : "—", - )} - {renderKV( - "Q1", - groupStats - ? fmt(Number(groupStats.lowerQuartile)) - : "—", - )} - {renderKV( - "Median", - groupStats ? fmt(Number(groupStats.median)) : "—", - )} - {renderKV( - "Q3", - groupStats - ? fmt(Number(groupStats.upperQuartile)) - : "—", - )} - {renderKV( - "Max", - groupStats ? fmt(Number(groupStats.max)) : "—", - )} + {renderKV("Min", fmt(Number(groupStats.min)))} + {renderKV("Q1", fmt(Number(groupStats.lowerQuartile)))} + {renderKV("Median", fmt(Number(groupStats.median)))} + {renderKV("Q3", fmt(Number(groupStats.upperQuartile)))} + {renderKV("Max", fmt(Number(groupStats.max)))} {renderKV( "Count", String( - (groupStats?.values?.length ?? 0) + + groupStats.values.length + (outliers?.[statsKey] ?? 0), ), )} @@ -535,45 +517,38 @@ export const EnsembleChart = ({
{/* Closest Data Point */} - {closestDataPoint && ( -
-
- Closest Data Point +
+
Closest Data Point
+
+
+ {renderKV("Value", fmt(Number(closestDataPoint.value)))} + {renderKV("Units", metricUnits)}
-
-
- {renderKV( - "Value", - fmt(Number(closestDataPoint.value)), - )} - {renderKV("Units", metricUnits)} -
-
-
Dimensions:
-
- {Object.entries(closestDataPoint.dimensions).map( - ([key, value]) => ( -
+
Dimensions:
+
+ {Object.entries(closestDataPoint.dimensions).map( + ([key, value]) => ( +
+ + {key}: + + - - {key}: - - - {value} - -
- ), - )} -
+ {value} + +
+ ), + )}
- )} +
); }}