From f0d5f3fa68bd013bb17775cede42a9a9c319682c Mon Sep 17 00:00:00 2001 From: "claude[bot]" <209825114+claude[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 15:30:44 +0000 Subject: [PATCH 1/2] Cap discrete OOB PMF bars at 2x inbound max with axis-break marker When a discrete question forecast has a below-lower-bound or above-upper-bound mass that far exceeds the tallest in-bounds bar, the entire in-bounds distribution gets visually squashed. Clamp OOB bar heights in the PMF chart at OOB_BAR_DISPLAY_RATIO (=2) times the tallest in-bounds bar and draw a zigzag axis-break marker on top of any clamped bar, with the true PMF value labeled above the break. Fixes #5117 Co-authored-by: Luke Sabor <32885230+lsabor@users.noreply.github.com> --- .../charts/continuous_area_chart.tsx | 131 ++++++++++++++++-- .../charts/primitives/oob_break_marker.tsx | 92 ++++++++++++ 2 files changed, 214 insertions(+), 9 deletions(-) create mode 100644 front_end/src/components/charts/primitives/oob_break_marker.tsx diff --git a/front_end/src/components/charts/continuous_area_chart.tsx b/front_end/src/components/charts/continuous_area_chart.tsx index 8bf7d40d42..730fbfceeb 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"; @@ -90,6 +93,11 @@ const BOTTOM_PADDING = 20; const HORIZONTAL_PADDING = 10; const CURSOR_POINT_OFFSET = 5; const CURSOR_CHART_EXTENSION = 10; +// For discrete PMFs: if an out-of-bounds bar (below-lower / above-upper) is more +// than this many times taller than the tallest in-bounds bar, its rendered +// height is clamped to this ratio and a broken-axis marker is drawn on top so +// the in-bounds distribution isn't visually squashed. +const OOB_BAR_DISPLAY_RATIO = 2; type Props = { question: Question | GraphingQuestionProps; @@ -180,8 +188,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 +200,31 @@ const ContinuousAreaChart: FC = ({ return { cdf: cdfRescaled, pmf: cdfToPmf(cdfRescaled) }; })() : { cdf, pmf }; + return { datum, scaled, componentCdfs }; + }); + + // Discrete PMF only: compute the tallest in-bounds bar across all series so + // outlying OOB bars can be capped at OOB_BAR_DISPLAY_RATIO * inbound max. + let oobCap: number | undefined; + if (question.type === QuestionType.Discrete && graphType !== "cdf") { + const inboundMax = Math.max( + 0, + ...scaledPerDatum.flatMap(({ scaled }) => scaled.pmf.slice(1, -1)) + ); + if (inboundMax > 0) { + oobCap = OOB_BAR_DISPLAY_RATIO * inboundMax; + } + } + 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 +236,7 @@ const ContinuousAreaChart: FC = ({ graphType, type: "user_components", question, + oobCap, }) ); } @@ -282,7 +308,13 @@ 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()); + // Exclude OOB PMF values (pmf[0], pmf[-1]) so an outlying below/above-bound + // bar doesn't squash the in-bounds distribution. The OOB bars are clamped + // separately in generateNumericAreaGraph and a broken-axis marker is drawn + // on top. + const maxValue = Math.max( + ...data.map((x) => x.pmf.slice(1, x.pmf.length - 1)).flat() + ); return { xDomain, yDomain: [0, Math.min(1, 1.2 * (maxValue <= 0 ? 1 : maxValue))], @@ -785,6 +817,61 @@ const ContinuousAreaChart: FC = ({ /> ); })} + {discrete && + charts.flatMap((chart, chartIndex) => { + if (!chart.oobClamped) return []; + const barFill = (() => { + if (colorOverride && chart.type !== "user") { + return colorOverride; + } + switch (chart.color) { + case "orange": + return getThemeColor( + METAC_COLORS.orange[chart.type === "user" ? "500" : "400"] + ); + case "green": + return getThemeColor(METAC_COLORS.olive["500"]); + case "gray": + return getThemeColor(METAC_COLORS.gray["500"]); + case "purple": + return getThemeColor(METAC_COLORS.purple["500"]); + default: + return getThemeColor(METAC_COLORS.gray["0"]); + } + })(); + 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: { + barWidth, + trueValue: clamp.trueY, + fill: barFill, + formatValue: (v: number) => `${(v * 100).toFixed(1)}%`, + }, + }); + } + return points.map((p) => ( + + + + } + /> + )); + })} {!discrete ? charts.map((chart, index) => ( = ({ ); }; +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; + }; }; function generateNumericAreaGraph(data: { @@ -1230,10 +1327,12 @@ function generateNumericAreaGraph(data: { 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 +1352,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 +1407,7 @@ function generateNumericAreaGraph(data: { } } + const hasOobClamp = !!(oobClamped.left || oobClamped.right); if (type === "user_components") { return { graphLine: graph, @@ -1304,6 +1415,7 @@ function generateNumericAreaGraph(data: { color: CHART_COLOR_MAP[type], type, graphType, + ...(hasOobClamp ? { oobClamped } : {}), }; } @@ -1348,6 +1460,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..fb1bf2853d --- /dev/null +++ b/front_end/src/components/charts/primitives/oob_break_marker.tsx @@ -0,0 +1,92 @@ +"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; + barWidth: number; + trueValue: number; + // Pre-resolved (already themed) fill color, used to visually mask the bar + // segment behind the zigzag so the break reads as separated. + fill?: string; + formatValue: (value: number) => string; +}; + +type Props = { + x?: number; + y?: number; + datum?: unknown; +}; + +const OobBreakMarker: FC = ({ x, y, datum }) => { + const { getThemeColor } = useAppTheme(); + const d = datum as OobBreakMarkerDatum | undefined; + if (x == null || y == null || !d) return null; + + const halfWidth = Math.max(4, d.barWidth / 2); + // A slightly wider bracket, tucked ~2px above the clamped bar top so the + // zigzag reads as an axis break on the bar rather than as data. + const anchorY = y - 2; + const step = halfWidth / 2; + const zigzagAmplitude = 3; + const strokeColor = getThemeColor(METAC_COLORS.gray["600"]); + const fillColor = d.fill ?? getThemeColor(METAC_COLORS.gray["0"]); + + // Two parallel zigzag lines with a thin band between them so the bar visually + // "breaks" (the space between reads as the removed section of axis). + const bandHalf = 2; + const upperY = anchorY - bandHalf; + const lowerY = anchorY + bandHalf; + const buildPath = (baseY: number) => + `M ${x - halfWidth},${baseY}` + + ` L ${x - halfWidth + step},${baseY - zigzagAmplitude}` + + ` L ${x},${baseY + zigzagAmplitude}` + + ` L ${x + halfWidth - step},${baseY - zigzagAmplitude}` + + ` L ${x + halfWidth},${baseY}`; + + const label = d.formatValue(d.trueValue); + const labelY = upperY - zigzagAmplitude - 6; + + return ( + + {/* Mask out the bar section behind the break with the chart background + color so the two zigzag strokes read as separated. */} + + + + + {label} + + + ); +}; + +export default OobBreakMarker; From a8ec4a564fd48465eb915412ea6327a28e6c65cc Mon Sep 17 00:00:00 2001 From: lsabor Date: Fri, 7 Aug 2026 13:20:19 -0700 Subject: [PATCH 2/2] Fix discrete OOB PMF bar's Y-axis break visualization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The axis-break marker for out-of-bounds discrete PMF bars never rendered: the bar's clamp cap and the Y-axis domain top used different, unrelated multipliers of the in-bounds max, so a clamped bar (and its marker) always extended past the visible plot and got clipped by the SVG viewport. Unifies the axis top and clamp cap into a single value, and reworks the break itself: instead of an overlaid color patch (which either mismatched the real background or clipped off-canvas), the OOB bar's own path is drawn with a real gap — a literal hole punched out with diagonal cut edges, anchored just above the tallest in-bounds bar, so the removed segment truly shows the page background through it. The axis also reserves extra headroom and hides regular tick labels above the break, leaving only the bar's true value annotated there. Co-Authored-By: Claude Sonnet 5 --- .../charts/continuous_area_chart.tsx | 192 +++++++++++++----- .../charts/primitives/oob_break_marker.tsx | 75 ++----- 2 files changed, 159 insertions(+), 108 deletions(-) diff --git a/front_end/src/components/charts/continuous_area_chart.tsx b/front_end/src/components/charts/continuous_area_chart.tsx index 730fbfceeb..4e87e37eee 100644 --- a/front_end/src/components/charts/continuous_area_chart.tsx +++ b/front_end/src/components/charts/continuous_area_chart.tsx @@ -89,15 +89,21 @@ 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; const CURSOR_CHART_EXTENSION = 10; -// For discrete PMFs: if an out-of-bounds bar (below-lower / above-upper) is more -// than this many times taller than the tallest in-bounds bar, its rendered -// height is clamped to this ratio and a broken-axis marker is drawn on top so -// the in-bounds distribution isn't visually squashed. -const OOB_BAR_DISPLAY_RATIO = 2; type Props = { question: Question | GraphingQuestionProps; @@ -176,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"), @@ -203,18 +255,9 @@ const ContinuousAreaChart: FC = ({ return { datum, scaled, componentCdfs }; }); - // Discrete PMF only: compute the tallest in-bounds bar across all series so - // outlying OOB bars can be capped at OOB_BAR_DISPLAY_RATIO * inbound max. - let oobCap: number | undefined; - if (question.type === QuestionType.Discrete && graphType !== "cdf") { - const inboundMax = Math.max( - 0, - ...scaledPerDatum.flatMap(({ scaled }) => scaled.pmf.slice(1, -1)) - ); - if (inboundMax > 0) { - oobCap = OOB_BAR_DISPLAY_RATIO * inboundMax; - } - } + // 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) { @@ -249,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; @@ -308,16 +351,12 @@ const ContinuousAreaChart: FC = ({ const xDomain: Tuple = [xMin, xMax]; if (graphType === "cdf") return { xDomain, yDomain: [0, 1] }; - // Exclude OOB PMF values (pmf[0], pmf[-1]) so an outlying below/above-bound - // bar doesn't squash the in-bounds distribution. The OOB bars are clamped - // separately in generateNumericAreaGraph and a broken-axis marker is drawn - // on top. - const maxValue = Math.max( - ...data.map((x) => x.pmf.slice(1, x.pmf.length - 1)).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, @@ -328,6 +367,7 @@ const ContinuousAreaChart: FC = ({ globalScaling, question.inbound_outcome_count, question.scaling, + oobAxisTop, ]); const xScale = useMemo( @@ -358,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({ @@ -814,31 +865,21 @@ 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 barFill = (() => { - if (colorOverride && chart.type !== "user") { - return colorOverride; - } - switch (chart.color) { - case "orange": - return getThemeColor( - METAC_COLORS.orange[chart.type === "user" ? "500" : "400"] - ); - case "green": - return getThemeColor(METAC_COLORS.olive["500"]); - case "gray": - return getThemeColor(METAC_COLORS.gray["500"]); - case "purple": - return getThemeColor(METAC_COLORS.purple["500"]); - default: - return getThemeColor(METAC_COLORS.gray["0"]); - } - })(); const points: Array<{ key: string; x: number; @@ -853,9 +894,7 @@ const ContinuousAreaChart: FC = ({ x: clamp.x, y: clamp.clampedY, datum: { - barWidth, trueValue: clamp.trueY, - fill: barFill, formatValue: (v: number) => `${(v * 100).toFixed(1)}%`, }, }); @@ -936,7 +975,7 @@ const ContinuousAreaChart: FC = ({ strokeWidth: 1, }, }} - tickValues={yScale.ticks} + tickValues={visibleYTicks} tickFormat={yScale.tickFormat} axisValue={xDomain[1]} /> @@ -1321,6 +1360,61 @@ type NumericPredictionGraph = { }; }; +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[]; diff --git a/front_end/src/components/charts/primitives/oob_break_marker.tsx b/front_end/src/components/charts/primitives/oob_break_marker.tsx index fb1bf2853d..18596917e1 100644 --- a/front_end/src/components/charts/primitives/oob_break_marker.tsx +++ b/front_end/src/components/charts/primitives/oob_break_marker.tsx @@ -8,11 +8,7 @@ import useAppTheme from "@/hooks/use_app_theme"; export type OobBreakMarkerDatum = { x?: number; y?: number; - barWidth: number; trueValue: number; - // Pre-resolved (already themed) fill color, used to visually mask the bar - // segment behind the zigzag so the break reads as separated. - fill?: string; formatValue: (value: number) => string; }; @@ -22,70 +18,31 @@ type Props = { 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 halfWidth = Math.max(4, d.barWidth / 2); - // A slightly wider bracket, tucked ~2px above the clamped bar top so the - // zigzag reads as an axis break on the bar rather than as data. - const anchorY = y - 2; - const step = halfWidth / 2; - const zigzagAmplitude = 3; const strokeColor = getThemeColor(METAC_COLORS.gray["600"]); - const fillColor = d.fill ?? getThemeColor(METAC_COLORS.gray["0"]); - - // Two parallel zigzag lines with a thin band between them so the bar visually - // "breaks" (the space between reads as the removed section of axis). - const bandHalf = 2; - const upperY = anchorY - bandHalf; - const lowerY = anchorY + bandHalf; - const buildPath = (baseY: number) => - `M ${x - halfWidth},${baseY}` + - ` L ${x - halfWidth + step},${baseY - zigzagAmplitude}` + - ` L ${x},${baseY + zigzagAmplitude}` + - ` L ${x + halfWidth - step},${baseY - zigzagAmplitude}` + - ` L ${x + halfWidth},${baseY}`; - const label = d.formatValue(d.trueValue); - const labelY = upperY - zigzagAmplitude - 6; return ( - - {/* Mask out the bar section behind the break with the chart background - color so the two zigzag strokes read as separated. */} - - - - - {label} - - + + {label} + ); };