diff --git a/front_end/src/components/charts/continuous_area_chart.tsx b/front_end/src/components/charts/continuous_area_chart.tsx index 8bf7d40d42..4e87e37eee 100644 --- a/front_end/src/components/charts/continuous_area_chart.tsx +++ b/front_end/src/components/charts/continuous_area_chart.tsx @@ -66,6 +66,9 @@ import { import ChartValueBox from "./primitives/chart_value_box"; import LineCursorPoints from "./primitives/line_cursor_points"; +import OobBreakMarker, { + OobBreakMarkerDatum, +} from "./primitives/oob_break_marker"; import ResolutionDiamond from "./primitives/resolution_diamond"; type ContinuousAreaColor = "orange" | "green" | "gray" | "purple"; @@ -86,6 +89,17 @@ export type ContinuousAreaGraphInput = Array<{ }>; const TOP_PADDING = 10; +// Discrete PMFs only: extra top padding reserved when an out-of-bounds bar +// (below-lower / above-upper) is clamped to the axis top, so the break's +// value label has room to render without being clipped. +const OOB_BREAK_TOP_PADDING = 28; +// Y-axis headroom above the tallest in-bounds bar, as a multiple of its +// value. The larger ratio only kicks in when a bar needs to be broken, so +// there's enough room above the tallest in-bounds bar for the notch (see +// `getDiscreteBarPath`) without changing the axis proportions of ordinary +// discrete charts. +const BASE_AXIS_HEADROOM_RATIO = 1.2; +const BREAK_AXIS_HEADROOM_RATIO = 1.5; const BOTTOM_PADDING = 20; const HORIZONTAL_PADDING = 10; const CURSOR_POINT_OFFSET = 5; @@ -168,7 +182,53 @@ const ContinuousAreaChart: FC = ({ const showYAxis = graphType === "cdf" || (question.type === QuestionType.Discrete && !hideYAxis); - const paddingTop = graphType === "cdf" || discrete ? TOP_PADDING : 0; + + // Discrete PMF only: the Y-axis top is derived from the tallest in-bounds + // bar, excluding the below-lower/above-upper edges, so a disproportionate + // OOB bar can't squash the rest of the distribution. OOB bars taller than + // this get clamped to it (see `charts` below) and get a broken-axis marker. + // When a break is needed, the axis gets extra headroom (BREAK_AXIS_HEADROOM_RATIO + // rather than BASE_AXIS_HEADROOM_RATIO) so the clamped bar's notch — see + // `getDiscreteBarPath` below, which relies on this same ratio — has enough + // room above the tallest in-bounds bar to read clearly. + const { oobAxisTop, hasOobOverflow, oobBreakThreshold } = useMemo(() => { + if (question.type !== QuestionType.Discrete || graphType === "cdf") { + return { + oobAxisTop: undefined, + hasOobOverflow: false, + oobBreakThreshold: undefined, + }; + } + const inboundMax = Math.max( + 0, + ...data.map((x) => x.pmf.slice(1, -1)).flat() + ); + const base = inboundMax <= 0 ? 1 : inboundMax; + const needsBreak = data.some( + (x) => + (x.pmf.at(0) ?? 0) > BASE_AXIS_HEADROOM_RATIO * base || + (x.pmf.at(-1) ?? 0) > BASE_AXIS_HEADROOM_RATIO * base + ); + const ratio = needsBreak + ? BREAK_AXIS_HEADROOM_RATIO + : BASE_AXIS_HEADROOM_RATIO; + return { + oobAxisTop: Math.min(1, ratio * base), + hasOobOverflow: needsBreak, + // The tallest in-bounds bar's value: the top of the "normal" scale + // region. Axis ticks above this fall in the reserved break headroom + // and are hidden, since that space no longer represents a regular + // linear continuation of the axis. + oobBreakThreshold: base, + }; + }, [data, question.type, graphType]); + + const paddingTop = + graphType === "cdf" || discrete + ? discrete && hasOobOverflow + ? OOB_BREAK_TOP_PADDING + : TOP_PADDING + : 0; const hasUserData = useMemo( () => data.some((d) => d.type === "user"), @@ -180,8 +240,7 @@ const ContinuousAreaChart: FC = ({ ? [...data].filter((el) => el.type === "user") : data; - const chartData: NumericPredictionGraph[] = []; - for (const datum of parsedData) { + const scaledPerDatum = parsedData.map((datum) => { const { pmf, cdf, componentCdfs } = datum; const useRescaled = globalScaling && !isNil(question.scaling.zero_point); const scaled = useRescaled @@ -193,13 +252,22 @@ const ContinuousAreaChart: FC = ({ return { cdf: cdfRescaled, pmf: cdfToPmf(cdfRescaled) }; })() : { cdf, pmf }; + return { datum, scaled, componentCdfs }; + }); + // Discrete PMF only: clamp OOB bars to the axis top computed above so + // they never render past the visible plot. + const oobCap = oobAxisTop; + + const chartData: NumericPredictionGraph[] = []; + for (const { datum, scaled, componentCdfs } of scaledPerDatum) { chartData.push( generateNumericAreaGraph({ ...scaled, graphType, type: datum.type, question, + oobCap, }) ); if (componentCdfs && componentCdfs.length > 1) { @@ -211,6 +279,7 @@ const ContinuousAreaChart: FC = ({ graphType, type: "user_components", question, + oobCap, }) ); } @@ -223,7 +292,7 @@ const ContinuousAreaChart: FC = ({ })); } return chartData; - }, [data, graphType, hideCP, question, globalScaling, variant]); + }, [data, graphType, hideCP, question, globalScaling, variant, oobAxisTop]); const { xDomain, yDomain } = useMemo<{ xDomain: Tuple; @@ -282,10 +351,12 @@ const ContinuousAreaChart: FC = ({ const xDomain: Tuple = [xMin, xMax]; if (graphType === "cdf") return { xDomain, yDomain: [0, 1] }; - const maxValue = Math.max(...data.map((x) => x.pmf).flat()); + // Excludes OOB PMF values (pmf[0], pmf[-1]) so an outlying below/above-bound + // bar doesn't squash the in-bounds distribution. OOB bars are clamped to + // this same top in `charts` above, with a broken-axis marker drawn on them. return { xDomain, - yDomain: [0, Math.min(1, 1.2 * (maxValue <= 0 ? 1 : maxValue))], + yDomain: [0, oobAxisTop ?? 1], }; }, [ data, @@ -296,6 +367,7 @@ const ContinuousAreaChart: FC = ({ globalScaling, question.inbound_outcome_count, question.scaling, + oobAxisTop, ]); const xScale = useMemo( @@ -326,6 +398,17 @@ const ContinuousAreaChart: FC = ({ [height, yDomain, paddingTop] ); + // When a bar is broken, ticks above the break sit in headroom reserved for + // the notch rather than a real linear continuation of the axis, so regular + // tick labels up there would be misleading — the clamped bar's own value + // label (rendered on the bar itself) is the only annotation shown there. + const visibleYTicks = useMemo(() => { + if (!hasOobOverflow || oobBreakThreshold === undefined) { + return yScale.ticks; + } + return yScale.ticks.filter((tick) => tick <= oobBreakThreshold); + }, [yScale, hasOobOverflow, oobBreakThreshold]); + const resolutionPoint = !isNil(question.resolution) && question.resolution !== "" ? getResolutionPoint({ @@ -782,9 +865,52 @@ const ContinuousAreaChart: FC = ({ }, }} barWidth={barWidth} + getPath={ + chart.oobClamped + ? (props) => + getDiscreteBarPath( + props as unknown as BarPathProps, + chart.oobClamped + ) + : undefined + } /> ); })} + {discrete && + charts.flatMap((chart, chartIndex) => { + if (!chart.oobClamped) return []; + const points: Array<{ + key: string; + x: number; + y: number; + datum: OobBreakMarkerDatum; + }> = []; + for (const side of ["left", "right"] as const) { + const clamp = chart.oobClamped[side]; + if (!clamp) continue; + points.push({ + key: `${chartIndex}-${side}`, + x: clamp.x, + y: clamp.clampedY, + datum: { + trueValue: clamp.trueY, + formatValue: (v: number) => `${(v * 100).toFixed(1)}%`, + }, + }); + } + return points.map((p) => ( + + + + } + /> + )); + })} {!discrete ? charts.map((chart, index) => ( = ({ strokeWidth: 1, }, }} - tickValues={yScale.ticks} + tickValues={visibleYTicks} tickFormat={yScale.tickFormat} axisValue={xDomain[1]} /> @@ -1216,24 +1342,91 @@ const ContinuousAreaChart: FC = ({ ); }; +type OobClampedBar = { + x: number; + clampedY: number; + trueY: number; +}; + type NumericPredictionGraph = { graphLine: Line; verticalLines: Line; color: ContinuousAreaColor; type: ContinuousAreaType; graphType: ContinuousAreaGraphType; + oobClamped?: { + left?: OobClampedBar; + right?: OobClampedBar; + }; +}; + +type BarPathProps = { + x0: number; + x1: number; + y0: number; + y1: number; + datum?: { x?: number }; }; +// The notch cut into a clamped OOB bar is anchored to the tallest in-bounds +// bar's height (not an arbitrary fraction of the OOB bar's own height): the +// solid lower segment rises to just above that neighboring bar, then a real +// gap (a hole in the path — nothing painted, so the actual background shows +// through) separates it from the cap, which fills the rest of the way up to +// the axis top. Since the axis top is BREAK_AXIS_HEADROOM_RATIO × the +// tallest in-bounds bar's value whenever a break is needed, the tallest +// in-bounds bar's pixel height is `barHeight / BREAK_AXIS_HEADROOM_RATIO`. +const OOB_NEIGHBOR_CLEARANCE = 4; +const OOB_GAP_HEIGHT = 18; +const OOB_GAP_SLANT = 6; +const OOB_MIN_CAP_HEIGHT = 10; + +function getDiscreteBarPath( + props: BarPathProps, + oobClamped: NumericPredictionGraph["oobClamped"] +): string { + const { x0, x1, y0, y1, datum } = props; + const isOobEdge = + !!oobClamped && + (oobClamped.left?.x === datum?.x || oobClamped.right?.x === datum?.x); + + const plainRect = `M ${x0},${y0} L ${x0},${y1} L ${x1},${y1} L ${x1},${y0} Z`; + if (!isOobEdge) { + return plainRect; + } + + const barHeight = y0 - y1; + const neighborTop = y0 - barHeight / BREAK_AXIS_HEADROOM_RATIO; + const gapBottom = neighborTop - OOB_NEIGHBOR_CLEARANCE; + const gapTop = gapBottom - OOB_GAP_HEIGHT; + const capHeight = gapTop - y1; + + // Not enough headroom above the neighboring bar for a legible notch (very + // short bar): fall back to a plain, un-broken rect rather than a cramped one. + if (capHeight < OOB_MIN_CAP_HEIGHT) { + return plainRect; + } + + const slant = Math.min(OOB_GAP_SLANT, (x1 - x0) / 2); + + return ( + `M ${x0},${y0} L ${x0},${gapBottom} L ${x1},${gapBottom - slant} L ${x1},${y0} Z ` + + `M ${x0},${gapTop} L ${x0},${y1} L ${x1},${y1} L ${x1},${gapTop - slant} Z` + ); +} + function generateNumericAreaGraph(data: { pmf: number[]; cdf: number[]; graphType: ContinuousAreaGraphType; type: ContinuousAreaType; question: Question | GraphingQuestionProps; + oobCap?: number; }): NumericPredictionGraph { - const { pmf, cdf, graphType, type, question } = data; + const { pmf, cdf, graphType, type, question, oobCap } = data; const graph: Line = []; + const oobClamped: { left?: OobClampedBar; right?: OobClampedBar } = {}; if (question.type === QuestionType.Discrete) { if (graphType === "cdf") { if (question.open_lower_bound) { @@ -1253,16 +1446,27 @@ function generateNumericAreaGraph(data: { } } else { if (question.open_lower_bound) { - graph.push({ x: -0.5 / (cdf.length - 1), y: pmf.at(0) ?? 0 }); + const trueY = pmf.at(0) ?? 0; + const x = -0.5 / (cdf.length - 1); + const clampedY = + oobCap !== undefined && trueY > oobCap ? oobCap : trueY; + if (clampedY !== trueY) { + oobClamped.left = { x, clampedY, trueY }; + } + graph.push({ x, y: clampedY }); } pmf.slice(1, -1).forEach((value, index) => { graph.push({ x: (index + 0.5) / (cdf.length - 1), y: value }); }); if (question.open_upper_bound) { - graph.push({ - x: (cdf.length - 0.5) / (cdf.length - 1), - y: pmf.at(-1) ?? 0, - }); + const trueY = pmf.at(-1) ?? 0; + const x = (cdf.length - 0.5) / (cdf.length - 1); + const clampedY = + oobCap !== undefined && trueY > oobCap ? oobCap : trueY; + if (clampedY !== trueY) { + oobClamped.right = { x, clampedY, trueY }; + } + graph.push({ x, y: clampedY }); } } } else { @@ -1297,6 +1501,7 @@ function generateNumericAreaGraph(data: { } } + const hasOobClamp = !!(oobClamped.left || oobClamped.right); if (type === "user_components") { return { graphLine: graph, @@ -1304,6 +1509,7 @@ function generateNumericAreaGraph(data: { color: CHART_COLOR_MAP[type], type, graphType, + ...(hasOobClamp ? { oobClamped } : {}), }; } @@ -1348,6 +1554,7 @@ function generateNumericAreaGraph(data: { color: CHART_COLOR_MAP[type], type, graphType, + ...(hasOobClamp ? { oobClamped } : {}), }; } diff --git a/front_end/src/components/charts/primitives/oob_break_marker.tsx b/front_end/src/components/charts/primitives/oob_break_marker.tsx new file mode 100644 index 0000000000..18596917e1 --- /dev/null +++ b/front_end/src/components/charts/primitives/oob_break_marker.tsx @@ -0,0 +1,49 @@ +"use client"; +import React, { FC } from "react"; + +import { CHART_FONT_STYLE } from "@/constants/chart_typography"; +import { METAC_COLORS } from "@/constants/colors"; +import useAppTheme from "@/hooks/use_app_theme"; + +export type OobBreakMarkerDatum = { + x?: number; + y?: number; + trueValue: number; + formatValue: (value: number) => string; +}; + +type Props = { + x?: number; + y?: number; + datum?: unknown; +}; + +// The bar itself is drawn with a literal gap (see `getDiscreteBarPath` in +// continuous_area_chart.tsx) so the break reads as an absence of the bar. +// This marker only needs to label the bar's true (un-clamped) value, shown +// just above where the bar is cut off at the axis top. +const OobBreakMarker: FC = ({ x, y, datum }) => { + const { getThemeColor } = useAppTheme(); + const d = datum as OobBreakMarkerDatum | undefined; + if (x == null || y == null || !d) return null; + + const strokeColor = getThemeColor(METAC_COLORS.gray["600"]); + const label = d.formatValue(d.trueValue); + + return ( + + {label} + + ); +}; + +export default OobBreakMarker;