diff --git a/src/components/charts/Histogram/Histogram.scss b/src/components/charts/Histogram/Histogram.scss index 3a65b799..d9b3c97b 100644 --- a/src/components/charts/Histogram/Histogram.scss +++ b/src/components/charts/Histogram/Histogram.scss @@ -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; diff --git a/src/components/charts/Histogram/Histogram.stories.tsx b/src/components/charts/Histogram/Histogram.stories.tsx index fd755be2..45d10b34 100644 --- a/src/components/charts/Histogram/Histogram.stories.tsx +++ b/src/components/charts/Histogram/Histogram.stories.tsx @@ -384,6 +384,55 @@ export const MultipleSeriesWithDistributionLines: Story = { }, }; +export const WithReferenceLineAndBand: Story = { + name: "With Reference Line And Band", + parameters: { + zephyr: { testCaseId: "" }, + docs: { + 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: { diff --git a/src/components/charts/Histogram/Histogram.tsx b/src/components/charts/Histogram/Histogram.tsx index 34078f7d..09881c2e 100644 --- a/src/components/charts/Histogram/Histogram.tsx +++ b/src/components/charts/Histogram/Histogram.tsx @@ -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; @@ -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 => { @@ -114,6 +139,8 @@ const Histogram: React.FC = ({ yTitle = "Frequency", bargap = 0.2, showDistributionLine = false, + referenceLines, + bands, }) => { const plotRef = useRef(null); const theme = usePlotlyTheme(); @@ -166,6 +193,11 @@ const Histogram: React.FC = ({ const gridColor = theme.gridColor; + const annotationLayer = useMemo( + () => buildChartAnnotations(theme, { referenceLines, bands }), + [theme, referenceLines, bands], + ); + const histogramData = useMemo( () => seriesWithColors.map((series) => ({ @@ -300,6 +332,7 @@ const Histogram: React.FC = ({ bargap: bargap, paper_bgcolor: theme.paperBg, plot_bgcolor: theme.plotBg, + shapes: annotationLayer.shapes, }; const config = { @@ -332,7 +365,7 @@ const Histogram: React.FC = ({ 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. @@ -359,13 +392,47 @@ const Histogram: React.FC = ({ const ChartLegend: React.FC<{ series: Array<{ name: string; color: string }>; - }> = ({ series }) => { - const items = series.map((item, i) => ( - + 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: , + })), + ...annotations.map((a, i) => ({ + key: `annotation-${i}-${a.label}`, + label: a.label, + swatch: + a.kind === "band" ? ( + + ) : ( + + ), + })), + ]; + + const items = entries.map((entry, i) => ( +
- - {item.name} - {i < series.length - 1 && } + {entry.swatch} + {entry.label} + {i < entries.length - 1 && }
)); @@ -406,7 +473,7 @@ const Histogram: React.FC = ({ }} /> - + {tooltipElement} diff --git a/src/components/charts/ScatterPlotInteractive/ScatterPlotInteractive.stories.tsx b/src/components/charts/ScatterPlotInteractive/ScatterPlotInteractive.stories.tsx index 0f29bb66..f49a6257 100644 --- a/src/components/charts/ScatterPlotInteractive/ScatterPlotInteractive.stories.tsx +++ b/src/components/charts/ScatterPlotInteractive/ScatterPlotInteractive.stories.tsx @@ -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: "" }, + }, +}; diff --git a/src/components/charts/ScatterPlotInteractive/ScatterPlotInteractive.tsx b/src/components/charts/ScatterPlotInteractive/ScatterPlotInteractive.tsx index 28710728..ad230ccc 100644 --- a/src/components/charts/ScatterPlotInteractive/ScatterPlotInteractive.tsx +++ b/src/components/charts/ScatterPlotInteractive/ScatterPlotInteractive.tsx @@ -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 @@ -70,6 +71,8 @@ const ScatterPlotInteractive: React.FC = ({ height = 600, showColorBar = true, className, + referenceLines, + bands, }) => { const plotRef = useRef(null); const theme = usePlotlyTheme(); @@ -206,6 +209,17 @@ const ScatterPlotInteractive: React.FC = ({ 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; @@ -248,7 +262,10 @@ const ScatterPlotInteractive: React.FC = ({ }, }; - 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 = getPlotlyLayoutConfig({ @@ -263,6 +280,17 @@ const ScatterPlotInteractive: React.FC = ({ 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 = { responsive: true, @@ -390,6 +418,8 @@ const ScatterPlotInteractive: React.FC = ({ nativeTooltip, bindTooltip, theme, + annotationLayer, + annotationLegend, ]); // Apply selection state to Plotly diff --git a/src/components/charts/ScatterPlotInteractive/types.ts b/src/components/charts/ScatterPlotInteractive/types.ts index 6254a693..d47ae5b4 100644 --- a/src/components/charts/ScatterPlotInteractive/types.ts +++ b/src/components/charts/ScatterPlotInteractive/types.ts @@ -1,3 +1,5 @@ +import type { Band, ReferenceLine } from "@/utils/chart-annotations"; + /** * A single data point in the scatter plot */ @@ -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[]; } /** diff --git a/src/index.ts b/src/index.ts index 0e9506f3..b5f1dedb 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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, diff --git a/src/utils/chart-annotations.test.ts b/src/utils/chart-annotations.test.ts new file mode 100644 index 00000000..607dfb9f --- /dev/null +++ b/src/utils/chart-annotations.test.ts @@ -0,0 +1,166 @@ +import { describe, it, expect } from "vitest"; + +import { annotationLegendTraces, buildChartAnnotations } from "./chart-annotations"; + +import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; + +const LIGHT: PlotlyThemeColors = { + paperBg: "transparent", + plotBg: "transparent", + textColor: "rgba(26, 26, 26, 1)", + textSecondary: "rgba(26, 26, 26, 0.6)", + gridColor: "rgba(158, 172, 192, 0.55)", + lineColor: "rgba(26, 26, 26, 1)", + tickColor: "rgba(225, 231, 239, 1)", + legendColor: "rgba(4, 38, 63, 1)", + spikeColor: "rgba(100, 116, 139, 1)", + markerOutline: "rgba(26, 26, 26, 0.45)", + isDark: false, +}; + +describe("buildChartAnnotations", () => { + it("returns empty layers when no config is provided", () => { + const { shapes, legendItems } = buildChartAnnotations(LIGHT); + expect(shapes).toEqual([]); + expect(legendItems).toEqual([]); + }); + + it("builds a vertical shape for an x-axis reference line", () => { + const { shapes } = buildChartAnnotations(LIGHT, { + referenceLines: [{ axis: "x", value: 0.7 }], + }); + expect(shapes).toHaveLength(1); + const [shape] = shapes; + expect(shape.type).toBe("line"); + // Vertical: fixed x in data coords, full-height via paper y-reference. + expect(shape.xref).toBe("x"); + expect(shape.yref).toBe("paper"); + expect(shape.x0).toBe(0.7); + expect(shape.x1).toBe(0.7); + expect(shape.y0).toBe(0); + expect(shape.y1).toBe(1); + // Threshold lines draw above the data. + expect(shape.layer).toBe("above"); + }); + + it("builds a horizontal shape for a y-axis reference line", () => { + const { shapes } = buildChartAnnotations(LIGHT, { + referenceLines: [{ axis: "y", value: 3 }], + }); + const [shape] = shapes; + expect(shape.xref).toBe("paper"); + expect(shape.yref).toBe("y"); + expect(shape.y0).toBe(3); + expect(shape.y1).toBe(3); + expect(shape.x0).toBe(0); + expect(shape.x1).toBe(1); + }); + + it("applies default width/dash and honors overrides on reference lines", () => { + const { shapes } = buildChartAnnotations(LIGHT, { + referenceLines: [ + { axis: "x", value: 1 }, + { axis: "x", value: 2, width: 4, dash: "solid", color: "#ff0000" }, + ], + }); + expect(shapes[0].line).toMatchObject({ width: 2, dash: "dash", color: LIGHT.textColor }); + expect(shapes[1].line).toMatchObject({ width: 4, dash: "solid", color: "#ff0000" }); + }); + + it("builds a normalized rect for a band and draws it below the data", () => { + const { shapes } = buildChartAnnotations(LIGHT, { + // `from`/`to` given in reverse order should still normalize low→high. + bands: [{ axis: "y", from: 3, to: -3 }], + }); + const [shape] = shapes; + expect(shape.type).toBe("rect"); + expect(shape.y0).toBe(-3); + expect(shape.y1).toBe(3); + expect(shape.layer).toBe("below"); + expect(shape.opacity).toBe(0.12); + }); + + it("honors band color and opacity overrides", () => { + const { shapes } = buildChartAnnotations(LIGHT, { + bands: [{ axis: "x", from: 0, to: 1, color: "#038599", opacity: 0.3 }], + }); + expect(shapes[0].fillcolor).toBe("#038599"); + expect(shapes[0].opacity).toBe(0.3); + }); + + it("emits legend items only when a label is present", () => { + const { legendItems } = buildChartAnnotations(LIGHT, { + referenceLines: [ + { axis: "x", value: 0.7, label: "cutoff" }, + { axis: "x", value: 0.9 }, + ], + bands: [{ axis: "y", from: -3, to: 3, label: "±3σ" }], + }); + expect(legendItems).toHaveLength(2); + // Bands come before reference lines in draw order. + expect(legendItems.map((i) => i.label)).toEqual(["±3σ", "cutoff"]); + }); + + it("tags legend items with the right kind and swatch metadata", () => { + const { legendItems } = buildChartAnnotations(LIGHT, { + referenceLines: [{ axis: "x", value: 1, label: "cutoff", color: "#E15759", dash: "dot", width: 3 }], + bands: [{ axis: "x", from: 0, to: 2, label: "pass", color: "#038599", opacity: 0.2 }], + }); + const band = legendItems.find((i) => i.label === "pass"); + const line = legendItems.find((i) => i.label === "cutoff"); + expect(band).toMatchObject({ kind: "band", color: "#038599", opacity: 0.2 }); + expect(line).toMatchObject({ kind: "line", color: "#E15759", dash: "dot", width: 3 }); + }); + + it("renders bands before reference lines so lines sit on top", () => { + const { shapes } = buildChartAnnotations(LIGHT, { + referenceLines: [{ axis: "x", value: 1 }], + bands: [{ axis: "x", from: 0, to: 2 }], + }); + expect(shapes[0].type).toBe("rect"); + expect(shapes[1].type).toBe("line"); + }); + + it("falls back to a theme-aware neutral color when none is given", () => { + const { legendItems } = buildChartAnnotations(LIGHT, { + referenceLines: [{ axis: "x", value: 1, label: "x" }], + }); + expect(legendItems[0].color).toBe(LIGHT.textColor); + }); +}); + +describe("annotationLegendTraces", () => { + it("returns one legend-only trace per item, drawing nothing on the plot", () => { + const { legendItems } = buildChartAnnotations(LIGHT, { + referenceLines: [{ axis: "x", value: 1, label: "cutoff", color: "#E15759", dash: "dot", width: 3 }], + bands: [{ axis: "x", from: 0, to: 2, label: "pass", color: "#038599", opacity: 0.2 }], + }); + const traces = annotationLegendTraces(legendItems); + expect(traces).toHaveLength(2); + for (const t of traces) { + expect(t.x).toEqual([null]); + expect(t.y).toEqual([null]); + expect(t.showlegend).toBe(true); + expect(t.hoverinfo).toBe("skip"); + } + }); + + it("renders a line swatch for reference lines", () => { + const traces = annotationLegendTraces([ + { label: "cutoff", color: "#E15759", kind: "line", dash: "dot", width: 3 }, + ]); + expect(traces[0].mode).toBe("lines"); + expect(traces[0].name).toBe("cutoff"); + expect(traces[0].line).toMatchObject({ color: "#E15759", dash: "dot", width: 3 }); + }); + + it("renders a square swatch for bands and floors faint opacity", () => { + const traces = annotationLegendTraces([ + { label: "pass", color: "#038599", kind: "band", opacity: 0.1 }, + ]); + expect(traces[0].mode).toBe("markers"); + expect(traces[0].marker).toMatchObject({ color: "#038599", symbol: "square" }); + // 0.1 is below the legend floor, so it's raised to 0.35 for visibility. + expect((traces[0].marker as { opacity: number }).opacity).toBe(0.35); + }); +}); diff --git a/src/utils/chart-annotations.ts b/src/utils/chart-annotations.ts new file mode 100644 index 00000000..07f99b85 --- /dev/null +++ b/src/utils/chart-annotations.ts @@ -0,0 +1,280 @@ +/** + * Shared annotation layer for charts — threshold/reference lines and shaded + * bands, rendered as themed Plotly layout `shapes`. Labeled lines/bands surface + * in the chart's legend rather than as on-plot tags. + * + * This is an opt-in enabler consumed by chart components (Histogram, + * InteractiveScatter, …). Consumers pass `referenceLines` / `bands` props; + * the chart calls {@link buildChartAnnotations} with its resolved theme, merges + * the `shapes` into the Plotly layout, and renders the `legendItems` in its + * legend (via {@link annotationLegendTraces} for charts that use Plotly's + * legend, or directly for charts with a custom legend). + * + * @example + * ```ts + * const { shapes, legendItems } = buildChartAnnotations(theme, { + * referenceLines: [{ axis: "x", value: 0.7, label: "cutoff" }], + * bands: [{ axis: "y", from: -3, to: 3, label: "±3σ" }], + * }); + * const layout = { ...base, shapes, showlegend: legendItems.length > 0 }; + * const data = [...traces, ...annotationLegendTraces(legendItems)]; + * ``` + */ +import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; + +/** Axis a reference line / band is measured against. */ +export type AnnotationAxis = "x" | "y"; + +/** Dash style for a reference line (maps to Plotly's `line.dash`). */ +export type ReferenceLineDash = + | "solid" + | "dot" + | "dash" + | "longdash" + | "dashdot" + | "longdashdot"; + +/** + * A threshold / reference line drawn across the full plot area. + * + * `axis: "x"` draws a **vertical** line at the given x `value`; `axis: "y"` + * draws a **horizontal** line at the given y `value`. The line always spans the + * opposite axis in full (via a `paper` reference), so it stays visible as the + * data range changes. + */ +export interface ReferenceLine { + /** Axis the `value` is measured on. `"x"` → vertical line, `"y"` → horizontal line. */ + axis: AnnotationAxis; + /** Position of the line, in data coordinates on `axis`. */ + value: number; + /** Optional label; shown as a legend item when provided. */ + label?: string; + /** Line color. Defaults to a theme-aware neutral that reads in both modes. */ + color?: string; + /** Line width in pixels. @default 2 */ + width?: number; + /** Dash style. @default "dash" */ + dash?: ReferenceLineDash; +} + +/** + * A shaded band (pass/fail region, ±σ envelope, …) spanning a from–to range on + * one axis and the full extent of the other. + * + * `axis: "x"` shades a **vertical** slab between two x values; `axis: "y"` + * shades a **horizontal** slab between two y values. `from`/`to` may be given in + * either order. + */ +export interface Band { + /** Axis the `from`/`to` bounds are measured on. */ + axis: AnnotationAxis; + /** One edge of the band, in data coordinates on `axis`. */ + from: number; + /** The other edge of the band, in data coordinates on `axis`. */ + to: number; + /** Optional label; shown as a legend item when provided. */ + label?: string; + /** Fill color. Defaults to a theme-aware neutral. */ + color?: string; + /** Fill opacity (0–1). @default 0.12 */ + opacity?: number; +} + +/** Opt-in annotation-layer configuration accepted by chart components. */ +export interface ChartAnnotationsConfig { + /** Threshold / reference lines to overlay. */ + referenceLines?: ReferenceLine[]; + /** Shaded from–to bands to overlay. */ + bands?: Band[]; +} + +/** Kind of annotation a legend entry represents. */ +export type AnnotationLegendKind = "line" | "band"; + +/** + * A chart-agnostic legend entry for a labeled reference line or band. Charts + * render these in whatever legend they own — via {@link annotationLegendTraces} + * for Plotly's legend, or directly for a custom (HTML) legend. + */ +export interface AnnotationLegendItem { + /** Label text shown in the legend. */ + label: string; + /** Swatch color (the line/band color). */ + color: string; + /** Whether the swatch reads as a line or a filled band. */ + kind: AnnotationLegendKind; + /** Dash style for `"line"` swatches. */ + dash?: ReferenceLineDash; + /** Line width for `"line"` swatches. */ + width?: number; + /** Fill opacity for `"band"` swatches. */ + opacity?: number; +} + +/** Output of {@link buildChartAnnotations}. */ +export interface ChartAnnotationLayer { + /** Plotly layout shapes for the lines/bands. Spread into `layout.shapes`. */ + shapes: Partial[]; + /** Legend entries for labeled lines/bands, in draw order (bands then lines). */ + legendItems: AnnotationLegendItem[]; +} + +const DEFAULT_LINE_WIDTH = 2; +const DEFAULT_LINE_DASH: ReferenceLineDash = "dash"; +const DEFAULT_BAND_OPACITY = 0.12; +/** Legend swatch opacity floor so faint bands stay visible in the legend. */ +const LEGEND_BAND_OPACITY_MIN = 0.35; + +/** Theme-aware default color for reference lines and bands. */ +const neutralColor = (theme: PlotlyThemeColors): string => theme.textColor; + +const referenceLineShape = ( + line: ReferenceLine, + color: string, +): Partial => { + const lineStyle = { + color, + width: line.width ?? DEFAULT_LINE_WIDTH, + dash: line.dash ?? DEFAULT_LINE_DASH, + }; + + // Vertical line (x): fixed x, spans full height via a paper y-reference. + // Horizontal line (y): fixed y, spans full width via a paper x-reference. + // `layer: "above"` keeps the threshold visible over bars/markers. + if (line.axis === "x") { + return { + type: "line", + xref: "x", + yref: "paper", + x0: line.value, + x1: line.value, + y0: 0, + y1: 1, + line: lineStyle, + layer: "above", + }; + } + return { + type: "line", + xref: "paper", + yref: "y", + x0: 0, + x1: 1, + y0: line.value, + y1: line.value, + line: lineStyle, + layer: "above", + }; +}; + +const bandShape = (band: Band, color: string): Partial => { + const low = Math.min(band.from, band.to); + const high = Math.max(band.from, band.to); + const fill = { + type: "rect" as const, + fillcolor: color, + opacity: band.opacity ?? DEFAULT_BAND_OPACITY, + line: { width: 0 }, + // Sit behind the data so bars/markers stay readable on top of the band. + layer: "below" as const, + }; + + // Vertical slab (x): bounded in x, full height via paper y-reference. + // Horizontal slab (y): bounded in y, full width via paper x-reference. + if (band.axis === "x") { + return { ...fill, xref: "x", yref: "paper", x0: low, x1: high, y0: 0, y1: 1 }; + } + return { ...fill, xref: "paper", yref: "y", x0: 0, x1: 1, y0: low, y1: high }; +}; + +/** + * Build Plotly `shapes` and legend entries for an opt-in annotation layer. + * + * Bands are emitted before reference lines so lines render on top of any + * overlapping band. Legend items are only produced when a `label` is provided. + * + * @param theme Resolved Plotly theme (from `usePlotlyTheme`) for legible defaults. + * @param config Reference lines and bands to render. + * @returns `{ shapes, legendItems }`. + */ +export function buildChartAnnotations( + theme: PlotlyThemeColors, + config: ChartAnnotationsConfig = {}, +): ChartAnnotationLayer { + const { referenceLines = [], bands = [] } = config; + const fallback = neutralColor(theme); + + const shapes: Partial[] = []; + const legendItems: AnnotationLegendItem[] = []; + + for (const band of bands) { + const color = band.color ?? fallback; + shapes.push(bandShape(band, color)); + if (band.label) { + legendItems.push({ + label: band.label, + color, + kind: "band", + opacity: band.opacity ?? DEFAULT_BAND_OPACITY, + }); + } + } + + for (const line of referenceLines) { + const color = line.color ?? fallback; + shapes.push(referenceLineShape(line, color)); + if (line.label) { + legendItems.push({ + label: line.label, + color, + kind: "line", + dash: line.dash ?? DEFAULT_LINE_DASH, + width: line.width ?? DEFAULT_LINE_WIDTH, + }); + } + } + + return { shapes, legendItems }; +} + +/** + * Convert {@link AnnotationLegendItem}s into legend-only Plotly traces (no data + * points, so nothing is drawn on the plot — they exist purely to add a swatch + + * label to Plotly's legend). Use with charts that render Plotly's own legend. + * + * Reference lines become a line swatch (matching color/width/dash); bands + * become a filled square swatch. The band swatch opacity is floored so faint + * fills remain visible at legend size. + */ +export function annotationLegendTraces( + legendItems: AnnotationLegendItem[], +): Partial[] { + return legendItems.map((item) => + item.kind === "line" + ? { + x: [null], + y: [null], + type: "scatter", + mode: "lines", + name: item.label, + line: { color: item.color, width: item.width ?? DEFAULT_LINE_WIDTH, dash: item.dash }, + showlegend: true, + hoverinfo: "skip", + } + : { + x: [null], + y: [null], + type: "scatter", + mode: "markers", + name: item.label, + marker: { + color: item.color, + symbol: "square", + size: 12, + opacity: Math.max(item.opacity ?? DEFAULT_BAND_OPACITY, LEGEND_BAND_OPACITY_MIN), + }, + showlegend: true, + hoverinfo: "skip", + }, + ); +}