Skip to content
9 changes: 9 additions & 0 deletions src/components/charts/Histogram/Histogram.scss
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,15 @@
margin-right: 6px;
}

.line-swatch {
display: inline-block;
width: 14px;
height: 0;
border-top-width: 2px;
border-top-style: dashed;
margin-right: 6px;
}

.divider {
display: inline-block;
width: 2px;
Expand Down
49 changes: 49 additions & 0 deletions src/components/charts/Histogram/Histogram.stories.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,55 @@ export const MultipleSeriesWithDistributionLines: Story = {
},
};

export const WithReferenceLineAndBand: Story = {
name: "With Reference Line And Band",
parameters: {
zephyr: { testCaseId: "" },
docs: {
Comment thread
owilliams-tetrascience marked this conversation as resolved.
description: {
story:
"Opt-in annotation layer: a shaded pass `band` and a `cutoff` reference line overlaid on the distribution. Labeled lines/bands appear as items in the chart legend.",
},
},
},
args: {
dataSeries: {
x: generateNormalData(20, 8, 200),
name: "Torque",
},
title: "Histogram with Cutoff",
xTitle: "Torque",
yTitle: "Frequency",
width: 520,
height: 480,
bands: [{ axis: "x", from: 24, to: 40, color: "#038599", label: "pass" }],
referenceLines: [{ axis: "x", value: 24, color: "#E15759", label: "cutoff" }],
},
play: async ({ canvasElement, step }) => {
const canvas = within(canvasElement);

await step("Chart renders", async () => {
expect(canvas.getByText("Histogram with Cutoff")).toBeInTheDocument();
expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument();
});

await step("Reference line and band shapes are drawn", async () => {
await waitFor(() => {
// One band rect + one reference line = two shapes.
expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(2);
});
});

await step("Labeled line and band appear as legend items", async () => {
const legend = canvasElement.querySelector(".legend-container") as HTMLElement;
expect(legend).toBeInTheDocument();
const legendText = within(legend);
expect(legendText.getByText("cutoff")).toBeInTheDocument();
expect(legendText.getByText("pass")).toBeInTheDocument();
});
},
};

