Skip to content
Draft
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
233 changes: 220 additions & 13 deletions front_end/src/components/charts/continuous_area_chart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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;
Expand Down Expand Up @@ -168,7 +182,53 @@ const ContinuousAreaChart: FC<Props> = ({
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"),
Expand All @@ -180,8 +240,7 @@ const ContinuousAreaChart: FC<Props> = ({
? [...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
Expand All @@ -193,13 +252,22 @@ const ContinuousAreaChart: FC<Props> = ({
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) {
Expand All @@ -211,6 +279,7 @@ const ContinuousAreaChart: FC<Props> = ({
graphType,
type: "user_components",
question,
oobCap,
})
);
}
Expand All @@ -223,7 +292,7 @@ const ContinuousAreaChart: FC<Props> = ({
}));
}
return chartData;
}, [data, graphType, hideCP, question, globalScaling, variant]);
}, [data, graphType, hideCP, question, globalScaling, variant, oobAxisTop]);

const { xDomain, yDomain } = useMemo<{
xDomain: Tuple<number>;
Expand Down Expand Up @@ -282,10 +351,12 @@ const ContinuousAreaChart: FC<Props> = ({
const xDomain: Tuple<number> = [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,
Expand All @@ -296,6 +367,7 @@ const ContinuousAreaChart: FC<Props> = ({
globalScaling,
question.inbound_outcome_count,
question.scaling,
oobAxisTop,
]);

const xScale = useMemo(
Expand Down Expand Up @@ -326,6 +398,17 @@ const ContinuousAreaChart: FC<Props> = ({
[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({
Expand Down Expand Up @@ -782,9 +865,52 @@ const ContinuousAreaChart: FC<Props> = ({
},
}}
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) => (
<VictoryScatter
key={`oob-break-${p.key}`}
data={[{ x: p.x, y: p.y, ...p.datum }]}
dataComponent={
<VictoryPortal>
<OobBreakMarker />
</VictoryPortal>
}
/>
));
})}
{!discrete
? charts.map((chart, index) => (
<VictoryLine
Expand Down Expand Up @@ -849,7 +975,7 @@ const ContinuousAreaChart: FC<Props> = ({
strokeWidth: 1,
},
}}
tickValues={yScale.ticks}
tickValues={visibleYTicks}
tickFormat={yScale.tickFormat}
axisValue={xDomain[1]}
/>
Expand Down Expand Up @@ -1216,24 +1342,91 @@ const ContinuousAreaChart: FC<Props> = ({
);
};

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) {
Expand All @@ -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 {
Expand Down Expand Up @@ -1297,13 +1501,15 @@ function generateNumericAreaGraph(data: {
}
}

const hasOobClamp = !!(oobClamped.left || oobClamped.right);
if (type === "user_components") {
return {
graphLine: graph,
verticalLines: [],
color: CHART_COLOR_MAP[type],
type,
graphType,
...(hasOobClamp ? { oobClamped } : {}),
};
}

Expand Down Expand Up @@ -1348,6 +1554,7 @@ function generateNumericAreaGraph(data: {
color: CHART_COLOR_MAP[type],
type,
graphType,
...(hasOobClamp ? { oobClamped } : {}),
};
}

Expand Down
Loading
Loading