From e20254a448e014cf06f90e1a6a3f7158b13e119e Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:45:46 -0400 Subject: [PATCH 1/5] feat(charts): add reference-line / band annotation layer Adds a shared, opt-in annotation layer for charts: threshold/reference lines and shaded from-to bands, rendered as themed Plotly shapes and labeled annotations. Wired as additive `referenceLines` / `bands` props on Histogram and InteractiveScatter (no breaking changes). Labels use a theme-aware opaque backing so they stay legible in light and dark mode. Includes Storybook stories with play tests (light + dark) on both charts, a unit test for the pure builder, and exports the types from src/index.ts. SW-2099 Co-Authored-By: Claude Opus 4.8 --- .../charts/Histogram/Histogram.stories.tsx | 97 ++++++ src/components/charts/Histogram/Histogram.tsx | 22 +- .../InteractiveScatter.stories.tsx | 83 ++++++ .../InteractiveScatter/InteractiveScatter.tsx | 12 + .../charts/InteractiveScatter/types.ts | 14 + src/index.ts | 1 + src/utils/chart-annotations.test.ts | 138 +++++++++ src/utils/chart-annotations.ts | 280 ++++++++++++++++++ 8 files changed, 646 insertions(+), 1 deletion(-) create mode 100644 src/utils/chart-annotations.test.ts create mode 100644 src/utils/chart-annotations.ts diff --git a/src/components/charts/Histogram/Histogram.stories.tsx b/src/components/charts/Histogram/Histogram.stories.tsx index bdc77690..74d20575 100644 --- a/src/components/charts/Histogram/Histogram.stories.tsx +++ b/src/components/charts/Histogram/Histogram.stories.tsx @@ -380,6 +380,103 @@ 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. Rendered as themed Plotly shapes with legible labels.", + }, + }, + }, + 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("Labels render on the annotation layer", async () => { + await waitFor(() => { + const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; + expect(text).toContain("cutoff"); + expect(text).toContain("pass"); + }); + }); + }, +}; + +export const ReferenceLineAndBandDarkMode: Story = { + name: "Reference Line And Band (Dark Mode)", + globals: { theme: "dark" }, + parameters: { + zephyr: { testCaseId: "" }, + docs: { + description: { + story: + "The same annotation layer in dark mode. Label tags keep an opaque backing and themed text so they stay legible over the plot.", + }, + }, + }, + args: { + dataSeries: { + x: generateNormalData(20, 8, 200), + name: "Torque", + }, + title: "Histogram with Cutoff (Dark)", + 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 }) => { + await step("Dark mode is active", async () => { + await waitFor(() => { + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + }); + + await step("Reference line and band shapes are drawn", async () => { + await waitFor(() => { + expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(2); + }); + }); + + await step("Labels remain legible in dark mode", async () => { + await waitFor(() => { + const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; + expect(text).toContain("cutoff"); + expect(text).toContain("pass"); + }); + }); + }, +}; + 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 094c99e0..e09bb8be 100644 --- a/src/components/charts/Histogram/Histogram.tsx +++ b/src/components/charts/Histogram/Histogram.tsx @@ -6,6 +6,7 @@ import { useChartTooltip } from "../ChartTooltip"; import { useElementSize } from "@/hooks/use-element-size"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; import { cn } from "@/lib/utils"; +import { buildChartAnnotations, type Band, type ReferenceLine } from "@/utils/chart-annotations"; import { CHART_COLORS } from "@/utils/colors"; import "./Histogram.scss"; @@ -44,6 +45,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 +125,8 @@ const Histogram: React.FC = ({ yTitle = "Frequency", bargap = 0.2, showDistributionLine = false, + referenceLines, + bands, }) => { const plotRef = useRef(null); const theme = usePlotlyTheme(); @@ -166,6 +179,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 +318,8 @@ const Histogram: React.FC = ({ bargap: bargap, paper_bgcolor: theme.paperBg, plot_bgcolor: theme.plotBg, + shapes: annotationLayer.shapes, + annotations: annotationLayer.annotations, }; const config = { @@ -324,7 +344,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. diff --git a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx index 7e840f8d..4a9d1857 100644 --- a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx +++ b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx @@ -927,3 +927,86 @@ 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. Here a `hit line` marks a y cutoff and + * a shaded band highlights the high-x region. + */ +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", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + expect(canvasElement.querySelectorAll(".scatterlayer .trace").length).toBe(1); + }); + + 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("Labels render on the annotation layer", async () => { + await waitFor(() => { + const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; + expect(text).toContain("hit line"); + expect(text).toContain("midpoint"); + expect(text).toContain("focus"); + }); + }); + }, + parameters: { + zephyr: { testCaseId: "" }, + }, +}; + +/** + * The annotation layer in dark mode — line and band colors, and the label + * tags' backing/text, stay legible against the dark plot. + */ +export const ReferenceLinesAndBandsDarkMode: Story = { + name: "Reference Lines And Bands (Dark Mode)", + globals: { theme: "dark" }, + args: { + data: BASIC_DATA, + title: "Scatter with Thresholds (Dark)", + ...DEFAULT_DIMS, + referenceLines: [{ axis: "y", value: 70, color: "#FD972F", label: "hit line" }], + bands: [{ axis: "x", from: 70, to: 100, color: "#038599", label: "focus" }], + }, + play: async ({ canvasElement, step }) => { + await step("Dark mode is active", async () => { + await waitFor(() => { + expect(document.documentElement.classList.contains("dark")).toBe(true); + }); + }); + + await step("Reference line and band shapes are drawn", async () => { + await waitFor(() => { + expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(2); + }); + }); + + await step("Labels remain legible in dark mode", async () => { + await waitFor(() => { + const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; + expect(text).toContain("hit line"); + expect(text).toContain("focus"); + }); + }); + }, + parameters: { + zephyr: { testCaseId: "" }, + }, +}; diff --git a/src/components/charts/InteractiveScatter/InteractiveScatter.tsx b/src/components/charts/InteractiveScatter/InteractiveScatter.tsx index 7744514d..c174b11d 100644 --- a/src/components/charts/InteractiveScatter/InteractiveScatter.tsx +++ b/src/components/charts/InteractiveScatter/InteractiveScatter.tsx @@ -21,6 +21,7 @@ import { import type { AxisConfig, InteractiveScatterProps, SelectionMode, TooltipConfig } from "./types"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; +import { 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 @@ -69,6 +70,8 @@ const InteractiveScatter: React.FC = ({ height = 600, showColorBar = true, className, + referenceLines, + bands, }) => { const plotRef = useRef(null); const theme = usePlotlyTheme(); @@ -205,6 +208,12 @@ const InteractiveScatter: React.FC = ({ return config; }, [sizes, shapes, colorMapping, plotlyColorscale, plotlyColors, showColorBar, processedData, colors]); + // Opt-in threshold lines / shaded bands, as themed Plotly shapes + labels. + const annotationLayer = useMemo( + () => buildChartAnnotations(theme, { referenceLines, bands }), + [theme, referenceLines, bands], + ); + // Create Plotly plot useEffect(() => { const currentRef = plotRef.current; @@ -262,6 +271,8 @@ const InteractiveScatter: React.FC = ({ enableBoxSelection, theme, }); + layout.shapes = annotationLayer.shapes; + layout.annotations = annotationLayer.annotations; const config: Partial = { responsive: true, @@ -378,6 +389,7 @@ const InteractiveScatter: React.FC = ({ nativeTooltip, bindTooltip, theme, + annotationLayer, ]); // Apply selection state to Plotly diff --git a/src/components/charts/InteractiveScatter/types.ts b/src/components/charts/InteractiveScatter/types.ts index 3a3453d6..52b898a8 100644 --- a/src/components/charts/InteractiveScatter/types.ts +++ b/src/components/charts/InteractiveScatter/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 InteractiveScatterProps { * 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 cd1a5d89..4842c13f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -109,3 +109,4 @@ export * from "@/components/ai/tool"; // Utils export * from "@/utils/colors"; +export * from "@/utils/chart-annotations"; diff --git a/src/utils/chart-annotations.test.ts b/src/utils/chart-annotations.test.ts new file mode 100644 index 00000000..d0c33f68 --- /dev/null +++ b/src/utils/chart-annotations.test.ts @@ -0,0 +1,138 @@ +import { describe, it, expect } from "vitest"; + +import { 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, +}; + +const DARK: PlotlyThemeColors = { ...LIGHT, textColor: "rgba(255, 255, 255, 0.9)", isDark: true }; + +describe("buildChartAnnotations", () => { + it("returns empty layers when no config is provided", () => { + const { shapes, annotations } = buildChartAnnotations(LIGHT); + expect(shapes).toEqual([]); + expect(annotations).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 annotations only when a label is present", () => { + const { annotations } = 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(annotations).toHaveLength(2); + const texts = annotations.map((a) => a.text); + expect(texts).toContain("cutoff"); + expect(texts).toContain("±3σ"); + }); + + it("centers a band label at the midpoint of its range", () => { + const { annotations } = buildChartAnnotations(LIGHT, { + bands: [{ axis: "x", from: 10, to: 30, label: "pass" }], + }); + expect(annotations[0].x).toBe(20); + }); + + 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("uses a legible, opaque label backing in each theme", () => { + const light = buildChartAnnotations(LIGHT, { + referenceLines: [{ axis: "x", value: 1, label: "x" }], + }).annotations[0]; + const dark = buildChartAnnotations(DARK, { + referenceLines: [{ axis: "x", value: 1, label: "x" }], + }).annotations[0]; + + expect(light.bgcolor).toBe("rgba(255, 255, 255, 0.85)"); + expect(light.font?.color).toBe(LIGHT.textColor); + expect(dark.bgcolor).toBe("rgba(15, 23, 42, 0.85)"); + expect(dark.font?.color).toBe(DARK.textColor); + // Label border echoes the annotated line's color. + expect(light.bordercolor).toBe(LIGHT.textColor); + }); +}); diff --git a/src/utils/chart-annotations.ts b/src/utils/chart-annotations.ts new file mode 100644 index 00000000..52ded0ce --- /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` and `annotations`. + * + * 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 and + * merges the result into the Plotly layout. + * + * @example + * ```ts + * const { shapes, annotations } = buildChartAnnotations(theme, { + * referenceLines: [{ axis: "x", value: 0.7, label: "cutoff" }], + * bands: [{ axis: "y", from: -3, to: 3, label: "±3σ" }], + * }); + * const layout = { ...base, shapes, annotations }; + * ``` + */ +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"; + +/** + * 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 text tag rendered on the line (themed for light/dark). */ + 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 text tag rendered on the band (themed for light/dark). */ + 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[]; +} + +/** Plotly layout fragments produced by {@link buildChartAnnotations}. */ +export interface ChartAnnotationLayer { + shapes: Partial[]; + annotations: Partial[]; +} + +const DEFAULT_LINE_WIDTH = 2; +const DEFAULT_LINE_DASH: ReferenceLineDash = "dash"; +const DEFAULT_BAND_OPACITY = 0.12; + +/** + * Solid backing color for label tags, chosen for legibility in each theme. + * The chart's paper background is transparent, so annotation labels need their + * own opaque-ish fill to stay readable over bars, points, and grid lines. + */ +const labelBackground = (theme: PlotlyThemeColors): string => + theme.isDark ? "rgba(15, 23, 42, 0.85)" : "rgba(255, 255, 255, 0.85)"; + +/** Theme-aware default color for reference lines and bands. */ +const neutralColor = (theme: PlotlyThemeColors): string => theme.textColor; + +/** + * Shared label styling so every tag reads consistently and legibly in both + * light and dark mode. The tag's border echoes the line/band color, tying the + * label to what it annotates. + */ +const labelStyle = ( + theme: PlotlyThemeColors, + borderColor: string, +): Partial => ({ + showarrow: false, + font: { + family: "Inter, sans-serif", + size: 12, + color: theme.textColor, + }, + bgcolor: labelBackground(theme), + bordercolor: borderColor, + borderwidth: 1, + borderpad: 3, +}); + +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 referenceLineLabel = ( + line: ReferenceLine, + color: string, + theme: PlotlyThemeColors, +): Partial => { + if (line.axis === "x") { + // Sit just above the top edge of the plot area, centered on the line. + return { + ...labelStyle(theme, color), + text: line.label, + xref: "x", + yref: "paper", + x: line.value, + y: 1, + xanchor: "center", + yanchor: "bottom", + }; + } + // Pin to the right edge of the plot area, centered on the line. + return { + ...labelStyle(theme, color), + text: line.label, + xref: "paper", + yref: "y", + x: 1, + y: line.value, + xanchor: "right", + yanchor: "middle", + }; +}; + +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 }; +}; + +const bandLabel = ( + band: Band, + color: string, + theme: PlotlyThemeColors, +): Partial => { + const mid = (band.from + band.to) / 2; + if (band.axis === "x") { + return { + ...labelStyle(theme, color), + text: band.label, + xref: "x", + yref: "paper", + x: mid, + y: 1, + xanchor: "center", + yanchor: "bottom", + }; + } + return { + ...labelStyle(theme, color), + text: band.label, + xref: "paper", + yref: "y", + x: 1, + y: mid, + xanchor: "right", + yanchor: "middle", + }; +}; + +/** + * Build Plotly `shapes` and `annotations` for an opt-in annotation layer. + * + * Bands are emitted before reference lines so lines render on top of any + * overlapping band. Labels 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, annotations }` to spread into a Plotly layout. + */ +export function buildChartAnnotations( + theme: PlotlyThemeColors, + config: ChartAnnotationsConfig = {}, +): ChartAnnotationLayer { + const { referenceLines = [], bands = [] } = config; + const fallback = neutralColor(theme); + + const shapes: Partial[] = []; + const annotations: Partial[] = []; + + for (const band of bands) { + const color = band.color ?? fallback; + shapes.push(bandShape(band, color)); + if (band.label) { + annotations.push(bandLabel(band, color, theme)); + } + } + + for (const line of referenceLines) { + const color = line.color ?? fallback; + shapes.push(referenceLineShape(line, color)); + if (line.label) { + annotations.push(referenceLineLabel(line, color, theme)); + } + } + + return { shapes, annotations }; +} From ef180ec96ee651783099ea40dbc71a647cdf17c3 Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Tue, 7 Jul 2026 21:52:39 -0400 Subject: [PATCH 2/5] fix(charts): address review on annotation layer - Add "longdashdot" to ReferenceLineDash so the full set of Plotly dash styles is accepted. - Re-export usePlotlyTheme / PlotlyThemeColors from the package entrypoint so buildChartAnnotations' signature is nameable without a deep import. Co-Authored-By: Claude Opus 4.8 --- src/index.ts | 3 +++ src/utils/chart-annotations.ts | 8 +++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/index.ts b/src/index.ts index 4842c13f..295c84f8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -107,6 +107,9 @@ 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"; diff --git a/src/utils/chart-annotations.ts b/src/utils/chart-annotations.ts index 52ded0ce..2e64e4e2 100644 --- a/src/utils/chart-annotations.ts +++ b/src/utils/chart-annotations.ts @@ -22,7 +22,13 @@ import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; 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"; +export type ReferenceLineDash = + | "solid" + | "dot" + | "dash" + | "longdash" + | "dashdot" + | "longdashdot"; /** * A threshold / reference line drawn across the full plot area. From 6aa6bf5a18e1c0291b0a6c54bd9348c74cd42cbf Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Tue, 7 Jul 2026 22:01:50 -0400 Subject: [PATCH 3/5] fix(charts): make dark-mode annotation play tests deterministic The `globals.theme` toggle sets `.dark` in the Storybook UI, but the @storybook/addon-vitest runner doesn't propagate it to documentElement, so `useIsDark` never saw it and the "dark mode is active" assertion failed in CI. Drive the class from the play function (with cleanup) so the component genuinely renders dark during the test, and drop the brittle infrastructure assertion. Co-Authored-By: Claude Opus 4.8 --- .../charts/Histogram/Histogram.stories.tsx | 38 +++++++++++-------- .../InteractiveScatter.stories.tsx | 38 +++++++++++-------- 2 files changed, 44 insertions(+), 32 deletions(-) diff --git a/src/components/charts/Histogram/Histogram.stories.tsx b/src/components/charts/Histogram/Histogram.stories.tsx index 74d20575..36710c9e 100644 --- a/src/components/charts/Histogram/Histogram.stories.tsx +++ b/src/components/charts/Histogram/Histogram.stories.tsx @@ -455,25 +455,31 @@ export const ReferenceLineAndBandDarkMode: Story = { referenceLines: [{ axis: "x", value: 24, color: "#E15759", label: "cutoff" }], }, play: async ({ canvasElement, step }) => { - await step("Dark mode is active", async () => { - await waitFor(() => { - expect(document.documentElement.classList.contains("dark")).toBe(true); + // The `globals.theme` toggle drives dark mode in the Storybook UI, but the + // Vitest/Playwright runner doesn't propagate it to `documentElement` (what + // `useIsDark` observes), so force the class here for a deterministic dark + // render, then restore it. + const root = document.documentElement; + const hadDark = root.classList.contains("dark"); + root.classList.add("dark"); + + try { + await step("Reference line and band shapes are drawn", async () => { + await waitFor(() => { + expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(2); + }); }); - }); - await step("Reference line and band shapes are drawn", async () => { - await waitFor(() => { - expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(2); + await step("Labels remain legible in dark mode", async () => { + await waitFor(() => { + const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; + expect(text).toContain("cutoff"); + expect(text).toContain("pass"); + }); }); - }); - - await step("Labels remain legible in dark mode", async () => { - await waitFor(() => { - const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; - expect(text).toContain("cutoff"); - expect(text).toContain("pass"); - }); - }); + } finally { + if (!hadDark) root.classList.remove("dark"); + } }, }; diff --git a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx index 4a9d1857..6ffdd46a 100644 --- a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx +++ b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx @@ -986,25 +986,31 @@ export const ReferenceLinesAndBandsDarkMode: Story = { bands: [{ axis: "x", from: 70, to: 100, color: "#038599", label: "focus" }], }, play: async ({ canvasElement, step }) => { - await step("Dark mode is active", async () => { - await waitFor(() => { - expect(document.documentElement.classList.contains("dark")).toBe(true); - }); - }); - - await step("Reference line and band shapes are drawn", async () => { - await waitFor(() => { - expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(2); + // The `globals.theme` toggle drives dark mode in the Storybook UI, but the + // Vitest/Playwright runner doesn't propagate it to `documentElement` (what + // `useIsDark` observes), so force the class here for a deterministic dark + // render, then restore it. + const root = document.documentElement; + const hadDark = root.classList.contains("dark"); + root.classList.add("dark"); + + try { + await step("Reference line and band shapes are drawn", async () => { + await waitFor(() => { + expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(2); + }); }); - }); - await step("Labels remain legible in dark mode", async () => { - await waitFor(() => { - const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; - expect(text).toContain("hit line"); - expect(text).toContain("focus"); + await step("Labels remain legible in dark mode", async () => { + await waitFor(() => { + const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; + expect(text).toContain("hit line"); + expect(text).toContain("focus"); + }); }); - }); + } finally { + if (!hadDark) root.classList.remove("dark"); + } }, parameters: { zephyr: { testCaseId: "" }, From d72e29bb6b03eec629c1bb7cd2741168634e611e Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Wed, 8 Jul 2026 09:57:06 -0400 Subject: [PATCH 4/5] test(charts): drop dedicated dark-mode annotation stories Dark mode is viewable on any story via the Storybook theme toggle, so separate dark-mode stories are redundant. Keep the light reference-line + band stories for both charts. Co-Authored-By: Claude Opus 4.8 --- .../charts/Histogram/Histogram.stories.tsx | 54 ------------------- .../InteractiveScatter.stories.tsx | 46 ---------------- 2 files changed, 100 deletions(-) diff --git a/src/components/charts/Histogram/Histogram.stories.tsx b/src/components/charts/Histogram/Histogram.stories.tsx index 36710c9e..875aeaac 100644 --- a/src/components/charts/Histogram/Histogram.stories.tsx +++ b/src/components/charts/Histogram/Histogram.stories.tsx @@ -429,60 +429,6 @@ export const WithReferenceLineAndBand: Story = { }, }; -export const ReferenceLineAndBandDarkMode: Story = { - name: "Reference Line And Band (Dark Mode)", - globals: { theme: "dark" }, - parameters: { - zephyr: { testCaseId: "" }, - docs: { - description: { - story: - "The same annotation layer in dark mode. Label tags keep an opaque backing and themed text so they stay legible over the plot.", - }, - }, - }, - args: { - dataSeries: { - x: generateNormalData(20, 8, 200), - name: "Torque", - }, - title: "Histogram with Cutoff (Dark)", - 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 }) => { - // The `globals.theme` toggle drives dark mode in the Storybook UI, but the - // Vitest/Playwright runner doesn't propagate it to `documentElement` (what - // `useIsDark` observes), so force the class here for a deterministic dark - // render, then restore it. - const root = document.documentElement; - const hadDark = root.classList.contains("dark"); - root.classList.add("dark"); - - try { - await step("Reference line and band shapes are drawn", async () => { - await waitFor(() => { - expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(2); - }); - }); - - await step("Labels remain legible in dark mode", async () => { - await waitFor(() => { - const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; - expect(text).toContain("cutoff"); - expect(text).toContain("pass"); - }); - }); - } finally { - if (!hadDark) root.classList.remove("dark"); - } - }, -}; - export const ContainerFilled: Story = { name: "Container Filled (responsive)", parameters: { diff --git a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx index 6ffdd46a..e05f5472 100644 --- a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx +++ b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx @@ -970,49 +970,3 @@ export const WithReferenceLinesAndBands: Story = { zephyr: { testCaseId: "" }, }, }; - -/** - * The annotation layer in dark mode — line and band colors, and the label - * tags' backing/text, stay legible against the dark plot. - */ -export const ReferenceLinesAndBandsDarkMode: Story = { - name: "Reference Lines And Bands (Dark Mode)", - globals: { theme: "dark" }, - args: { - data: BASIC_DATA, - title: "Scatter with Thresholds (Dark)", - ...DEFAULT_DIMS, - referenceLines: [{ axis: "y", value: 70, color: "#FD972F", label: "hit line" }], - bands: [{ axis: "x", from: 70, to: 100, color: "#038599", label: "focus" }], - }, - play: async ({ canvasElement, step }) => { - // The `globals.theme` toggle drives dark mode in the Storybook UI, but the - // Vitest/Playwright runner doesn't propagate it to `documentElement` (what - // `useIsDark` observes), so force the class here for a deterministic dark - // render, then restore it. - const root = document.documentElement; - const hadDark = root.classList.contains("dark"); - root.classList.add("dark"); - - try { - await step("Reference line and band shapes are drawn", async () => { - await waitFor(() => { - expect(canvasElement.querySelectorAll(".shapelayer path").length).toBeGreaterThanOrEqual(2); - }); - }); - - await step("Labels remain legible in dark mode", async () => { - await waitFor(() => { - const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; - expect(text).toContain("hit line"); - expect(text).toContain("focus"); - }); - }); - } finally { - if (!hadDark) root.classList.remove("dark"); - } - }, - parameters: { - zephyr: { testCaseId: "" }, - }, -}; From 5dcede0ff57668bc5502845ac4b96efa04945477 Mon Sep 17 00:00:00 2001 From: Oseer Williams <265368733+owilliams-tetrascience@users.noreply.github.com> Date: Wed, 8 Jul 2026 10:17:00 -0400 Subject: [PATCH 5/5] feat(charts): show reference-line / band labels as legend items Labeled reference lines and bands now surface in each chart's legend instead of as on-plot text tags. The shared builder returns chart- agnostic legendItems; InteractiveScatter renders them via Plotly's legend (annotationLegendTraces), and Histogram folds them into its custom HTML legend (line swatch for lines, filled box for bands). Co-Authored-By: Claude Opus 4.8 --- .../charts/Histogram/Histogram.scss | 9 + .../charts/Histogram/Histogram.stories.tsx | 14 +- src/components/charts/Histogram/Histogram.tsx | 65 +++++- .../InteractiveScatter.stories.tsx | 14 +- .../InteractiveScatter/InteractiveScatter.tsx | 26 ++- src/utils/chart-annotations.test.ts | 84 ++++--- src/utils/chart-annotations.ts | 206 +++++++++--------- 7 files changed, 258 insertions(+), 160 deletions(-) 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 875aeaac..25c42dcc 100644 --- a/src/components/charts/Histogram/Histogram.stories.tsx +++ b/src/components/charts/Histogram/Histogram.stories.tsx @@ -387,7 +387,7 @@ export const WithReferenceLineAndBand: Story = { docs: { description: { story: - "Opt-in annotation layer: a shaded pass `band` and a `cutoff` reference line overlaid on the distribution. Rendered as themed Plotly shapes with legible labels.", + "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.", }, }, }, @@ -419,12 +419,12 @@ export const WithReferenceLineAndBand: Story = { }); }); - await step("Labels render on the annotation layer", async () => { - await waitFor(() => { - const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; - expect(text).toContain("cutoff"); - expect(text).toContain("pass"); - }); + 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(); }); }, }; diff --git a/src/components/charts/Histogram/Histogram.tsx b/src/components/charts/Histogram/Histogram.tsx index e09bb8be..9ab4d2eb 100644 --- a/src/components/charts/Histogram/Histogram.tsx +++ b/src/components/charts/Histogram/Histogram.tsx @@ -6,13 +6,27 @@ import { useChartTooltip } from "../ChartTooltip"; import { useElementSize } from "@/hooks/use-element-size"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; import { cn } from "@/lib/utils"; -import { buildChartAnnotations, type Band, type ReferenceLine } from "@/utils/chart-annotations"; +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; @@ -319,7 +333,6 @@ const Histogram: React.FC = ({ paper_bgcolor: theme.paperBg, plot_bgcolor: theme.plotBg, shapes: annotationLayer.shapes, - annotations: annotationLayer.annotations, }; const config = { @@ -370,13 +383,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 && }
)); @@ -417,7 +464,7 @@ const Histogram: React.FC = ({ }} /> - + {tooltipElement} diff --git a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx index e05f5472..cc3c410f 100644 --- a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx +++ b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx @@ -930,8 +930,8 @@ export const ThemedTooltip: Story = { /** * Opt-in annotation layer: threshold / reference lines and shaded from–to - * bands, drawn as themed Plotly shapes. Here a `hit line` marks a y cutoff and - * a shaded band highlights the high-x region. + * bands, drawn as themed Plotly shapes. Labeled lines/bands (`hit line`, + * `midpoint`, `focus`) surface in the chart legend. */ export const WithReferenceLinesAndBands: Story = { args: { @@ -945,9 +945,11 @@ export const WithReferenceLinesAndBands: Story = { bands: [{ axis: "x", from: 70, to: 100, color: "#038599", label: "focus" }], }, play: async ({ canvasElement, step }) => { - await step("Chart renders", async () => { + await step("Chart renders with data points", async () => { expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - expect(canvasElement.querySelectorAll(".scatterlayer .trace").length).toBe(1); + await waitFor(() => { + expect(canvasElement.querySelectorAll(".scatterlayer .points path").length).toBeGreaterThan(0); + }); }); await step("Two reference lines and one band are drawn", async () => { @@ -957,9 +959,9 @@ export const WithReferenceLinesAndBands: Story = { }); }); - await step("Labels render on the annotation layer", async () => { + await step("Labeled lines and band appear as legend items", async () => { await waitFor(() => { - const text = canvasElement.querySelector(".infolayer")?.textContent ?? ""; + const text = canvasElement.querySelector(".infolayer .legend")?.textContent ?? ""; expect(text).toContain("hit line"); expect(text).toContain("midpoint"); expect(text).toContain("focus"); diff --git a/src/components/charts/InteractiveScatter/InteractiveScatter.tsx b/src/components/charts/InteractiveScatter/InteractiveScatter.tsx index c174b11d..1d4945ac 100644 --- a/src/components/charts/InteractiveScatter/InteractiveScatter.tsx +++ b/src/components/charts/InteractiveScatter/InteractiveScatter.tsx @@ -21,7 +21,7 @@ import { import type { AxisConfig, InteractiveScatterProps, SelectionMode, TooltipConfig } from "./types"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; -import { buildChartAnnotations } from "@/utils/chart-annotations"; +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 @@ -208,11 +208,16 @@ const InteractiveScatter: React.FC = ({ return config; }, [sizes, shapes, colorMapping, plotlyColorscale, plotlyColors, showColorBar, processedData, colors]); - // Opt-in threshold lines / shaded bands, as themed Plotly shapes + labels. + // 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(() => { @@ -256,7 +261,10 @@ const InteractiveScatter: 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({ @@ -272,7 +280,16 @@ const InteractiveScatter: React.FC = ({ theme, }); layout.shapes = annotationLayer.shapes; - layout.annotations = annotationLayer.annotations; + // 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 +407,7 @@ const InteractiveScatter: React.FC = ({ bindTooltip, theme, annotationLayer, + annotationLegend, ]); // Apply selection state to Plotly diff --git a/src/utils/chart-annotations.test.ts b/src/utils/chart-annotations.test.ts index d0c33f68..607dfb9f 100644 --- a/src/utils/chart-annotations.test.ts +++ b/src/utils/chart-annotations.test.ts @@ -1,6 +1,6 @@ import { describe, it, expect } from "vitest"; -import { buildChartAnnotations } from "./chart-annotations"; +import { annotationLegendTraces, buildChartAnnotations } from "./chart-annotations"; import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; @@ -18,13 +18,11 @@ const LIGHT: PlotlyThemeColors = { isDark: false, }; -const DARK: PlotlyThemeColors = { ...LIGHT, textColor: "rgba(255, 255, 255, 0.9)", isDark: true }; - describe("buildChartAnnotations", () => { it("returns empty layers when no config is provided", () => { - const { shapes, annotations } = buildChartAnnotations(LIGHT); + const { shapes, legendItems } = buildChartAnnotations(LIGHT); expect(shapes).toEqual([]); - expect(annotations).toEqual([]); + expect(legendItems).toEqual([]); }); it("builds a vertical shape for an x-axis reference line", () => { @@ -90,25 +88,28 @@ describe("buildChartAnnotations", () => { expect(shapes[0].opacity).toBe(0.3); }); - it("emits annotations only when a label is present", () => { - const { annotations } = buildChartAnnotations(LIGHT, { + 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(annotations).toHaveLength(2); - const texts = annotations.map((a) => a.text); - expect(texts).toContain("cutoff"); - expect(texts).toContain("±3σ"); + expect(legendItems).toHaveLength(2); + // Bands come before reference lines in draw order. + expect(legendItems.map((i) => i.label)).toEqual(["±3σ", "cutoff"]); }); - it("centers a band label at the midpoint of its range", () => { - const { annotations } = buildChartAnnotations(LIGHT, { - bands: [{ axis: "x", from: 10, to: 30, label: "pass" }], + 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 }], }); - expect(annotations[0].x).toBe(20); + 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", () => { @@ -120,19 +121,46 @@ describe("buildChartAnnotations", () => { expect(shapes[1].type).toBe("line"); }); - it("uses a legible, opaque label backing in each theme", () => { - const light = buildChartAnnotations(LIGHT, { - referenceLines: [{ axis: "x", value: 1, label: "x" }], - }).annotations[0]; - const dark = buildChartAnnotations(DARK, { + it("falls back to a theme-aware neutral color when none is given", () => { + const { legendItems } = buildChartAnnotations(LIGHT, { referenceLines: [{ axis: "x", value: 1, label: "x" }], - }).annotations[0]; - - expect(light.bgcolor).toBe("rgba(255, 255, 255, 0.85)"); - expect(light.font?.color).toBe(LIGHT.textColor); - expect(dark.bgcolor).toBe("rgba(15, 23, 42, 0.85)"); - expect(dark.font?.color).toBe(DARK.textColor); - // Label border echoes the annotated line's color. - expect(light.bordercolor).toBe(LIGHT.textColor); + }); + 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 index 2e64e4e2..07f99b85 100644 --- a/src/utils/chart-annotations.ts +++ b/src/utils/chart-annotations.ts @@ -1,19 +1,23 @@ /** * Shared annotation layer for charts — threshold/reference lines and shaded - * bands, rendered as themed Plotly layout `shapes` and `annotations`. + * 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 and - * merges the result into the Plotly layout. + * 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, annotations } = buildChartAnnotations(theme, { + * 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, annotations }; + * const layout = { ...base, shapes, showlegend: legendItems.length > 0 }; + * const data = [...traces, ...annotationLegendTraces(legendItems)]; * ``` */ import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; @@ -43,7 +47,7 @@ export interface ReferenceLine { axis: AnnotationAxis; /** Position of the line, in data coordinates on `axis`. */ value: number; - /** Optional text tag rendered on the line (themed for light/dark). */ + /** 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; @@ -68,7 +72,7 @@ export interface Band { from: number; /** The other edge of the band, in data coordinates on `axis`. */ to: number; - /** Optional text tag rendered on the band (themed for light/dark). */ + /** Optional label; shown as a legend item when provided. */ label?: string; /** Fill color. Defaults to a theme-aware neutral. */ color?: string; @@ -84,48 +88,46 @@ export interface ChartAnnotationsConfig { bands?: Band[]; } -/** Plotly layout fragments produced by {@link buildChartAnnotations}. */ +/** 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[]; - annotations: 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; - -/** - * Solid backing color for label tags, chosen for legibility in each theme. - * The chart's paper background is transparent, so annotation labels need their - * own opaque-ish fill to stay readable over bars, points, and grid lines. - */ -const labelBackground = (theme: PlotlyThemeColors): string => - theme.isDark ? "rgba(15, 23, 42, 0.85)" : "rgba(255, 255, 255, 0.85)"; +/** 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; -/** - * Shared label styling so every tag reads consistently and legibly in both - * light and dark mode. The tag's border echoes the line/band color, tying the - * label to what it annotates. - */ -const labelStyle = ( - theme: PlotlyThemeColors, - borderColor: string, -): Partial => ({ - showarrow: false, - font: { - family: "Inter, sans-serif", - size: 12, - color: theme.textColor, - }, - bgcolor: labelBackground(theme), - bordercolor: borderColor, - borderwidth: 1, - borderpad: 3, -}); - const referenceLineShape = ( line: ReferenceLine, color: string, @@ -165,37 +167,6 @@ const referenceLineShape = ( }; }; -const referenceLineLabel = ( - line: ReferenceLine, - color: string, - theme: PlotlyThemeColors, -): Partial => { - if (line.axis === "x") { - // Sit just above the top edge of the plot area, centered on the line. - return { - ...labelStyle(theme, color), - text: line.label, - xref: "x", - yref: "paper", - x: line.value, - y: 1, - xanchor: "center", - yanchor: "bottom", - }; - } - // Pin to the right edge of the plot area, centered on the line. - return { - ...labelStyle(theme, color), - text: line.label, - xref: "paper", - yref: "y", - x: 1, - y: line.value, - xanchor: "right", - yanchor: "middle", - }; -}; - const bandShape = (band: Band, color: string): Partial => { const low = Math.min(band.from, band.to); const high = Math.max(band.from, band.to); @@ -216,45 +187,15 @@ const bandShape = (band: Band, color: string): Partial => { return { ...fill, xref: "paper", yref: "y", x0: 0, x1: 1, y0: low, y1: high }; }; -const bandLabel = ( - band: Band, - color: string, - theme: PlotlyThemeColors, -): Partial => { - const mid = (band.from + band.to) / 2; - if (band.axis === "x") { - return { - ...labelStyle(theme, color), - text: band.label, - xref: "x", - yref: "paper", - x: mid, - y: 1, - xanchor: "center", - yanchor: "bottom", - }; - } - return { - ...labelStyle(theme, color), - text: band.label, - xref: "paper", - yref: "y", - x: 1, - y: mid, - xanchor: "right", - yanchor: "middle", - }; -}; - /** - * Build Plotly `shapes` and `annotations` for an opt-in annotation layer. + * 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. Labels are only produced when a `label` is provided. + * 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, annotations }` to spread into a Plotly layout. + * @returns `{ shapes, legendItems }`. */ export function buildChartAnnotations( theme: PlotlyThemeColors, @@ -264,13 +205,18 @@ export function buildChartAnnotations( const fallback = neutralColor(theme); const shapes: Partial[] = []; - const annotations: Partial[] = []; + const legendItems: AnnotationLegendItem[] = []; for (const band of bands) { const color = band.color ?? fallback; shapes.push(bandShape(band, color)); if (band.label) { - annotations.push(bandLabel(band, color, theme)); + legendItems.push({ + label: band.label, + color, + kind: "band", + opacity: band.opacity ?? DEFAULT_BAND_OPACITY, + }); } } @@ -278,9 +224,57 @@ export function buildChartAnnotations( const color = line.color ?? fallback; shapes.push(referenceLineShape(line, color)); if (line.label) { - annotations.push(referenceLineLabel(line, color, theme)); + legendItems.push({ + label: line.label, + color, + kind: "line", + dash: line.dash ?? DEFAULT_LINE_DASH, + width: line.width ?? DEFAULT_LINE_WIDTH, + }); } } - return { shapes, annotations }; + 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", + }, + ); }