export const ContainerFilled: Story = {
name: "Container Filled (responsive)",
parameters: {
Expand Down
83 changes: 75 additions & 8 deletions src/components/charts/Histogram/Histogram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,27 @@ import { getLoadedPlotly, loadPlotly } from "../plotly-loader";
import { useElementSize } from "@/hooks/use-element-size";
import { CHART_FONT_FAMILY, usePlotlyTheme } from "@/hooks/use-plotly-theme";
import { cn } from "@/lib/utils";
import {
buildChartAnnotations,
type AnnotationLegendItem,
type Band,
type ReferenceLine,
} from "@/utils/chart-annotations";
import { CHART_COLORS } from "@/utils/colors";
import "./Histogram.scss";

/** Exponent coefficient for normal distribution calculation */
const NORMAL_DISTRIBUTION_EXPONENT_COEFF = -0.5;

/** Map a Plotly dash style to the nearest CSS `border-top-style` for legend swatches. */
const dashToCssBorderStyle = (dash?: string): "solid" | "dotted" | "dashed" =>
dash === "solid" ? "solid" : dash === "dot" ? "dotted" : "dashed";

/** Fallback band fill opacity (mirrors the annotation-layer builder default). */
const DEFAULT_BAND_OPACITY = 0.12;
/** Floor for band legend swatches so faint fills stay visible at swatch size. */
const LEGEND_BAND_SWATCH_MIN_OPACITY = 0.35;

interface HistogramDataSeries {
x: number[];
name: string;
Expand Down Expand Up @@ -44,6 +59,16 @@ type HistogramProps = {
yTitle?: string;
bargap?: number;
showDistributionLine?: boolean;
/**
* Opt-in threshold / reference lines drawn across the plot (e.g. a cutoff).
* Rendered as themed Plotly shapes with optional labels.
*/
referenceLines?: ReferenceLine[];
/**
* Opt-in shaded from–to bands (e.g. a pass region or ±σ envelope).
* Rendered as themed Plotly shapes behind the bars.
*/
bands?: Band[];
};

const calculateMean = (data: number[]): number => {
Expand Down Expand Up @@ -114,6 +139,8 @@ const Histogram: React.FC<HistogramProps> = ({
yTitle = "Frequency",
bargap = 0.2,
showDistributionLine = false,
referenceLines,
bands,
}) => {
const plotRef = useRef<HTMLDivElement>(null);
const theme = usePlotlyTheme();
Expand Down Expand Up @@ -166,6 +193,11 @@ const Histogram: React.FC<HistogramProps> = ({

const gridColor = theme.gridColor;

const annotationLayer = useMemo(
() => buildChartAnnotations(theme, { referenceLines, bands }),
[theme, referenceLines, bands],
);

const histogramData = useMemo(
() =>
seriesWithColors.map((series) => ({
Expand Down Expand Up @@ -300,6 +332,7 @@ const Histogram: React.FC<HistogramProps> = ({
bargap: bargap,
paper_bgcolor: theme.paperBg,
plot_bgcolor: theme.plotBg,
shapes: annotationLayer.shapes,
};

const config = {
Expand Down Expand Up @@ -332,7 +365,7 @@ const Histogram: React.FC<HistogramProps> = ({
plotInitedRef.current = false;
}
};
}, [hasSize, xTitle, yTitle, bargap, plotData, effectiveBarMode, gridColor, theme, bindTooltip]);
}, [hasSize, xTitle, yTitle, bargap, plotData, effectiveBarMode, gridColor, theme, bindTooltip, annotationLayer]);

// Resize in place when the measured/overridden size changes — cheaper than
// recreating the plot, and it preserves tooltip/event bindings.
Expand All @@ -359,13 +392,47 @@ const Histogram: React.FC<HistogramProps> = ({

const ChartLegend: React.FC<{
series: Array<{ name: string; color: string }>;
}> = ({ series }) => {
const items = series.map((item, i) => (
<React.Fragment key={item.name}>
annotations: AnnotationLegendItem[];
}> = ({ series, annotations }) => {
// Data series (filled boxes) followed by any labeled reference lines / bands
// (line and band swatches) — a single legend for everything on the plot.
const entries: Array<{ key: string; label: string; swatch: React.ReactNode }> = [
...series.map((s) => ({
key: `series-${s.name}`,
label: s.name,
swatch: <span className="color-box" style={{ background: s.color }} />,
})),
...annotations.map((a, i) => ({
key: `annotation-${i}-${a.label}`,
label: a.label,
swatch:
a.kind === "band" ? (
<span
className="color-box"
style={{
background: a.color,
opacity: Math.max(a.opacity ?? DEFAULT_BAND_OPACITY, LEGEND_BAND_SWATCH_MIN_OPACITY),
}}
/>
) : (
<span
className="line-swatch"
style={{
borderTopColor: a.color,
borderTopStyle: dashToCssBorderStyle(a.dash),
borderTopWidth: a.width ?? 2,
}}
/>
),
})),
];

const items = entries.map((entry, i) => (
<React.Fragment key={entry.key}>
<div className="legend-item">
<span className="color-box" style={{ background: item.color }} />
{item.name}
{i < series.length - 1 && <span className="divider" />}
{entry.swatch}
{entry.label}
{i < entries.length - 1 && <span className="divider" />}
</div>
</React.Fragment>
));
Expand Down Expand Up @@ -406,7 +473,7 @@ const Histogram: React.FC<HistogramProps> = ({
}}
/>
</div>
<ChartLegend series={seriesWithColors} />
<ChartLegend series={seriesWithColors} annotations={annotationLayer.legendItems} />
</div>
{tooltipElement}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -948,3 +948,48 @@ export const ThemedTooltip: Story = {
zephyr: { testCaseId: "SW-T5414" },
},
};

/**
* Opt-in annotation layer: threshold / reference lines and shaded from–to
* bands, drawn as themed Plotly shapes. Labeled lines/bands (`hit line`,
* `midpoint`, `focus`) surface in the chart legend.
*/
export const WithReferenceLinesAndBands: Story = {
args: {
data: BASIC_DATA,
title: "Scatter with Thresholds",
...DEFAULT_DIMS,
referenceLines: [
{ axis: "y", value: 70, color: "#E15759", label: "hit line" },
{ axis: "x", value: 50, label: "midpoint" },
],
bands: [{ axis: "x", from: 70, to: 100, color: "#038599", label: "focus" }],
},
play: async ({ canvasElement, step }) => {
await step("Chart renders with data points", async () => {
expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument();
await waitFor(() => {
expect(canvasElement.querySelectorAll(".scatterlayer .points path").length).toBeGreaterThan(0);
});
});

await step("Two reference lines and one band are drawn", async () => {
await waitFor(() => {
// 2 reference lines + 1 band = three shapes.
expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(3);
});
});

await step("Labeled lines and band appear as legend items", async () => {
await waitFor(() => {
const text = canvasElement.querySelector(".infolayer .legend")?.textContent ?? "";
expect(text).toContain("hit line");
expect(text).toContain("midpoint");
expect(text).toContain("focus");
});
});
},
parameters: {
zephyr: { testCaseId: "" },
},
};
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import type { AxisConfig, ScatterPlotInteractiveProps, SelectionMode, TooltipCon
import type Plotly from "plotly.js-dist";

import { usePlotlyTheme } from "@/hooks/use-plotly-theme";
import { annotationLegendTraces, buildChartAnnotations } from "@/utils/chart-annotations";

// Stable default prop objects — inline defaults would create a new identity
// on every render, retriggering the plot effect (and tearing down the
Expand Down Expand Up @@ -70,6 +71,8 @@ const ScatterPlotInteractive: React.FC<ScatterPlotInteractiveProps> = ({
height = 600,
showColorBar = true,
className,
referenceLines,
bands,
}) => {
const plotRef = useRef<HTMLDivElement>(null);
const theme = usePlotlyTheme();
Expand Down Expand Up @@ -206,6 +209,17 @@ const ScatterPlotInteractive: React.FC<ScatterPlotInteractiveProps> = ({
return config;
}, [sizes, shapes, colorMapping, plotlyColorscale, plotlyColors, showColorBar, processedData, colors]);

// Opt-in threshold lines / shaded bands, as themed Plotly shapes. Labeled
// lines/bands surface as legend-only traces (see below).
const annotationLayer = useMemo(
() => buildChartAnnotations(theme, { referenceLines, bands }),
[theme, referenceLines, bands],
);
const annotationLegend = useMemo(
() => annotationLegendTraces(annotationLayer.legendItems),
[annotationLayer],
);

// Create Plotly plot
useEffect(() => {
const currentRef = plotRef.current;
Expand Down Expand Up @@ -248,7 +262,10 @@ const ScatterPlotInteractive: React.FC<ScatterPlotInteractiveProps> = ({
},
};

const plotData: Plotly.Data[] = [trace as Plotly.Data];
// Data trace stays at index 0 (selection restyle targets it); legend-only
// annotation traces follow and carry no points, so they never affect
// selection or hover.
const plotData: Plotly.Data[] = [trace as Plotly.Data, ...(annotationLegend as Plotly.Data[])];

// Configure layout
const layout: Partial<Plotly.Layout> = getPlotlyLayoutConfig({
Expand All @@ -263,6 +280,17 @@ const ScatterPlotInteractive: React.FC<ScatterPlotInteractiveProps> = ({
enableBoxSelection,
theme,
});
layout.shapes = annotationLayer.shapes;
// Show Plotly's legend only when there are labeled annotations to list.
if (annotationLegend.length > 0) {
layout.showlegend = true;
layout.legend = {
bgcolor: theme.isDark ? "rgba(15, 23, 42, 0.6)" : "rgba(255, 255, 255, 0.6)",
bordercolor: theme.gridColor,
borderwidth: 1,
font: { family: "Inter, sans-serif", color: theme.legendColor },
};
}

const config: Partial<Plotly.Config> = {
responsive: true,
Expand Down Expand Up @@ -390,6 +418,8 @@ const ScatterPlotInteractive: React.FC<ScatterPlotInteractiveProps> = ({
nativeTooltip,
bindTooltip,
theme,
annotationLayer,
annotationLegend,
]);

// Apply selection state to Plotly
Expand Down
14 changes: 14 additions & 0 deletions src/components/charts/ScatterPlotInteractive/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import type { Band, ReferenceLine } from "@/utils/chart-annotations";

/**
* A single data point in the scatter plot
*/
Expand Down Expand Up @@ -245,6 +247,18 @@ export interface ScatterPlotInteractiveProps {
* Custom CSS class name
*/
className?: string;

/**
* Opt-in threshold / reference lines (e.g. a hit cutoff) drawn across the
* plot. Rendered as themed Plotly shapes with optional labels.
*/
referenceLines?: ReferenceLine[];

/**
* Opt-in shaded from–to bands (e.g. a pass/fail region or ±3σ envelope)
* drawn behind the points. Rendered as themed Plotly shapes.
*/
bands?: Band[];
}

/**
Expand Down
4 changes: 4 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -110,8 +110,12 @@ export * from "@/components/ai/suggestion";
export * from "@/components/ai/task";
export * from "@/components/ai/tool";

// Hooks
export * from "@/hooks/use-plotly-theme";

// Utils
export * from "@/utils/colors";
export * from "@/utils/chart-annotations";
// Extend the slim code-highlighting language set (SW-2007)
export {
getSupportedCodeBlockLanguages,
Expand Down
Loading
Loading