From 8a06f8a176e8e83de0bc587a9efeeb116abef152 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Mon, 4 May 2026 08:55:52 -0500 Subject: [PATCH 01/30] Adds range annotations to ChromatogramChart --- .../ChromatogramChart.stories.tsx | 215 ++++++++++++++++++ .../ChromatogramChart/ChromatogramChart.tsx | 20 +- .../charts/ChromatogramChart/constants.ts | 20 ++ .../charts/ChromatogramChart/index.ts | 1 + .../ChromatogramChart/rangeAnnotations.ts | 173 ++++++++++++++ .../charts/ChromatogramChart/types.ts | 53 +++++ 6 files changed, 480 insertions(+), 2 deletions(-) create mode 100644 src/components/charts/ChromatogramChart/rangeAnnotations.ts diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index 55f610e3..6596a96b 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -4,6 +4,7 @@ import { ChromatogramChart, type ChromatogramSeries, type PeakAnnotation, + type RangeAnnotation, } from "./ChromatogramChart"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -474,6 +475,38 @@ export const UserDefinedPeakBoundaries: Story = { }, }; +// --------------------------------------------------------------------------- +// Range annotation data +// --------------------------------------------------------------------------- + +// IgG charge-variant chromatogram — three fractions across a single peak cluster. +const chargeVariantData = generateChromatogramData([ + { rt: 5.2, height: 120, width: 0.25 }, + { rt: 5.8, height: 420, width: 0.3 }, + { rt: 6.4, height: 180, width: 0.25 }, + { rt: 7.1, height: 80, width: 0.2 }, +]); + +// Three adjacent fractions matching the screenshot layout +const adjacentRangeAnnotations: RangeAnnotation[] = [ + { label: "Acidic-01", startX: 4.5, endX: 5.5, color: "#8E8E93" }, + { label: "Acidic-02", startX: 5.5, endX: 6.7, color: "#FF3B30" }, + { label: "Main", startX: 6.7, endX: 7.8, color: "#34C759" }, +]; + +// Two annotations covering exactly the same range — forces auto lane stacking +const sameRangeAnnotations: RangeAnnotation[] = [ + { label: "IgG Fraction", startX: 5.0, endX: 7.2, color: "#007AFF" }, + { label: "Caffeine Window", startX: 5.0, endX: 7.2, color: "#FF9500" }, +]; + +// Nested ranges: one broad outer bracket + two narrower inner sub-regions +const nestedRangeAnnotations: RangeAnnotation[] = [ + { label: "Acidic Region", startX: 4.5, endX: 7.5, color: "#8E8E93" }, + { label: "Acidic-01", startX: 4.5, endX: 5.5, color: "#FF6B6B" }, + { label: "Acidic-02", startX: 5.6, endX: 6.8, color: "#FF3B30" }, +]; + /** * Combining automatic peak detection with user-defined annotations. Auto-detected peaks * show computed areas while user annotations provide custom labels. Both can have @@ -543,3 +576,185 @@ export const CombinedAutoAndUserPeaks: Story = { zephyr: { testCaseId: "SW-T1115" }, }, }; + +/** + * Three adjacent, non-overlapping fraction windows rendered at the top of the plot + * area — replicating the charge-variant labeling style shown in the design screenshot. + * All bars share lane 0 and sit flush with the top of the plot in paper-space. + */ +export const RangeAnnotationsBasic: Story = { + args: { + series: [{ ...chargeVariantData, name: "IgG Sample" }], + title: "Charge Variant Fractions", + rangeAnnotations: adjacentRangeAnnotations, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect(canvas.getByText("Charge Variant Fractions")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Range annotation labels are rendered", async () => { + const labels = canvasElement.querySelectorAll(".annotation-text"); + expect(labels.length).toBeGreaterThanOrEqual(3); + }); + }, + parameters: { + docs: { + description: { + story: + "Adjacent fraction windows with no overlap. All three fit in lane 0 and are rendered as paper-space bars flush with the top of the plot. Colors are supplied per-annotation.", + }, + }, + zephyr: { testCaseId: "SW-T1116" }, + }, +}; + +/** + * Two annotations sharing exactly the same x-range. The auto lane-assignment algorithm + * detects the overlap and places them in separate lanes (lane 0 on top, lane 1 below). + */ +export const RangeAnnotationsSameRange: Story = { + args: { + series: [{ ...chargeVariantData, name: "IgG Sample" }], + title: "Same-Range Annotations (Auto-Stacked)", + rangeAnnotations: sameRangeAnnotations, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Same-Range Annotations (Auto-Stacked)") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Both overlapping labels are rendered in separate lanes", async () => { + const labels = canvasElement.querySelectorAll(".annotation-text"); + expect(labels.length).toBeGreaterThanOrEqual(2); + }); + }, + parameters: { + docs: { + description: { + story: + "When two range annotations share the same startX/endX the lane auto-assignment stacks them vertically. No `lane` prop is required — overlap is detected automatically.", + }, + }, + zephyr: { testCaseId: "SW-T1117" }, + }, +}; + +/** + * A broad outer bracket contains two narrower inner sub-regions. The auto lane-assignment + * puts the outer region in lane 0 (top) and the two inner bars in lane 1 (below), + * creating a two-level hierarchy without any explicit `lane` props. + */ +export const RangeAnnotationsNested: Story = { + args: { + series: [{ ...chargeVariantData, name: "IgG Sample" }], + title: "Nested Range Annotations (Auto-Stacked)", + rangeAnnotations: nestedRangeAnnotations, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Nested Range Annotations (Auto-Stacked)") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("All three annotation labels are rendered", async () => { + const labels = canvasElement.querySelectorAll(".annotation-text"); + expect(labels.length).toBeGreaterThanOrEqual(3); + }); + }, + parameters: { + docs: { + description: { + story: + "A broad outer annotation overlaps two narrower inner annotations. The greedy lane algorithm places the outer bar in lane 0 and both inner bars in lane 1, producing a two-row hierarchy automatically.", + }, + }, + zephyr: { testCaseId: "SW-T1118" }, + }, +}; + +/** + * Explicit `lane` props override auto-assignment. Here the outer bracket is forced to + * lane 1 (visually below the inner bars at lane 0) to demonstrate manual control. + * Also shows `yAnchor: "auto"` which floats the bars just above the local signal peak. + */ +export const RangeAnnotationsExplicitLanes: Story = { + args: { + series: [{ ...chargeVariantData, name: "IgG Sample" }], + title: "Explicit Lane Override + Auto Y-Anchor", + rangeAnnotations: [ + { + label: "Acidic-01", + startX: 4.5, + endX: 5.5, + color: "#FF6B6B", + yAnchor: "auto", + lane: 0, + }, + { + label: "Acidic-02", + startX: 5.6, + endX: 6.8, + color: "#FF3B30", + yAnchor: "auto", + lane: 0, + }, + { + label: "Acidic Region", + startX: 4.5, + endX: 7.5, + color: "#8E8E93", + yAnchor: "auto", + lane: 1, + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Explicit Lane Override + Auto Y-Anchor") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("All annotation labels are rendered", async () => { + const labels = canvasElement.querySelectorAll(".annotation-text"); + expect(labels.length).toBeGreaterThanOrEqual(3); + }); + }, + parameters: { + docs: { + description: { + story: + "Explicit `lane` values override auto-assignment. The two narrow sub-regions are pinned to lane 0 and the broad outer bracket to lane 1, so it renders below them. `yAnchor: \"auto\"` places all bars in data-space just above the local signal maximum rather than in fixed paper-space.", + }, + }, + zephyr: { testCaseId: "SW-T1119" }, + }, +}; diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index cf51ddfe..3ad06381 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -18,6 +18,7 @@ import { processUserAnnotations, } from "./dataProcessing"; import { detectPeaks } from "./peakDetection"; +import { buildRangeAnnotationElements } from "./rangeAnnotations"; import type { ChromatogramSeries, @@ -28,6 +29,7 @@ import type { PeakDetectionOptions, ChromatogramChartProps, PeakWithMeta, + RangeAnnotation, } from "./types"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; @@ -36,6 +38,7 @@ import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; export type { ChromatogramSeries, PeakAnnotation, + RangeAnnotation, BaselineCorrectionMethod, BoundaryMarkerStyle, BoundaryMarkerType, @@ -67,6 +70,8 @@ const ChromatogramChart: React.FC = ({ boundaryMarkers = "none", annotationOverlapThreshold = 0.4, showExportButton = true, + rangeAnnotations = [], + rangeAnnotationOverlapThreshold = 0, }) => { // Derive peak detection state from options const enablePeakDetection = peakDetectionOptions !== undefined; @@ -173,6 +178,16 @@ const ChromatogramChart: React.FC = ({ plotlyAnnotations.push(...createGroupAnnotations(group)); } + // Build range annotation shapes and labels + const { shapes: rangeShapes, annotations: rangeAnnotationLabels } = + rangeAnnotations.length > 0 + ? buildRangeAnnotationElements( + rangeAnnotations, + rangeAnnotationOverlapThreshold, + processedSeries + ) + : { shapes: [], annotations: [] }; + const layout: Partial = { title: title ? { @@ -249,7 +264,8 @@ const ChromatogramChart: React.FC = ({ font: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, }, showlegend: showLegend && series.length > 1, - annotations: plotlyAnnotations, + annotations: [...plotlyAnnotations, ...rangeAnnotationLabels], + shapes: rangeShapes, }; const config: Partial = { @@ -282,7 +298,7 @@ const ChromatogramChart: React.FC = ({ processedSeries, allDetectedPeaks, series.length, width, height, title, xAxisTitle, yAxisTitle, processedAnnotations, xRange, yRange, showLegend, showGridX, showGridY, showMarkers, markerSize, showCrosshairs, enablePeakDetection, peakDetectionOptions, showPeakAreas, boundaryMarkers, - annotationOverlapThreshold, showExportButton, theme, + annotationOverlapThreshold, showExportButton, theme, rangeAnnotations, rangeAnnotationOverlapThreshold, ]); return ( diff --git a/src/components/charts/ChromatogramChart/constants.ts b/src/components/charts/ChromatogramChart/constants.ts index b29bcbd5..ff8df450 100644 --- a/src/components/charts/ChromatogramChart/constants.ts +++ b/src/components/charts/ChromatogramChart/constants.ts @@ -32,3 +32,23 @@ export const CHROMATOGRAM_ANNOTATION = { AUTO_ANNOTATION_FONT_SIZE: 10, } as const; +/** + * Constants for range (fraction/region) annotations + */ +export const RANGE_ANNOTATION = { + /** Default fill opacity for the colored bar */ + DEFAULT_OPACITY: 0.5, + /** Default label font size */ + DEFAULT_FONT_SIZE: 11, + /** Bar height in paper coordinates (fraction of plot height) for "top" anchor */ + BAR_HEIGHT_PAPER: 0.04, + /** Gap between stacked lanes in paper coordinates */ + LANE_GAP_PAPER: 0.01, + /** Multiplier on barHeight to compute the per-lane vertical stride in data coords */ + LANE_DATA_STRIDE_FACTOR: 1.5, + /** Fraction above local peak maximum for "auto" baseline placement */ + AUTO_Y_CLEARANCE_FACTOR: 1.05, + /** Default bar height as a fraction of the global data maximum for "auto" mode */ + AUTO_BAR_HEIGHT_FACTOR: 0.04, +} as const; + diff --git a/src/components/charts/ChromatogramChart/index.ts b/src/components/charts/ChromatogramChart/index.ts index 5afeff59..ae2d3612 100644 --- a/src/components/charts/ChromatogramChart/index.ts +++ b/src/components/charts/ChromatogramChart/index.ts @@ -2,6 +2,7 @@ export { ChromatogramChart } from "./ChromatogramChart"; export type { ChromatogramSeries, PeakAnnotation, + RangeAnnotation, ChromatogramChartProps, BaselineCorrectionMethod, BoundaryMarkerStyle, diff --git a/src/components/charts/ChromatogramChart/rangeAnnotations.ts b/src/components/charts/ChromatogramChart/rangeAnnotations.ts new file mode 100644 index 00000000..1e7286ed --- /dev/null +++ b/src/components/charts/ChromatogramChart/rangeAnnotations.ts @@ -0,0 +1,173 @@ +import { CHART_COLORS } from "../../../utils/colors"; + +import { RANGE_ANNOTATION } from "./constants"; + +import type { RangeAnnotation } from "./types"; +import type Plotly from "plotly.js-dist"; + +/** + * Assign a lane index to each RangeAnnotation. + * + * Annotations with an explicit `lane` value keep it; the rest are greedy-assigned + * (sorted by startX) to the lowest lane where they do not overlap any already-placed + * annotation (within `overlapThreshold` x-units). + */ +export function assignRangeLanes( + annotations: RangeAnnotation[], + overlapThreshold: number +): number[] { + const assignedLanes = new Array(annotations.length).fill(-1); + + // Track the rightmost endX seen in each lane (initialised lazily). + const laneEndX: number[] = []; + + const setLaneEnd = (lane: number, endX: number) => { + while (laneEndX.length <= lane) laneEndX.push(-Infinity); + laneEndX[lane] = Math.max(laneEndX[lane], endX); + }; + + // First pass: honour explicit lane values so they reserve space. + annotations.forEach((ann, i) => { + if (ann.lane !== undefined) { + assignedLanes[i] = ann.lane; + setLaneEnd(ann.lane, ann.endX); + } + }); + + // Second pass: greedy auto-assign, processed in startX order. + const autoIndices = annotations + .map((_, i) => i) + .filter((i) => assignedLanes[i] === -1) + .sort((a, b) => annotations[a].startX - annotations[b].startX); + + for (const idx of autoIndices) { + const ann = annotations[idx]; + + // Find the lowest lane where this bar fits (no overlap with threshold). + let chosenLane = laneEndX.findIndex( + (end) => ann.startX >= end - overlapThreshold + ); + + if (chosenLane === -1) { + chosenLane = laneEndX.length; + } + + assignedLanes[idx] = chosenLane; + setLaneEnd(chosenLane, ann.endX); + } + + return assignedLanes; +} + +/** + * Build Plotly shapes (colored bars) and annotations (labels) for all range annotations. + */ +export function buildRangeAnnotationElements( + rangeAnnotations: RangeAnnotation[], + overlapThreshold: number, + seriesData: { x: number[]; y: number[] }[] +): { + shapes: Partial[]; + annotations: Partial[]; +} { + const lanes = assignRangeLanes(rangeAnnotations, overlapThreshold); + const shapes: Partial[] = []; + const annotations: Partial[] = []; + + const globalMaxY = computeGlobalMaxY(seriesData); + + rangeAnnotations.forEach((ann, i) => { + const lane = lanes[i]; + const color = ann.color ?? CHART_COLORS[i % CHART_COLORS.length]; + const opacity = ann.opacity ?? RANGE_ANNOTATION.DEFAULT_OPACITY; + const fontSize = ann.fontSize ?? RANGE_ANNOTATION.DEFAULT_FONT_SIZE; + const labelColor = ann.labelColor ?? color; + const yAnchor = ann.yAnchor ?? "top"; + const centerX = (ann.startX + ann.endX) / 2; + + let shapeY0: number; + let shapeY1: number; + let yref: "paper" | "y"; + + if (yAnchor === "top") { + const barHeight = ann.barHeight ?? RANGE_ANNOTATION.BAR_HEIGHT_PAPER; + const stride = barHeight + RANGE_ANNOTATION.LANE_GAP_PAPER; + // Lane 0 is flush with the top of the plot; higher lanes step downward. + shapeY1 = 1.0 - lane * stride; + shapeY0 = shapeY1 - barHeight; + yref = "paper"; + } else { + const baseY = + yAnchor === "auto" + ? computeLocalPeakY(ann.startX, ann.endX, seriesData) + : (yAnchor as number); + const barHeight = + ann.barHeight ?? globalMaxY * RANGE_ANNOTATION.AUTO_BAR_HEIGHT_FACTOR; + const laneOffset = + lane * barHeight * RANGE_ANNOTATION.LANE_DATA_STRIDE_FACTOR; + // Lane 0 sits closest to the data; higher lanes stack away from the signal. + shapeY0 = baseY + laneOffset; + shapeY1 = shapeY0 + barHeight; + yref = "y"; + } + + const labelY = (shapeY0 + shapeY1) / 2; + + shapes.push({ + type: "rect", + xref: "x", + yref, + x0: ann.startX, + x1: ann.endX, + y0: shapeY0, + y1: shapeY1, + fillcolor: color, + opacity, + line: { width: 0 }, + }); + + annotations.push({ + x: centerX, + y: labelY, + xref: "x", + yref, + text: ann.label, + showarrow: false, + font: { + size: fontSize, + color: labelColor, + family: "Inter, sans-serif", + }, + xanchor: "center", + yanchor: "middle", + }); + }); + + return { shapes, annotations }; +} + +function computeLocalPeakY( + startX: number, + endX: number, + seriesData: { x: number[]; y: number[] }[] +): number { + let maxY = 0; + for (const s of seriesData) { + for (let j = 0; j < s.x.length; j++) { + if (s.x[j] >= startX && s.x[j] <= endX && s.y[j] > maxY) { + maxY = s.y[j]; + } + } + } + return maxY * RANGE_ANNOTATION.AUTO_Y_CLEARANCE_FACTOR; +} + +function computeGlobalMaxY(seriesData: { x: number[]; y: number[] }[]): number { + let maxY = 0; + for (const s of seriesData) { + for (const y of s.y) { + if (y > maxY) maxY = y; + } + } + return maxY; +} diff --git a/src/components/charts/ChromatogramChart/types.ts b/src/components/charts/ChromatogramChart/types.ts index a7c8dcd9..23c80460 100644 --- a/src/components/charts/ChromatogramChart/types.ts +++ b/src/components/charts/ChromatogramChart/types.ts @@ -97,6 +97,48 @@ export interface PeakDetectionOptions { relativeThreshold?: boolean; } +/** + * A horizontal colored bar spanning [startX, endX] with a centered label. + * Used to annotate chromatographic fractions, compound windows, or regions of interest. + */ +export interface RangeAnnotation { + /** Label text displayed centered within the bar */ + label: string; + /** Left edge of the range (x-axis units, same as series data) */ + startX: number; + /** Right edge of the range (x-axis units, same as series data) */ + endX: number; + /** CSS color for the bar fill (defaults to next CHART_COLOR in sequence) */ + color?: string; + /** Fill opacity for the bar (0–1, default: 0.5) */ + opacity?: number; + /** + * Vertical placement of the bar: + * - "top" (default) — fixed near the top of the plot area in paper-space; always + * visible regardless of data scale + * - "auto" — floats just above the tallest data point in [startX, endX]; placed + * in y data-coordinates so it tracks with zoom + * - number — exact y data-coordinate for the bottom edge of the bar + */ + yAnchor?: "auto" | "top" | number; + /** + * Height of the colored bar. + * - When yAnchor is "top": fraction of plot height in paper-space (default: 0.04) + * - When yAnchor is "auto" or a number: y data units (default: 4% of data max) + */ + barHeight?: number; + /** Font size of the label (default: 11) */ + fontSize?: number; + /** Label text color (defaults to the bar color) */ + labelColor?: string; + /** + * Vertical stacking lane (0 = closest to data, higher = further away for "auto"/number; + * 0 = topmost, higher = lower for "top"). + * When omitted, lane is auto-assigned to avoid overlapping bars. + */ + lane?: number; +} + /** * Props for ChromatogramChart component */ @@ -154,6 +196,17 @@ export interface ChromatogramChartProps { annotationOverlapThreshold?: number; /** Show export button in modebar (default: true) */ showExportButton?: boolean; + /** + * Horizontal range annotations displayed as colored bars with centered labels. + * Rendered as Plotly shapes; independent of the peak annotation system. + */ + rangeAnnotations?: RangeAnnotation[]; + /** + * x-axis overlap threshold for auto lane assignment (same units as series x data). + * Two range annotations whose ranges overlap by more than this amount are placed in + * separate lanes. Default: 0 (only exact overlaps trigger stacking). + */ + rangeAnnotationOverlapThreshold?: number; } /** From e83e82653c7e3cea68ce4020055036ab56d9a578 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Mon, 4 May 2026 09:27:27 -0500 Subject: [PATCH 02/30] Fixes vertical overlap between annotation and peak --- .../ChromatogramChart/ChromatogramChart.tsx | 8 ++- .../charts/ChromatogramChart/constants.ts | 18 +++-- .../ChromatogramChart/rangeAnnotations.ts | 65 ++++++++++++++----- .../charts/ChromatogramChart/types.ts | 16 +++-- 4 files changed, 76 insertions(+), 31 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index 3ad06381..39baa61d 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -178,15 +178,16 @@ const ChromatogramChart: React.FC = ({ plotlyAnnotations.push(...createGroupAnnotations(group)); } - // Build range annotation shapes and labels - const { shapes: rangeShapes, annotations: rangeAnnotationLabels } = + // Build range annotation shapes and labels. + // yDomainMax shrinks the y-axis domain so "top" bars live in the blank zone above it. + const { shapes: rangeShapes, annotations: rangeAnnotationLabels, yDomainMax } = rangeAnnotations.length > 0 ? buildRangeAnnotationElements( rangeAnnotations, rangeAnnotationOverlapThreshold, processedSeries ) - : { shapes: [], annotations: [] }; + : { shapes: [], annotations: [], yDomainMax: 1.0 }; const layout: Partial = { title: title @@ -246,6 +247,7 @@ const ChromatogramChart: React.FC = ({ linewidth: 1, range: yRange, autorange: !yRange, + domain: [0, yDomainMax] as [number, number], zeroline: false, tickfont: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, showspikes: showCrosshairs, diff --git a/src/components/charts/ChromatogramChart/constants.ts b/src/components/charts/ChromatogramChart/constants.ts index ff8df450..6250c77e 100644 --- a/src/components/charts/ChromatogramChart/constants.ts +++ b/src/components/charts/ChromatogramChart/constants.ts @@ -40,15 +40,21 @@ export const RANGE_ANNOTATION = { DEFAULT_OPACITY: 0.5, /** Default label font size */ DEFAULT_FONT_SIZE: 11, - /** Bar height in paper coordinates (fraction of plot height) for "top" anchor */ + /** Bar height in paper coordinates (fraction of plot height) for "top" and "auto" anchors */ BAR_HEIGHT_PAPER: 0.04, /** Gap between stacked lanes in paper coordinates */ LANE_GAP_PAPER: 0.01, - /** Multiplier on barHeight to compute the per-lane vertical stride in data coords */ + /** Multiplier on barHeight to compute the per-lane vertical stride in data coordinates */ LANE_DATA_STRIDE_FACTOR: 1.5, - /** Fraction above local peak maximum for "auto" baseline placement */ - AUTO_Y_CLEARANCE_FACTOR: 1.05, - /** Default bar height as a fraction of the global data maximum for "auto" mode */ - AUTO_BAR_HEIGHT_FACTOR: 0.04, + /** + * Plotly's typical autorange extension factor (Plotly renders data up to ~1/margin_factor + * of the plot height). Used to convert a data-y value to an approximate paper-y position + * when yAnchor is "auto". + */ + AUTO_YRANGE_MARGIN: 1.1, + /** Paper-space clearance added above the estimated peak paper-y for "auto" bars */ + AUTO_PAPER_CLEARANCE: 0.06, + /** Default bar height as a fraction of the global data max for number yAnchor */ + DATA_BAR_HEIGHT_FACTOR: 0.04, } as const; diff --git a/src/components/charts/ChromatogramChart/rangeAnnotations.ts b/src/components/charts/ChromatogramChart/rangeAnnotations.ts index 1e7286ed..a27ac19e 100644 --- a/src/components/charts/ChromatogramChart/rangeAnnotations.ts +++ b/src/components/charts/ChromatogramChart/rangeAnnotations.ts @@ -61,6 +61,11 @@ export function assignRangeLanes( /** * Build Plotly shapes (colored bars) and annotations (labels) for all range annotations. + * + * Also returns `yDomainMax`: the fraction of the plot height that the y-axis should + * occupy. When "top" bars are present the y-axis is shrunk so that the reserved zone + * above it (paper-y from yDomainMax to 1.0) is always blank, preventing the bars from + * visually overlapping with the signal no matter how tall the data is. */ export function buildRangeAnnotationElements( rangeAnnotations: RangeAnnotation[], @@ -69,13 +74,29 @@ export function buildRangeAnnotationElements( ): { shapes: Partial[]; annotations: Partial[]; + yDomainMax: number; } { const lanes = assignRangeLanes(rangeAnnotations, overlapThreshold); const shapes: Partial[] = []; const annotations: Partial[] = []; - const globalMaxY = computeGlobalMaxY(seriesData); + // Determine how many "top" lanes are used and shrink the y-axis domain accordingly. + // Each lane needs BAR_HEIGHT_PAPER + LANE_GAP_PAPER of vertical space. + let maxTopLane = -1; + rangeAnnotations.forEach((ann, i) => { + if ((ann.yAnchor ?? "top") === "top") { + maxTopLane = Math.max(maxTopLane, lanes[i]); + } + }); + const topLaneCount = maxTopLane + 1; + const defaultStride = RANGE_ANNOTATION.BAR_HEIGHT_PAPER + RANGE_ANNOTATION.LANE_GAP_PAPER; + // Add one extra gap below the lowest bar so it doesn't sit flush against the data. + const yDomainMax = + topLaneCount > 0 + ? Math.max(1.0 - topLaneCount * defaultStride - RANGE_ANNOTATION.LANE_GAP_PAPER, 0.5) + : 1.0; + rangeAnnotations.forEach((ann, i) => { const lane = lanes[i]; const color = ann.color ?? CHART_COLORS[i % CHART_COLORS.length]; @@ -92,21 +113,35 @@ export function buildRangeAnnotationElements( if (yAnchor === "top") { const barHeight = ann.barHeight ?? RANGE_ANNOTATION.BAR_HEIGHT_PAPER; const stride = barHeight + RANGE_ANNOTATION.LANE_GAP_PAPER; - // Lane 0 is flush with the top of the plot; higher lanes step downward. + // Lane 0 sits flush with paper-y 1.0; higher lanes step downward. + // Because the y-axis domain ends at yDomainMax, these bars render in the + // reserved blank zone above the axes — always clear of the signal. shapeY1 = 1.0 - lane * stride; shapeY0 = shapeY1 - barHeight; yref = "paper"; + } else if (yAnchor === "auto") { + // Estimate where the local peak sits in paper-space, scaled to yDomainMax so the + // estimate is accurate after domain adjustment. + const localMaxY = computeLocalMaxY(ann.startX, ann.endX, seriesData); + const barHeight = ann.barHeight ?? RANGE_ANNOTATION.BAR_HEIGHT_PAPER; + const stride = barHeight + RANGE_ANNOTATION.LANE_GAP_PAPER; + const estimatedPeakPaperY = + globalMaxY > 0 + ? (localMaxY / (globalMaxY * RANGE_ANNOTATION.AUTO_YRANGE_MARGIN)) * yDomainMax + : yDomainMax * 0.5; + const baseY = Math.min( + estimatedPeakPaperY + RANGE_ANNOTATION.AUTO_PAPER_CLEARANCE, + yDomainMax - barHeight + ); + // Lane 0 = closest to the peak; higher lanes stack upward (away from data). + shapeY1 = Math.min(baseY + lane * stride + barHeight, yDomainMax); + shapeY0 = shapeY1 - barHeight; + yref = "paper"; } else { - const baseY = - yAnchor === "auto" - ? computeLocalPeakY(ann.startX, ann.endX, seriesData) - : (yAnchor as number); - const barHeight = - ann.barHeight ?? globalMaxY * RANGE_ANNOTATION.AUTO_BAR_HEIGHT_FACTOR; - const laneOffset = - lane * barHeight * RANGE_ANNOTATION.LANE_DATA_STRIDE_FACTOR; - // Lane 0 sits closest to the data; higher lanes stack away from the signal. - shapeY0 = baseY + laneOffset; + // Explicit data-coordinate placement. + const barHeight = ann.barHeight ?? globalMaxY * RANGE_ANNOTATION.DATA_BAR_HEIGHT_FACTOR; + const laneOffset = lane * barHeight * RANGE_ANNOTATION.LANE_DATA_STRIDE_FACTOR; + shapeY0 = (yAnchor as number) + laneOffset; shapeY1 = shapeY0 + barHeight; yref = "y"; } @@ -143,10 +178,10 @@ export function buildRangeAnnotationElements( }); }); - return { shapes, annotations }; + return { shapes, annotations, yDomainMax }; } -function computeLocalPeakY( +function computeLocalMaxY( startX: number, endX: number, seriesData: { x: number[]; y: number[] }[] @@ -159,7 +194,7 @@ function computeLocalPeakY( } } } - return maxY * RANGE_ANNOTATION.AUTO_Y_CLEARANCE_FACTOR; + return maxY; } function computeGlobalMaxY(seriesData: { x: number[]; y: number[] }[]): number { diff --git a/src/components/charts/ChromatogramChart/types.ts b/src/components/charts/ChromatogramChart/types.ts index 23c80460..e7a84941 100644 --- a/src/components/charts/ChromatogramChart/types.ts +++ b/src/components/charts/ChromatogramChart/types.ts @@ -114,17 +114,19 @@ export interface RangeAnnotation { opacity?: number; /** * Vertical placement of the bar: - * - "top" (default) — fixed near the top of the plot area in paper-space; always - * visible regardless of data scale - * - "auto" — floats just above the tallest data point in [startX, endX]; placed - * in y data-coordinates so it tracks with zoom - * - number — exact y data-coordinate for the bottom edge of the bar + * - "top" (default) — fixed at the top of the plot area in paper-space; all bars + * line up at the same height regardless of the underlying signal + * - "auto" — paper-space position estimated proportionally from the local peak height + * relative to the global maximum; bars float visibly above each individual peak + * without overlapping the signal + * - number — exact y data-coordinate for the bottom edge of the bar; use when you + * need pixel-precise placement and control the yRange yourself */ yAnchor?: "auto" | "top" | number; /** * Height of the colored bar. - * - When yAnchor is "top": fraction of plot height in paper-space (default: 0.04) - * - When yAnchor is "auto" or a number: y data units (default: 4% of data max) + * - When yAnchor is "top" or "auto": fraction of plot height in paper-space (default: 0.04) + * - When yAnchor is a number: y data units (default: 4% of global data max) */ barHeight?: number; /** Font size of the label (default: 11) */ From e9197b4cc6d8a5b1475228064bb4883c5c6ec62e Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Mon, 4 May 2026 09:54:40 -0500 Subject: [PATCH 03/30] Adds StackedChromatogram --- .../StackedChromatogramChart.stories.tsx | 340 ++++++++++++++++++ .../StackedChromatogramChart.tsx | 35 ++ .../charts/StackedChromatogramChart/index.ts | 3 + .../StackedChromatogramChart/transforms.ts | 57 +++ .../charts/StackedChromatogramChart/types.ts | 29 ++ src/index.ts | 1 + 6 files changed, 465 insertions(+) create mode 100644 src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx create mode 100644 src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx create mode 100644 src/components/charts/StackedChromatogramChart/index.ts create mode 100644 src/components/charts/StackedChromatogramChart/transforms.ts create mode 100644 src/components/charts/StackedChromatogramChart/types.ts diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx new file mode 100644 index 00000000..ab2ffe34 --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -0,0 +1,340 @@ +import { expect, within } from "storybook/test"; + +import { StackedChromatogramChart } from "./StackedChromatogramChart"; + +import type { StackedChromatogramChartProps } from "./types"; +import type { Meta, StoryObj } from "@storybook/react-vite"; + +const generateChromatogramData = ( + peaks: Array<{ rt: number; height: number; width: number }>, + noise: number = 0.5 +): { x: number[]; y: number[] } => { + const x: number[] = []; + const y: number[] = []; + for (let t = 0; t <= 30; t += 0.05) { + x.push(parseFloat(t.toFixed(2))); + let signal = 0; + peaks.forEach((peak) => { + signal += + peak.height * + Math.exp(-Math.pow(t - peak.rt, 2) / (2 * Math.pow(peak.width, 2))); + }); + signal += (Math.random() - 0.5) * noise; + y.push(Math.max(0, signal)); + } + return { x, y }; +}; + +// Three injections with slight retention-time drift — typical system suitability set +const injection1 = generateChromatogramData([ + { rt: 5.8, height: 420, width: 0.4 }, + { rt: 12.5, height: 180, width: 0.5 }, + { rt: 18.3, height: 350, width: 0.45 }, +]); +const injection2 = generateChromatogramData( + [ + { rt: 5.9, height: 380, width: 0.42 }, + { rt: 12.6, height: 195, width: 0.48 }, + { rt: 18.4, height: 320, width: 0.47 }, + ], + 0.8 +); +const injection3 = generateChromatogramData( + [ + { rt: 5.7, height: 440, width: 0.38 }, + { rt: 12.4, height: 170, width: 0.52 }, + { rt: 18.2, height: 365, width: 0.43 }, + ], + 0.6 +); + +const overlaySeriesData: StackedChromatogramChartProps["series"] = [ + { ...injection1, name: "Injection 1" }, + { ...injection2, name: "Injection 2" }, + { ...injection3, name: "Injection 3" }, +]; + +// IgG charge-variant runs with clearly separated peaks for stack mode +const chargeVariant1 = generateChromatogramData([ + { rt: 5.2, height: 120, width: 0.25 }, + { rt: 5.8, height: 420, width: 0.3 }, + { rt: 6.5, height: 180, width: 0.25 }, +]); +const chargeVariant2 = generateChromatogramData( + [ + { rt: 5.3, height: 100, width: 0.25 }, + { rt: 5.9, height: 390, width: 0.31 }, + { rt: 6.6, height: 160, width: 0.26 }, + ], + 0.7 +); +const chargeVariant3 = generateChromatogramData( + [ + { rt: 5.1, height: 135, width: 0.24 }, + { rt: 5.75, height: 450, width: 0.29 }, + { rt: 6.4, height: 200, width: 0.24 }, + ], + 0.6 +); + +const stackSeriesData: StackedChromatogramChartProps["series"] = [ + { ...chargeVariant1, name: "Day 1" }, + { ...chargeVariant2, name: "Day 2" }, + { ...chargeVariant3, name: "Day 3" }, +]; + +const meta: Meta = { + title: "Charts/StackedChromatogramChart", + component: StackedChromatogramChart, + parameters: { + layout: "centered", + }, + tags: ["autodocs"], +}; + +export default meta; +type Story = StoryObj; + +/** + * Overlay mode (default) — all traces share the same y-axis. Useful for comparing + * retention-time reproducibility across injections. + */ +export const OverlayMode: Story = { + args: { + series: overlaySeriesData, + title: "Injection Overlay — System Suitability", + stackingMode: "overlay", + showCrosshairs: true, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Injection Overlay — System Suitability") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Three traces are rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(3); + }); + + await step("Legend shows all injection names", async () => { + expect(canvas.getByText("Injection 1")).toBeInTheDocument(); + expect(canvas.getByText("Injection 2")).toBeInTheDocument(); + expect(canvas.getByText("Injection 3")).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: + "Overlay mode (default). All traces occupy the same y-axis range so retention times and peak heights can be compared directly. Crosshairs are enabled to aid comparison.", + }, + }, + zephyr: { testCaseId: "SW-T1120" }, + }, +}; + +/** + * Stack mode — each series is offset vertically by stackOffset AU so traces don't + * overlap. Ideal for visualizing many runs in a waterfall layout. + */ +export const StackMode: Story = { + args: { + series: stackSeriesData, + title: "Charge Variant Runs — Stacked", + stackingMode: "stack", + stackOffset: 500, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Charge Variant Runs — Stacked") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Three traces are rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(3); + }); + + await step("Legend shows all day labels", async () => { + expect(canvas.getByText("Day 1")).toBeInTheDocument(); + expect(canvas.getByText("Day 2")).toBeInTheDocument(); + expect(canvas.getByText("Day 3")).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: + "Stack mode shifts each series up by stackOffset data units, creating a waterfall layout. The y-axis range automatically expands to fit all offset traces.", + }, + }, + zephyr: { testCaseId: "SW-T1121" }, + }, +}; + +/** + * Stack mode with per-series peak annotations. Each annotations[i] array maps to + * series[i]; in stack mode the annotation y-values are shifted by the same offset as + * the trace so labels stay pinned to their peaks. + */ +export const StackModeWithAnnotations: Story = { + args: { + series: stackSeriesData, + title: "Stacked Runs with Peak Labels", + stackingMode: "stack", + stackOffset: 500, + annotations: [ + // Day 1 annotations + [ + { x: 5.8, y: 420, text: "Main", ay: -35 }, + { x: 5.2, y: 120, text: "Acidic", ay: -35, ax: -30 }, + { x: 6.5, y: 180, text: "Basic", ay: -35, ax: 30 }, + ], + // Day 2 annotations + [ + { x: 5.9, y: 390, text: "Main", ay: -35 }, + { x: 5.3, y: 100, text: "Acidic", ay: -35, ax: -30 }, + { x: 6.6, y: 160, text: "Basic", ay: -35, ax: 30 }, + ], + // Day 3 annotations + [ + { x: 5.75, y: 450, text: "Main", ay: -35 }, + { x: 5.1, y: 135, text: "Acidic", ay: -35, ax: -30 }, + { x: 6.4, y: 200, text: "Basic", ay: -35, ax: 30 }, + ], + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Stacked Runs with Peak Labels") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Three traces are rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(3); + }); + + await step("Annotations are rendered", async () => { + const annotationEls = canvasElement.querySelectorAll(".annotation-text"); + // 3 series × 3 peaks each = 9 annotations total + expect(annotationEls.length).toBeGreaterThanOrEqual(9); + }); + }, + parameters: { + docs: { + description: { + story: + "Per-series annotations passed as a 2-D array (annotations[i] → series[i]). In stack mode the component automatically shifts each annotation's y-value by the same offset applied to its trace, so labels stay anchored to their peaks.", + }, + }, + zephyr: { testCaseId: "SW-T1122" }, + }, +}; + +/** + * Overlay with automatic peak detection enabled. Since both modes share the same + * underlying ChromatogramChart, all peak-detection and boundary-marker features work + * transparently in both modes. + */ +export const OverlayWithPeakDetection: Story = { + args: { + series: [ + { ...injection1, name: "Injection 1" }, + { ...injection2, name: "Injection 2" }, + ], + title: "Overlay + Auto Peak Detection", + stackingMode: "overlay", + peakDetectionOptions: { + minHeight: 0.1, + prominence: 0.05, + minDistance: 20, + }, + showPeakAreas: true, + boundaryMarkers: "enabled", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Overlay + Auto Peak Detection") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Two data traces are rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + // Main traces + boundary marker traces + expect(traces.length).toBeGreaterThanOrEqual(2); + }); + + await step("Peak area annotations are displayed", async () => { + const annotations = canvasElement.querySelectorAll(".annotation-text"); + expect(annotations.length).toBeGreaterThan(0); + }); + }, + parameters: { + docs: { + description: { + story: + "Overlay mode with automatic peak detection, area display, and boundary markers enabled. All ChromatogramChart features (peak detection, boundary markers, range annotations, baseline correction) are available in both stacking modes.", + }, + }, + zephyr: { testCaseId: "SW-T1123" }, + }, +}; + +/** + * Drag the "Stack Offset" slider in the Controls panel to adjust the vertical + * separation between traces in real time. stackingMode is locked to 'stack'. + */ +export const InteractiveOffset: Story = { + argTypes: { + stackOffset: { + control: { type: "range", min: 0, max: 700, step: 10 }, + description: "Vertical separation between stacked traces (data units)", + }, + }, + args: { + series: stackSeriesData, + title: "Stacked Offset — Interactive", + stackingMode: "stack", + stackOffset: 500, + showCrosshairs: true, + }, + parameters: { + docs: { + description: { + story: + "Use the **Stack Offset** slider in the Controls panel to adjust vertical separation between traces live. At 0 the traces overlap (equivalent to overlay mode); increasing the offset creates a waterfall layout.", + }, + }, + zephyr: { testCaseId: "SW-T1124" }, + }, +}; diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx new file mode 100644 index 00000000..6f58387b --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx @@ -0,0 +1,35 @@ +import { useMemo } from "react"; + +import { ChromatogramChart } from "../ChromatogramChart"; + +import { applyStackingTransform } from "./transforms"; + +import type { StackedChromatogramChartProps } from "./types"; + +export function StackedChromatogramChart({ + series, + stackingMode = "overlay", + stackOffset = 0, + annotations, + ...restProps +}: StackedChromatogramChartProps) { + const { + series: transformedSeries, + annotations: transformedAnnotations, + yRange, + } = useMemo( + () => applyStackingTransform(series, annotations, stackingMode, stackOffset), + [series, annotations, stackingMode, stackOffset] + ); + + return ( + + ); +} + +export default StackedChromatogramChart; diff --git a/src/components/charts/StackedChromatogramChart/index.ts b/src/components/charts/StackedChromatogramChart/index.ts new file mode 100644 index 00000000..71d5579f --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/index.ts @@ -0,0 +1,3 @@ +export { StackedChromatogramChart } from "./StackedChromatogramChart"; +export type { StackedChromatogramChartProps, StackingMode } from "./types"; +export { applyStackingTransform } from "./transforms"; diff --git a/src/components/charts/StackedChromatogramChart/transforms.ts b/src/components/charts/StackedChromatogramChart/transforms.ts new file mode 100644 index 00000000..f2d9bf15 --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/transforms.ts @@ -0,0 +1,57 @@ +import type { ChromatogramSeries, PeakAnnotation } from "../ChromatogramChart"; +import type { StackingMode } from "./types"; + +interface TransformResult { + series: ChromatogramSeries[]; + annotations: PeakAnnotation[]; + yRange: [number, number]; +} + +export function applyStackingTransform( + inputSeries: ChromatogramSeries[], + inputAnnotations: PeakAnnotation[][] | undefined, + mode: StackingMode, + stackOffset: number +): TransformResult { + const yValues = inputSeries.flatMap((s) => s.y); + const yMin = Math.min(...yValues, 0); + const yMax = Math.max(...yValues); + const consistentYRange: [number, number] = [yMin, yMax]; + + if (mode === "overlay") { + return { + series: inputSeries, + annotations: inputAnnotations ? inputAnnotations.flat() : [], + yRange: consistentYRange, + }; + } + + // 'stack' mode: shift each series up by its index × stackOffset + const offsetSeries: ChromatogramSeries[] = inputSeries.map( + (series, index) => ({ + ...series, + y: series.y.map((yVal) => yVal + index * stackOffset), + }) + ); + + const offsetAnnotations: PeakAnnotation[] = []; + if (inputAnnotations) { + inputAnnotations.forEach((seriesAnnotations, seriesIndex) => { + const yShift = seriesIndex * stackOffset; + seriesAnnotations.forEach((ann) => { + offsetAnnotations.push({ ...ann, y: ann.y + yShift }); + }); + }); + } + + const allYValues = offsetSeries.flatMap((s) => s.y); + const annotationYValues = offsetAnnotations.map((a) => a.y); + const stackedYMin = Math.min(...allYValues, ...annotationYValues, 0); + const stackedYMax = Math.max(...allYValues, ...annotationYValues); + + return { + series: offsetSeries, + annotations: offsetAnnotations, + yRange: [stackedYMin, stackedYMax], + }; +} diff --git a/src/components/charts/StackedChromatogramChart/types.ts b/src/components/charts/StackedChromatogramChart/types.ts new file mode 100644 index 00000000..883960d5 --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/types.ts @@ -0,0 +1,29 @@ +import type { + ChromatogramChartProps, + ChromatogramSeries, + PeakAnnotation, +} from "../ChromatogramChart"; + +export type StackingMode = "overlay" | "stack"; + +export interface StackedChromatogramChartProps + extends Omit { + /** Array of data series to display */ + series: ChromatogramSeries[]; + + /** Stacking mode: 'overlay' (default) or 'stack' */ + stackingMode?: StackingMode; + + /** + * Y-offset between stacked series in data units (e.g., AU). + * Only applies in 'stack' mode; ignored in 'overlay'. + */ + stackOffset?: number; + + /** + * Peak annotations per series, parallel to the series array. + * annotations[i] corresponds to series[i]. + * In stack mode, y values are shifted by the series offset. + */ + annotations?: PeakAnnotation[][]; +} diff --git a/src/index.ts b/src/index.ts index 63e53c9a..6ac89438 100644 --- a/src/index.ts +++ b/src/index.ts @@ -15,6 +15,7 @@ export * from "@/components/charts/BarGraph"; export * from "@/components/charts/Boxplot"; export * from "@/components/charts/Chromatogram"; export * from "@/components/charts/ChromatogramChart"; +export * from "@/components/charts/StackedChromatogramChart"; export * from "@/components/charts/DotPlot"; export * from "@/components/charts/Heatmap"; export * from "@/components/charts/Histogram"; From 826d7bdf1ee12ccc39eeb968b638ae0dc53330d3 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Mon, 4 May 2026 11:42:24 -0500 Subject: [PATCH 04/30] Makes RangedAnnotations work with StackedChromatogramChart --- .../StackedChromatogramChart.stories.tsx | 123 ++++++++++++++++++ .../StackedChromatogramChart.tsx | 14 +- .../StackedChromatogramChart/transforms.ts | 28 +++- .../charts/StackedChromatogramChart/types.ts | 15 ++- 4 files changed, 176 insertions(+), 4 deletions(-) diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx index ab2ffe34..c957e4e9 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -310,6 +310,78 @@ export const OverlayWithPeakDetection: Story = { }, }; +// Per-series fraction windows for the charge-variant stack stories. +// Each series has three adjacent fractions; yAnchor is set just above the +// local peak maximum so bars stay pinned to their trace when the offset changes. +const fractionAnnotationsPerSeries = [ + // Day 1 — unshifted; peaks at ~135 (Acidic), ~450 (Main), ~200 (Basic) + [ + { label: "Acidic", startX: 4.8, endX: 5.45, color: "#8E8E93", yAnchor: 145, barHeight: 30 }, + { label: "Main", startX: 5.45, endX: 6.25, color: "#007AFF", yAnchor: 460, barHeight: 30 }, + { label: "Basic", startX: 6.25, endX: 7.0, color: "#34C759", yAnchor: 210, barHeight: 30 }, + ], + // Day 2 — same yAnchor values; in stack mode these are shifted up by stackOffset + [ + { label: "Acidic", startX: 4.8, endX: 5.45, color: "#8E8E93", yAnchor: 145, barHeight: 30 }, + { label: "Main", startX: 5.45, endX: 6.25, color: "#007AFF", yAnchor: 460, barHeight: 30 }, + { label: "Basic", startX: 6.25, endX: 7.0, color: "#34C759", yAnchor: 210, barHeight: 30 }, + ], + // Day 3 + [ + { label: "Acidic", startX: 4.8, endX: 5.45, color: "#8E8E93", yAnchor: 145, barHeight: 30 }, + { label: "Main", startX: 5.45, endX: 6.25, color: "#007AFF", yAnchor: 460, barHeight: 30 }, + { label: "Basic", startX: 6.25, endX: 7.0, color: "#34C759", yAnchor: 210, barHeight: 30 }, + ], +]; + +/** + * Three stacked runs each labelled with Acidic / Main / Basic fraction windows. + * The bars are positioned with numeric yAnchor so they are pinned just above their + * respective trace — they shift upward by stackOffset × seriesIndex automatically. + */ +export const StackWithRangeAnnotations: Story = { + args: { + series: stackSeriesData, + title: "Charge Variant Fractions — Stacked", + stackingMode: "stack", + stackOffset: 500, + rangeAnnotations: fractionAnnotationsPerSeries, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Charge Variant Fractions — Stacked") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Three data traces are rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(3); + }); + + await step("Range annotation labels are rendered (3 fractions × 3 series)", async () => { + const labels = canvasElement.querySelectorAll(".annotation-text"); + // 3 fractions × 3 series = 9 range annotation labels + expect(labels.length).toBeGreaterThanOrEqual(9); + }); + }, + parameters: { + docs: { + description: { + story: + "Each series carries its own set of fraction windows (Acidic / Main / Basic) defined with numeric `yAnchor` values relative to the unshifted data. In stack mode the component adds `seriesIndex × stackOffset` to each numeric `yAnchor`, so the bars stay anchored just above their own trace regardless of the offset.", + }, + }, + zephyr: { testCaseId: "SW-T1125" }, + }, +}; + /** * Drag the "Stack Offset" slider in the Controls panel to adjust the vertical * separation between traces in real time. stackingMode is locked to 'stack'. @@ -338,3 +410,54 @@ export const InteractiveOffset: Story = { zephyr: { testCaseId: "SW-T1124" }, }, }; + +// Fraction windows for the interactive story. +// yAnchor = peak_height - barHeight so each bar's top sits at the peak apex — +// bars are always inside the stacked y-axis range and move with their trace. +const fractionAnnotationsInteractive = [ + [ + { label: "Acidic", startX: 4.8, endX: 5.45, color: "#8E8E93", yAnchor: 90, barHeight: 25 }, + { label: "Main", startX: 5.45, endX: 6.25, color: "#007AFF", yAnchor: 390, barHeight: 25 }, + { label: "Basic", startX: 6.25, endX: 7.0, color: "#34C759", yAnchor: 150, barHeight: 25 }, + ], + [ + { label: "Acidic", startX: 4.8, endX: 5.2, color: "#FF9500", yAnchor: 65, barHeight: 25 }, + { label: "Main", startX: 5.5, endX: 6.3, color: "#007AFF", yAnchor: 362, barHeight: 25 }, + { label: "Basic", startX: 6.3, endX: 7.0, color: "#34C759", yAnchor: 130, barHeight: 25 }, + ], + [ + { label: "Acidic", startX: 4.7, endX: 5.4, color: "#8E8E93", yAnchor: 108, barHeight: 25 }, + { label: "Main", startX: 5.4, endX: 6.15, color: "#007AFF", yAnchor: 420, barHeight: 25 }, + { label: "Basic", startX: 6.15, endX: 6.8, color: "#34C759", yAnchor: 170, barHeight: 25 }, + ], +]; + +/** + * Combine the offset slider with per-series fraction windows positioned just above + * each trace. Dragging the slider moves both traces and their bars together. + */ +export const InteractiveOffsetWithRangeAnnotations: Story = { + argTypes: { + stackOffset: { + control: { type: "range", min: 0, max: 700, step: 10 }, + description: "Vertical separation between stacked traces (data units)", + }, + }, + args: { + series: stackSeriesData, + title: "Stacked Fractions — Interactive Offset", + stackingMode: "stack", + stackOffset: 500, + rangeAnnotations: fractionAnnotationsInteractive, + showCrosshairs: true, + }, + parameters: { + docs: { + description: { + story: + "Drag the **Stack Offset** slider to spread or collapse the traces. Each run's fraction bars use a numeric `yAnchor` set just above the local peak, so bars move with their trace. Day 2 splits the acidic region into Acidic-01 / Acidic-02; Day 3 uses slightly shifted boundaries.", + }, + }, + zephyr: { testCaseId: "SW-T1126" }, + }, +}; diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx index 6f58387b..75dc2d89 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx @@ -11,15 +11,24 @@ export function StackedChromatogramChart({ stackingMode = "overlay", stackOffset = 0, annotations, + rangeAnnotations, ...restProps }: StackedChromatogramChartProps) { const { series: transformedSeries, annotations: transformedAnnotations, + rangeAnnotations: transformedRangeAnnotations, yRange, } = useMemo( - () => applyStackingTransform(series, annotations, stackingMode, stackOffset), - [series, annotations, stackingMode, stackOffset] + () => + applyStackingTransform( + series, + annotations, + rangeAnnotations, + stackingMode, + stackOffset + ), + [series, annotations, rangeAnnotations, stackingMode, stackOffset] ); return ( @@ -27,6 +36,7 @@ export function StackedChromatogramChart({ {...restProps} series={transformedSeries} annotations={transformedAnnotations} + rangeAnnotations={transformedRangeAnnotations} yRange={yRange} /> ); diff --git a/src/components/charts/StackedChromatogramChart/transforms.ts b/src/components/charts/StackedChromatogramChart/transforms.ts index f2d9bf15..719ebb69 100644 --- a/src/components/charts/StackedChromatogramChart/transforms.ts +++ b/src/components/charts/StackedChromatogramChart/transforms.ts @@ -1,15 +1,21 @@ -import type { ChromatogramSeries, PeakAnnotation } from "../ChromatogramChart"; +import type { + ChromatogramSeries, + PeakAnnotation, + RangeAnnotation, +} from "../ChromatogramChart"; import type { StackingMode } from "./types"; interface TransformResult { series: ChromatogramSeries[]; annotations: PeakAnnotation[]; + rangeAnnotations: RangeAnnotation[]; yRange: [number, number]; } export function applyStackingTransform( inputSeries: ChromatogramSeries[], inputAnnotations: PeakAnnotation[][] | undefined, + inputRangeAnnotations: RangeAnnotation[][] | undefined, mode: StackingMode, stackOffset: number ): TransformResult { @@ -22,6 +28,7 @@ export function applyStackingTransform( return { series: inputSeries, annotations: inputAnnotations ? inputAnnotations.flat() : [], + rangeAnnotations: inputRangeAnnotations ? inputRangeAnnotations.flat() : [], yRange: consistentYRange, }; } @@ -44,6 +51,24 @@ export function applyStackingTransform( }); } + // Shift numeric yAnchor values; "top" and "auto" anchors are unaffected + // because they derive position from paper-space or the already-shifted data. + const offsetRangeAnnotations: RangeAnnotation[] = []; + if (inputRangeAnnotations) { + inputRangeAnnotations.forEach((seriesRangeAnnotations, seriesIndex) => { + const yShift = seriesIndex * stackOffset; + seriesRangeAnnotations.forEach((ann) => { + offsetRangeAnnotations.push({ + ...ann, + yAnchor: + typeof ann.yAnchor === "number" + ? ann.yAnchor + yShift + : ann.yAnchor, + }); + }); + }); + } + const allYValues = offsetSeries.flatMap((s) => s.y); const annotationYValues = offsetAnnotations.map((a) => a.y); const stackedYMin = Math.min(...allYValues, ...annotationYValues, 0); @@ -52,6 +77,7 @@ export function applyStackingTransform( return { series: offsetSeries, annotations: offsetAnnotations, + rangeAnnotations: offsetRangeAnnotations, yRange: [stackedYMin, stackedYMax], }; } diff --git a/src/components/charts/StackedChromatogramChart/types.ts b/src/components/charts/StackedChromatogramChart/types.ts index 883960d5..26daf4ce 100644 --- a/src/components/charts/StackedChromatogramChart/types.ts +++ b/src/components/charts/StackedChromatogramChart/types.ts @@ -2,12 +2,16 @@ import type { ChromatogramChartProps, ChromatogramSeries, PeakAnnotation, + RangeAnnotation, } from "../ChromatogramChart"; export type StackingMode = "overlay" | "stack"; export interface StackedChromatogramChartProps - extends Omit { + extends Omit< + ChromatogramChartProps, + "series" | "annotations" | "yRange" | "rangeAnnotations" + > { /** Array of data series to display */ series: ChromatogramSeries[]; @@ -26,4 +30,13 @@ export interface StackedChromatogramChartProps * In stack mode, y values are shifted by the series offset. */ annotations?: PeakAnnotation[][]; + + /** + * Range annotations per series, parallel to the series array. + * rangeAnnotations[i] corresponds to series[i]. + * In stack mode, numeric yAnchor values are shifted by the series offset so + * bars stay pinned to their trace. "top" and "auto" anchors are unaffected + * (they derive their position from paper-space or the already-shifted data). + */ + rangeAnnotations?: RangeAnnotation[][]; } From 818f9d7c31386f1d964d5c5ce2841bda9900ed82 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Mon, 4 May 2026 15:18:10 -0500 Subject: [PATCH 05/30] Fixes annotation behavior with zoom --- .../ChromatogramChart.stories.tsx | 205 ++++-------------- .../ChromatogramChart/ChromatogramChart.tsx | 94 ++++++++ .../ChromatogramChart/rangeAnnotations.ts | 1 + 3 files changed, 142 insertions(+), 158 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index 6596a96b..f6b15baa 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -108,6 +108,14 @@ const userDefinedPeaksWithBoundaries: PeakAnnotation[] = [ }, ]; +// Range annotations marking chromatographic fractions across the x-axis +const sampleRangeAnnotations: RangeAnnotation[] = [ + { label: "Void", startX: 0, endX: 2.5, color: "#8E8E93" }, + { label: "Caffeine", startX: 4.5, endX: 7.2, color: "#007AFF" }, + { label: "Theobromine", startX: 11.0, endX: 14.0, color: "#34C759" }, + { label: "Theophylline", startX: 16.8, endX: 19.8, color: "#FF9500" }, +]; + const meta: Meta = { title: "Charts/ChromatogramChart", component: ChromatogramChart, @@ -475,38 +483,6 @@ export const UserDefinedPeakBoundaries: Story = { }, }; -// --------------------------------------------------------------------------- -// Range annotation data -// --------------------------------------------------------------------------- - -// IgG charge-variant chromatogram — three fractions across a single peak cluster. -const chargeVariantData = generateChromatogramData([ - { rt: 5.2, height: 120, width: 0.25 }, - { rt: 5.8, height: 420, width: 0.3 }, - { rt: 6.4, height: 180, width: 0.25 }, - { rt: 7.1, height: 80, width: 0.2 }, -]); - -// Three adjacent fractions matching the screenshot layout -const adjacentRangeAnnotations: RangeAnnotation[] = [ - { label: "Acidic-01", startX: 4.5, endX: 5.5, color: "#8E8E93" }, - { label: "Acidic-02", startX: 5.5, endX: 6.7, color: "#FF3B30" }, - { label: "Main", startX: 6.7, endX: 7.8, color: "#34C759" }, -]; - -// Two annotations covering exactly the same range — forces auto lane stacking -const sameRangeAnnotations: RangeAnnotation[] = [ - { label: "IgG Fraction", startX: 5.0, endX: 7.2, color: "#007AFF" }, - { label: "Caffeine Window", startX: 5.0, endX: 7.2, color: "#FF9500" }, -]; - -// Nested ranges: one broad outer bracket + two narrower inner sub-regions -const nestedRangeAnnotations: RangeAnnotation[] = [ - { label: "Acidic Region", startX: 4.5, endX: 7.5, color: "#8E8E93" }, - { label: "Acidic-01", startX: 4.5, endX: 5.5, color: "#FF6B6B" }, - { label: "Acidic-02", startX: 5.6, endX: 6.8, color: "#FF3B30" }, -]; - /** * Combining automatic peak detection with user-defined annotations. Auto-detected peaks * show computed areas while user annotations provide custom labels. Both can have @@ -578,183 +554,96 @@ export const CombinedAutoAndUserPeaks: Story = { }; /** - * Three adjacent, non-overlapping fraction windows rendered at the top of the plot - * area — replicating the charge-variant labeling style shown in the design screenshot. - * All bars share lane 0 and sit flush with the top of the plot in paper-space. + * Horizontal colored bars mark chromatographic fractions (Void, Caffeine, Theobromine, + * Theophylline) above the signal trace. Labels stay centred within their bar. Zoom or + * pan the chart to verify that labels reposition to the visible portion of each bar and + * disappear when a bar scrolls fully out of view. */ -export const RangeAnnotationsBasic: Story = { +export const WithRangeAnnotations: Story = { args: { - series: [{ ...chargeVariantData, name: "IgG Sample" }], - title: "Charge Variant Fractions", - rangeAnnotations: adjacentRangeAnnotations, + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Chromatogram with Fraction Windows", + rangeAnnotations: sampleRangeAnnotations, }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step("Chart title is displayed", async () => { - expect(canvas.getByText("Charge Variant Fractions")).toBeInTheDocument(); + expect(canvas.getByText("Chromatogram with Fraction Windows")).toBeInTheDocument(); }); await step("Chart container renders", async () => { expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); }); - await step("Range annotation labels are rendered", async () => { - const labels = canvasElement.querySelectorAll(".annotation-text"); - expect(labels.length).toBeGreaterThanOrEqual(3); - }); - }, - parameters: { - docs: { - description: { - story: - "Adjacent fraction windows with no overlap. All three fit in lane 0 and are rendered as paper-space bars flush with the top of the plot. Colors are supplied per-annotation.", - }, - }, - zephyr: { testCaseId: "SW-T1116" }, - }, -}; - -/** - * Two annotations sharing exactly the same x-range. The auto lane-assignment algorithm - * detects the overlap and places them in separate lanes (lane 0 on top, lane 1 below). - */ -export const RangeAnnotationsSameRange: Story = { - args: { - series: [{ ...chargeVariantData, name: "IgG Sample" }], - title: "Same-Range Annotations (Auto-Stacked)", - rangeAnnotations: sameRangeAnnotations, - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect( - canvas.getByText("Same-Range Annotations (Auto-Stacked)") - ).toBeInTheDocument(); + await step("Trace is rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(1); }); - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + await step("Range annotation labels are rendered", async () => { + expect(canvas.getByText("Caffeine")).toBeInTheDocument(); + expect(canvas.getByText("Theobromine")).toBeInTheDocument(); + expect(canvas.getByText("Theophylline")).toBeInTheDocument(); + expect(canvas.getByText("Void")).toBeInTheDocument(); }); - await step("Both overlapping labels are rendered in separate lanes", async () => { - const labels = canvasElement.querySelectorAll(".annotation-text"); - expect(labels.length).toBeGreaterThanOrEqual(2); + await step("Range annotation shapes are rendered", async () => { + const shapes = canvasElement.querySelectorAll(".shapelayer path"); + expect(shapes.length).toBeGreaterThanOrEqual(4); }); }, parameters: { docs: { description: { story: - "When two range annotations share the same startX/endX the lane auto-assignment stacks them vertically. No `lane` prop is required — overlap is detected automatically.", + "Fraction windows rendered as coloured bars above the chromatogram trace. Labels reposition to stay centred within the visible portion of each bar as the user zooms or pans. Use the mode bar zoom controls or drag-to-zoom to exercise the behaviour.", }, }, - zephyr: { testCaseId: "SW-T1117" }, + zephyr: { testCaseId: "SW-T1116" }, }, }; /** - * A broad outer bracket contains two narrower inner sub-regions. The auto lane-assignment - * puts the outer region in lane 0 (top) and the two inner bars in lane 1 (below), - * creating a two-level hierarchy without any explicit `lane` props. + * Range annotations combined with auto peak detection. The fraction bars sit above the + * axis area while the peak labels (with areas) annotate the signal directly. */ -export const RangeAnnotationsNested: Story = { +export const WithRangeAnnotationsAndPeakDetection: Story = { args: { - series: [{ ...chargeVariantData, name: "IgG Sample" }], - title: "Nested Range Annotations (Auto-Stacked)", - rangeAnnotations: nestedRangeAnnotations, + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Fraction Windows + Peak Detection", + rangeAnnotations: sampleRangeAnnotations, + peakDetectionOptions: { minHeight: 0.1, prominence: 0.05, minDistance: 20 }, + showPeakAreas: true, }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step("Chart title is displayed", async () => { - expect( - canvas.getByText("Nested Range Annotations (Auto-Stacked)") - ).toBeInTheDocument(); + expect(canvas.getByText("Fraction Windows + Peak Detection")).toBeInTheDocument(); }); await step("Chart container renders", async () => { expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); }); - await step("All three annotation labels are rendered", async () => { - const labels = canvasElement.querySelectorAll(".annotation-text"); - expect(labels.length).toBeGreaterThanOrEqual(3); - }); - }, - parameters: { - docs: { - description: { - story: - "A broad outer annotation overlaps two narrower inner annotations. The greedy lane algorithm places the outer bar in lane 0 and both inner bars in lane 1, producing a two-row hierarchy automatically.", - }, - }, - zephyr: { testCaseId: "SW-T1118" }, - }, -}; - -/** - * Explicit `lane` props override auto-assignment. Here the outer bracket is forced to - * lane 1 (visually below the inner bars at lane 0) to demonstrate manual control. - * Also shows `yAnchor: "auto"` which floats the bars just above the local signal peak. - */ -export const RangeAnnotationsExplicitLanes: Story = { - args: { - series: [{ ...chargeVariantData, name: "IgG Sample" }], - title: "Explicit Lane Override + Auto Y-Anchor", - rangeAnnotations: [ - { - label: "Acidic-01", - startX: 4.5, - endX: 5.5, - color: "#FF6B6B", - yAnchor: "auto", - lane: 0, - }, - { - label: "Acidic-02", - startX: 5.6, - endX: 6.8, - color: "#FF3B30", - yAnchor: "auto", - lane: 0, - }, - { - label: "Acidic Region", - startX: 4.5, - endX: 7.5, - color: "#8E8E93", - yAnchor: "auto", - lane: 1, - }, - ], - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect( - canvas.getByText("Explicit Lane Override + Auto Y-Anchor") - ).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + await step("Range annotation labels are rendered", async () => { + expect(canvas.getByText("Caffeine")).toBeInTheDocument(); + expect(canvas.getByText("Theophylline")).toBeInTheDocument(); }); - await step("All annotation labels are rendered", async () => { - const labels = canvasElement.querySelectorAll(".annotation-text"); - expect(labels.length).toBeGreaterThanOrEqual(3); + await step("Peak area annotations are displayed", async () => { + const annotations = canvasElement.querySelectorAll(".annotation-text"); + expect(annotations.length).toBeGreaterThan(4); }); }, parameters: { docs: { description: { story: - "Explicit `lane` values override auto-assignment. The two narrow sub-regions are pinned to lane 0 and the broad outer bracket to lane 1, so it renders below them. `yAnchor: \"auto\"` places all bars in data-space just above the local signal maximum rather than in fixed paper-space.", + "Fraction windows coexist with auto-detected peak labels. The bars reserve space at the top of the plot; peak area annotations appear directly on the signal. Zooming into a single fraction keeps both bar labels and peak labels visible.", }, }, - zephyr: { testCaseId: "SW-T1119" }, + zephyr: { testCaseId: "SW-T1117" }, }, }; diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index 39baa61d..d3af07cb 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -77,6 +77,9 @@ const ChromatogramChart: React.FC = ({ const enablePeakDetection = peakDetectionOptions !== undefined; const plotRef = useRef(null); const theme = usePlotlyTheme(); + // Prevents the plotly_relayout fired by our own annotation update from + // being treated as a user-initiated zoom and triggering another update cycle. + const isAnnotationRelayout = useRef(false); // Memoize processed series with baseline correction const processedSeries = useMemo(() => { @@ -291,7 +294,98 @@ const ChromatogramChart: React.FC = ({ Plotly.newPlot(currentRef, plotData, layout, config); + // Keep range annotation labels centred within the visible portion of their bars + // when the user zooms or pans. Without repositioning, labels whose original x + // (bar centre) is outside the current viewport float off-screen while the coloured + // bar remains visible. + // + // We defer the relayout to requestAnimationFrame so it fires *after* Plotly has + // finished its own RAF-based rendering pass. Calling Plotly.relayout synchronously + // inside plotly_relayout re-enters Plotly's pipeline and blanks the chart. + // isAnnotationRelayout guards against the annotation relayout itself firing another + // round-trip through this handler. + let pendingLabelUpdate: ReturnType | null = null; + + if (rangeAnnotations.length > 0) { + (currentRef as unknown as Plotly.PlotlyHTMLElement).on( + "plotly_relayout", + (eventData: Plotly.PlotRelayoutEvent) => { + const ed = eventData as Record; + + // Skip events caused by our own annotation update to prevent feedback loops. + if (isAnnotationRelayout.current) return; + + // Ignore relayout events that aren't x-axis range changes + // (e.g. y-axis-only changes from the zoom-in/out buttons). + const isXAxisChange = + "xaxis.range[0]" in ed || + ("xaxis.range" in ed && Array.isArray(ed["xaxis.range"])) || + ed["xaxis.autorange"] === true; + if (!isXAxisChange) return; + + // Capture values synchronously before the RAF fires. + const isAutorange = ed["xaxis.autorange"] === true; + const xMin = isAutorange + ? null + : "xaxis.range[0]" in ed + ? (ed["xaxis.range[0]"] as number) + : (ed["xaxis.range"] as [number, number])[0]; + const xMax = isAutorange + ? null + : "xaxis.range[0]" in ed + ? (ed["xaxis.range[1]"] as number) + : (ed["xaxis.range"] as [number, number])[1]; + + if (pendingLabelUpdate !== null) cancelAnimationFrame(pendingLabelUpdate); + + pendingLabelUpdate = requestAnimationFrame(() => { + pendingLabelUpdate = null; + const el = currentRef; + if (!el) return; + + const nextAnnotations = (() => { + if (isAutorange || xMin === null || xMax === null) { + return [...plotlyAnnotations, ...rangeAnnotationLabels]; + } + + const updatedLabels = rangeAnnotationLabels.map((labelAnn, i) => { + const rangeAnn = rangeAnnotations[i]; + if (!rangeAnn) return labelAnn; + + const visibleStart = Math.max(rangeAnn.startX, xMin); + const visibleEnd = Math.min(rangeAnn.endX, xMax); + + if (visibleStart >= visibleEnd) { + // Bar is fully outside the viewport — hide the label but keep x + // within the current range so Plotly cannot use it to expand the axis. + return { ...labelAnn, visible: false, x: (xMin + xMax) / 2 }; + } + + return { + ...labelAnn, + x: (visibleStart + visibleEnd) / 2, + visible: true, + }; + }); + + return [...plotlyAnnotations, ...updatedLabels]; + })(); + + isAnnotationRelayout.current = true; + (Plotly.relayout(el, { + annotations: nextAnnotations, + } as unknown as Partial) as unknown as Promise) + .finally(() => { + isAnnotationRelayout.current = false; + }); + }); + } + ); + } + return () => { + if (pendingLabelUpdate !== null) cancelAnimationFrame(pendingLabelUpdate); + isAnnotationRelayout.current = false; if (currentRef) { Plotly.purge(currentRef); } diff --git a/src/components/charts/ChromatogramChart/rangeAnnotations.ts b/src/components/charts/ChromatogramChart/rangeAnnotations.ts index a27ac19e..2abe8764 100644 --- a/src/components/charts/ChromatogramChart/rangeAnnotations.ts +++ b/src/components/charts/ChromatogramChart/rangeAnnotations.ts @@ -175,6 +175,7 @@ export function buildRangeAnnotationElements( }, xanchor: "center", yanchor: "middle", + cliponaxis: false, }); }); From 201cdbaec3a95135e925b8181dff0196d8f192fc Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Mon, 4 May 2026 20:44:42 -0500 Subject: [PATCH 06/30] Adds props for selecting and highlighting peaks --- .../ChromatogramChart.stories.tsx | 107 +----- .../ChromatogramChart/ChromatogramChart.tsx | 351 +++++++++++++----- .../charts/ChromatogramChart/annotations.ts | 111 +++++- .../charts/ChromatogramChart/constants.ts | 8 + .../ChromatogramChart/rangeAnnotations.ts | 1 + .../charts/ChromatogramChart/types.ts | 80 ++++ 6 files changed, 455 insertions(+), 203 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index f6b15baa..56f39b49 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -1,10 +1,12 @@ +import React, { useState } from "react"; + import { expect, within } from "storybook/test"; import { ChromatogramChart, type ChromatogramSeries, type PeakAnnotation, - type RangeAnnotation, + type PeakSelectEvent, } from "./ChromatogramChart"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -108,14 +110,6 @@ const userDefinedPeaksWithBoundaries: PeakAnnotation[] = [ }, ]; -// Range annotations marking chromatographic fractions across the x-axis -const sampleRangeAnnotations: RangeAnnotation[] = [ - { label: "Void", startX: 0, endX: 2.5, color: "#8E8E93" }, - { label: "Caffeine", startX: 4.5, endX: 7.2, color: "#007AFF" }, - { label: "Theobromine", startX: 11.0, endX: 14.0, color: "#34C759" }, - { label: "Theophylline", startX: 16.8, endX: 19.8, color: "#FF9500" }, -]; - const meta: Meta = { title: "Charts/ChromatogramChart", component: ChromatogramChart, @@ -552,98 +546,3 @@ export const CombinedAutoAndUserPeaks: Story = { zephyr: { testCaseId: "SW-T1115" }, }, }; - -/** - * Horizontal colored bars mark chromatographic fractions (Void, Caffeine, Theobromine, - * Theophylline) above the signal trace. Labels stay centred within their bar. Zoom or - * pan the chart to verify that labels reposition to the visible portion of each bar and - * disappear when a bar scrolls fully out of view. - */ -export const WithRangeAnnotations: Story = { - args: { - series: [{ ...singleInjectionData, name: "Sample A" }], - title: "Chromatogram with Fraction Windows", - rangeAnnotations: sampleRangeAnnotations, - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect(canvas.getByText("Chromatogram with Fraction Windows")).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - }); - - await step("Trace is rendered", async () => { - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBe(1); - }); - - await step("Range annotation labels are rendered", async () => { - expect(canvas.getByText("Caffeine")).toBeInTheDocument(); - expect(canvas.getByText("Theobromine")).toBeInTheDocument(); - expect(canvas.getByText("Theophylline")).toBeInTheDocument(); - expect(canvas.getByText("Void")).toBeInTheDocument(); - }); - - await step("Range annotation shapes are rendered", async () => { - const shapes = canvasElement.querySelectorAll(".shapelayer path"); - expect(shapes.length).toBeGreaterThanOrEqual(4); - }); - }, - parameters: { - docs: { - description: { - story: - "Fraction windows rendered as coloured bars above the chromatogram trace. Labels reposition to stay centred within the visible portion of each bar as the user zooms or pans. Use the mode bar zoom controls or drag-to-zoom to exercise the behaviour.", - }, - }, - zephyr: { testCaseId: "SW-T1116" }, - }, -}; - -/** - * Range annotations combined with auto peak detection. The fraction bars sit above the - * axis area while the peak labels (with areas) annotate the signal directly. - */ -export const WithRangeAnnotationsAndPeakDetection: Story = { - args: { - series: [{ ...singleInjectionData, name: "Sample A" }], - title: "Fraction Windows + Peak Detection", - rangeAnnotations: sampleRangeAnnotations, - peakDetectionOptions: { minHeight: 0.1, prominence: 0.05, minDistance: 20 }, - showPeakAreas: true, - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect(canvas.getByText("Fraction Windows + Peak Detection")).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - }); - - await step("Range annotation labels are rendered", async () => { - expect(canvas.getByText("Caffeine")).toBeInTheDocument(); - expect(canvas.getByText("Theophylline")).toBeInTheDocument(); - }); - - await step("Peak area annotations are displayed", async () => { - const annotations = canvasElement.querySelectorAll(".annotation-text"); - expect(annotations.length).toBeGreaterThan(4); - }); - }, - parameters: { - docs: { - description: { - story: - "Fraction windows coexist with auto-detected peak labels. The bars reserve space at the top of the plot; peak area annotations appear directly on the signal. Zooming into a single fraction keeps both bar labels and peak labels visible.", - }, - }, - zephyr: { testCaseId: "SW-T1117" }, - }, -}; diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index d3af07cb..e1b4f944 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -3,13 +3,13 @@ import React, { useEffect, useMemo, useRef } from "react"; import { CHART_COLORS } from "../../../utils/colors"; - import { groupOverlappingPeaks, createGroupAnnotations, + resolveSelectionAppearance, } from "./annotations"; import { createBoundaryMarkerTraces } from "./boundaryMarkers"; -import { CHROMATOGRAM_LAYOUT } from "./constants"; +import { CHROMATOGRAM_LAYOUT, CHROMATOGRAM_TRACE } from "./constants"; import { validateSeriesData, applyBaselineCorrection, @@ -23,6 +23,8 @@ import { buildRangeAnnotationElements } from "./rangeAnnotations"; import type { ChromatogramSeries, PeakAnnotation, + PeakSelectEvent, + PeakSelectionAppearance, BaselineCorrectionMethod, BoundaryMarkerStyle, BoundaryMarkerType, @@ -38,6 +40,8 @@ import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; export type { ChromatogramSeries, PeakAnnotation, + PeakSelectEvent, + PeakSelectionAppearance, RangeAnnotation, BaselineCorrectionMethod, BoundaryMarkerStyle, @@ -72,15 +76,39 @@ const ChromatogramChart: React.FC = ({ showExportButton = true, rangeAnnotations = [], rangeAnnotationOverlapThreshold = 0, + selectedPeakIds, + onPeakClick, + onPeakHover, + selectionAppearance, }) => { - // Derive peak detection state from options const enablePeakDetection = peakDetectionOptions !== undefined; const plotRef = useRef(null); const theme = usePlotlyTheme(); + // Prevents the plotly_relayout fired by our own annotation update from // being treated as a user-initiated zoom and triggering another update cycle. const isAnnotationRelayout = useRef(false); + // Stable refs for callbacks — avoids including them in effect dep arrays + // (consumers often pass arrow functions that change identity every render). + const onPeakClickRef = useRef(onPeakClick); + const onPeakHoverRef = useRef(onPeakHover); + onPeakClickRef.current = onPeakClick; + onPeakHoverRef.current = onPeakHover; + + // Tracks the series index whose line is currently thickened on hover. + const thickenedSeriesRef = useRef(null); + + // Holds the latest peak Plotly annotations so that closures (e.g. the range + // repositioning handler) always use the current selection-styled annotations. + const peakAnnotationsRef = useRef[]>([]); + + // Holds the latest range annotation labels, kept in sync by both the main + // effect (initial render) and the range-repositioning handler (on pan/zoom). + // The selection effect reads this ref so it can combine peak + range labels + // in Plotly.relayout without wiping out any repositioning done by the user. + const rangeAnnotationLabelsRef = useRef[]>([]); + // Memoize processed series with baseline correction const processedSeries = useMemo(() => { return series.map((s) => { @@ -98,7 +126,6 @@ const ChromatogramChart: React.FC = ({ if (annotations.length === 0 || processedSeries.length === 0) { return annotations; } - // Use first series data for index lookup (user annotations apply to first series) const { x, y } = processedSeries[0]; return processUserAnnotations(annotations, x, y); }, [annotations, processedSeries]); @@ -117,11 +144,115 @@ const ChromatogramChart: React.FC = ({ return peaks; }, [processedSeries, enablePeakDetection, peakDetectionOptions]); + // Resolve selection appearance defaults once (stable as long as individual + // fields don't change — consumers should memoize selectionAppearance if needed). + const resolvedAppearance = useMemo( + () => resolveSelectionAppearance(selectionAppearance), + // eslint-disable-next-line react-hooks/exhaustive-deps + [ + selectionAppearance?.selected?.borderColor, + selectionAppearance?.selected?.backgroundColor, + selectionAppearance?.selected?.bold, + selectionAppearance?.unselected?.opacity, + selectionAppearance?.hoverLineWidthMultiplier, + ] + ); + + // All peaks that can be interacted with (clicked / hovered / selected), + // each with a stable ID and the metadata needed for PeakSelectEvent. + const allPeaksForInteraction = useMemo(() => { + const result: Array<{ + peak: PeakAnnotation & { id: string }; + seriesIndex: number; + seriesName: string; + isAutoDetected: boolean; + }> = []; + + processedAnnotations.forEach((ann, i) => { + result.push({ + peak: { ...ann, id: ann.id ?? `user-ann-${i}` }, + seriesIndex: 0, + seriesName: series[0]?.name ?? "", + isAutoDetected: false, + }); + }); + + allDetectedPeaks.forEach(({ peaks, seriesIndex }) => { + peaks.forEach((peak, peakIndex) => { + result.push({ + peak: { ...peak, id: `peak-${seriesIndex}-${peakIndex}` }, + seriesIndex, + seriesName: series[seriesIndex]?.name ?? `Series ${seriesIndex + 1}`, + isAutoDetected: true, + }); + }); + }); + + return result; + }, [processedAnnotations, allDetectedPeaks, series]); + + // Build Plotly annotation objects for all peaks, applying selection styling. + // This memo re-runs when selectedPeakIds or resolvedAppearance changes so the + // selection effect can call Plotly.relayout with updated annotations without + // triggering a full chart rebuild. + const peakAnnotations = useMemo(() => { + const anySelected = (selectedPeakIds?.length ?? 0) > 0; + const options = { + selectedPeakIds: selectedPeakIds ?? [], + anySelected, + appearance: resolvedAppearance, + }; + + const allPeaksWithMeta: PeakWithMeta[] = []; + + processedAnnotations.forEach((ann, i) => { + allPeaksWithMeta.push({ + peak: { ...ann, id: ann.id ?? `user-ann-${i}` }, + seriesIndex: -1, + }); + }); + + if (showPeakAreas && enablePeakDetection) { + allDetectedPeaks.forEach(({ peaks, seriesIndex }) => { + peaks.forEach((peak, peakIndex) => { + allPeaksWithMeta.push({ + peak: { ...peak, id: `peak-${seriesIndex}-${peakIndex}` }, + seriesIndex, + }); + }); + }); + } + + const groups = groupOverlappingPeaks(allPeaksWithMeta, annotationOverlapThreshold); + const result: Partial[] = []; + for (const group of groups) { + result.push(...createGroupAnnotations(group, options)); + } + return result; + }, [ + processedAnnotations, + allDetectedPeaks, + showPeakAreas, + enablePeakDetection, + annotationOverlapThreshold, + selectedPeakIds, + resolvedAppearance, + ]); + + // Keep the ref in sync every render so that closures in effects always read + // the latest selection-styled annotations. + peakAnnotationsRef.current = peakAnnotations; + + // ── Main chart effect ────────────────────────────────────────────────────── + // Rebuilds the full chart when structural props change (data, size, axes, …). + // selectedPeakIds and selectionAppearance are intentionally excluded from + // deps — selection changes are handled by the lightweight selection effect + // below via Plotly.relayout, which preserves the user's current zoom/pan. useEffect(() => { const currentRef = plotRef.current; if (!currentRef || series.length === 0) return; - // Build trace data with auto-assigned colors + // Build trace data const plotData: Plotly.Data[] = processedSeries.map((s, index) => { const traceColor = s.color || CHART_COLORS[index % CHART_COLORS.length]; const extraContent = buildHoverExtraContent(s.name, s.metadata); @@ -134,55 +265,49 @@ const ChromatogramChart: React.FC = ({ name: s.name, line: { color: traceColor, - width: 1.5, + width: CHROMATOGRAM_TRACE.BASE_LINE_WIDTH, }, hovertemplate: `%{x:.2f} ${xAxisTitle}
%{y:.2f} ${yAxisTitle}${extraContent}`, }; if (showMarkers) { - trace.marker = { - size: markerSize, - color: traceColor, - }; + trace.marker = { size: markerSize, color: traceColor }; } return trace; }); - // Add peak boundary markers if enabled + // Peak boundary markers if (boundaryMarkers !== "none") { const peaksWithData = collectPeaksWithBoundaryData(allDetectedPeaks, processedAnnotations, processedSeries); if (peaksWithData.length > 0) { - const boundaryTraces = createBoundaryMarkerTraces(peaksWithData); - plotData.push(...boundaryTraces); + plotData.push(...createBoundaryMarkerTraces(peaksWithData)); } } - // Collect all peaks for unified staggering logic - const allPeaksWithMeta: PeakWithMeta[] = []; - - // Add user-defined annotations (seriesIndex -1 indicates user-defined) - processedAnnotations.forEach((ann) => { - allPeaksWithMeta.push({ peak: ann, seriesIndex: -1 }); - }); - - // Add auto-detected peaks if enabled - if (showPeakAreas && enablePeakDetection) { - allDetectedPeaks.forEach(({ peaks, seriesIndex }) => { - peaks.forEach((peak) => { - allPeaksWithMeta.push({ peak, seriesIndex }); - }); - }); - } - - // Group all overlapping peaks and create annotations with staggering - const groups = groupOverlappingPeaks(allPeaksWithMeta, annotationOverlapThreshold); - const plotlyAnnotations: Partial[] = []; - - for (const group of groups) { - plotlyAnnotations.push(...createGroupAnnotations(group)); + // Invisible hit-area markers for click / hover on peaks. + // hovertemplate "" suppresses the tooltip entry while still + // allowing plotly_click and plotly_hover to fire for these points. + if (allPeaksForInteraction.length > 0) { + const hitAreaTrace: Plotly.Data = { + x: allPeaksForInteraction.map((p) => p.peak.x), + y: allPeaksForInteraction.map((p) => p.peak.y), + type: "scatter" as const, + mode: "markers" as const, + marker: { size: 14, opacity: 0 }, + hovertemplate: "", + showlegend: false, + name: "", + customdata: allPeaksForInteraction.map((p) => ({ + id: p.peak.id, + peak: p.peak, + seriesIndex: p.seriesIndex, + seriesName: p.seriesName, + isAutoDetected: p.isAutoDetected, + })) as unknown as Plotly.Datum[], + }; + plotData.push(hitAreaTrace); } - // Build range annotation shapes and labels. - // yDomainMax shrinks the y-axis domain so "top" bars live in the blank zone above it. + // Range annotation shapes and labels const { shapes: rangeShapes, annotations: rangeAnnotationLabels, yDomainMax } = rangeAnnotations.length > 0 ? buildRangeAnnotationElements( @@ -192,15 +317,14 @@ const ChromatogramChart: React.FC = ({ ) : { shapes: [], annotations: [], yDomainMax: 1.0 }; + // Store initial range labels so the selection effect can combine them + rangeAnnotationLabelsRef.current = rangeAnnotationLabels; + const layout: Partial = { title: title ? { text: title, - font: { - size: 20, - family: "Inter, sans-serif", - color: theme.textColor, - }, + font: { size: 20, family: "Inter, sans-serif", color: theme.textColor }, } : undefined, width, @@ -269,7 +393,7 @@ const ChromatogramChart: React.FC = ({ font: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, }, showlegend: showLegend && series.length > 1, - annotations: [...plotlyAnnotations, ...rangeAnnotationLabels], + annotations: [...peakAnnotationsRef.current, ...rangeAnnotationLabels], shapes: rangeShapes, }; @@ -286,24 +410,71 @@ const ChromatogramChart: React.FC = ({ toImageButtonOptions: { format: "png", filename: "chromatogram", - width: width, - height: height, + width, + height, }, }), }; Plotly.newPlot(currentRef, plotData, layout, config); - // Keep range annotation labels centred within the visible portion of their bars - // when the user zooms or pans. Without repositioning, labels whose original x - // (bar centre) is outside the current viewport float off-screen while the coloured - // bar remains visible. - // - // We defer the relayout to requestAnimationFrame so it fires *after* Plotly has - // finished its own RAF-based rendering pass. Calling Plotly.relayout synchronously - // inside plotly_relayout re-enters Plotly's pipeline and blanks the chart. - // isAnnotationRelayout guards against the annotation relayout itself firing another - // round-trip through this handler. + // ── Event: peak click ────────────────────────────────────────────────── + (currentRef as unknown as Plotly.PlotlyHTMLElement).on( + "plotly_click", + (eventData: Plotly.PlotMouseEvent) => { + if (!onPeakClickRef.current) return; + const peakPoint = eventData.points.find((p) => p.customdata != null); + if (!peakPoint) return; + onPeakClickRef.current(peakPoint.customdata as unknown as PeakSelectEvent); + } + ); + + // ── Event: trace hover / peak hover ─────────────────────────────────── + // Trace thickening fires on hover over any point on a series trace (matches + // SST ChromatogramPanel behaviour). The onPeakHover callback fires only when + // the cursor is over an invisible peak hit-area marker. + (currentRef as unknown as Plotly.PlotlyHTMLElement).on( + "plotly_hover", + (eventData: Plotly.PlotHoverEvent) => { + // General trace thickening — first hovered point that belongs to a series trace + const pt = eventData.points[0]; + if (pt && pt.curveNumber < processedSeries.length) { + const targetIdx = pt.curveNumber; + if (thickenedSeriesRef.current !== targetIdx) { + if (thickenedSeriesRef.current !== null) { + Plotly.restyle(currentRef, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH } as Plotly.Data, [thickenedSeriesRef.current]); + } + Plotly.restyle( + currentRef, + { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH * resolvedAppearance.hoverLineWidthMultiplier } as Plotly.Data, + [targetIdx] + ); + thickenedSeriesRef.current = targetIdx; + } + } + + // Peak-specific callback — only when near an invisible hit-area marker + const peakPoint = eventData.points.find((p) => p.customdata != null); + if (peakPoint) { + onPeakHoverRef.current?.(peakPoint.customdata as unknown as PeakSelectEvent); + } + } + ); + + (currentRef as unknown as Plotly.PlotlyHTMLElement).on( + "plotly_unhover", + () => { + onPeakHoverRef.current?.(null); + if (thickenedSeriesRef.current !== null) { + Plotly.restyle(currentRef, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH } as Plotly.Data, [thickenedSeriesRef.current]); + thickenedSeriesRef.current = null; + } + } + ); + + // ── Range annotation label repositioning on pan / zoom ───────────────── + // Deferred via requestAnimationFrame to avoid re-entering Plotly's own RAF + // rendering pass. isAnnotationRelayout guards against feedback loops. let pendingLabelUpdate: ReturnType | null = null; if (rangeAnnotations.length > 0) { @@ -311,19 +482,14 @@ const ChromatogramChart: React.FC = ({ "plotly_relayout", (eventData: Plotly.PlotRelayoutEvent) => { const ed = eventData as Record; - - // Skip events caused by our own annotation update to prevent feedback loops. if (isAnnotationRelayout.current) return; - // Ignore relayout events that aren't x-axis range changes - // (e.g. y-axis-only changes from the zoom-in/out buttons). const isXAxisChange = "xaxis.range[0]" in ed || ("xaxis.range" in ed && Array.isArray(ed["xaxis.range"])) || ed["xaxis.autorange"] === true; if (!isXAxisChange) return; - // Capture values synchronously before the RAF fires. const isAutorange = ed["xaxis.autorange"] === true; const xMin = isAutorange ? null @@ -340,42 +506,32 @@ const ChromatogramChart: React.FC = ({ pendingLabelUpdate = requestAnimationFrame(() => { pendingLabelUpdate = null; - const el = currentRef; - if (!el) return; + if (!currentRef) return; - const nextAnnotations = (() => { + const updatedRangeLabels = (() => { if (isAutorange || xMin === null || xMax === null) { - return [...plotlyAnnotations, ...rangeAnnotationLabels]; + return rangeAnnotationLabels; } - - const updatedLabels = rangeAnnotationLabels.map((labelAnn, i) => { + return rangeAnnotationLabels.map((labelAnn, i) => { const rangeAnn = rangeAnnotations[i]; if (!rangeAnn) return labelAnn; - const visibleStart = Math.max(rangeAnn.startX, xMin); const visibleEnd = Math.min(rangeAnn.endX, xMax); - if (visibleStart >= visibleEnd) { - // Bar is fully outside the viewport — hide the label but keep x - // within the current range so Plotly cannot use it to expand the axis. return { ...labelAnn, visible: false, x: (xMin + xMax) / 2 }; } - - return { - ...labelAnn, - x: (visibleStart + visibleEnd) / 2, - visible: true, - }; + return { ...labelAnn, x: (visibleStart + visibleEnd) / 2, visible: true }; }); - - return [...plotlyAnnotations, ...updatedLabels]; })(); isAnnotationRelayout.current = true; - (Plotly.relayout(el, { - annotations: nextAnnotations, + (Plotly.relayout(currentRef, { + annotations: [...peakAnnotationsRef.current, ...updatedRangeLabels], } as unknown as Partial) as unknown as Promise) .finally(() => { + // Keep rangeAnnotationLabelsRef in sync with the repositioned labels + // so the selection effect can use them correctly. + rangeAnnotationLabelsRef.current = updatedRangeLabels; isAnnotationRelayout.current = false; }); }); @@ -386,17 +542,35 @@ const ChromatogramChart: React.FC = ({ return () => { if (pendingLabelUpdate !== null) cancelAnimationFrame(pendingLabelUpdate); isAnnotationRelayout.current = false; - if (currentRef) { - Plotly.purge(currentRef); - } + thickenedSeriesRef.current = null; + if (currentRef) Plotly.purge(currentRef); }; }, [ - processedSeries, allDetectedPeaks, series.length, width, height, title, xAxisTitle, yAxisTitle, - processedAnnotations, xRange, yRange, showLegend, showGridX, showGridY, showMarkers, markerSize, - showCrosshairs, enablePeakDetection, peakDetectionOptions, showPeakAreas, boundaryMarkers, - annotationOverlapThreshold, showExportButton, theme, rangeAnnotations, rangeAnnotationOverlapThreshold, + processedSeries, allDetectedPeaks, allPeaksForInteraction, series.length, + width, height, title, xAxisTitle, yAxisTitle, + processedAnnotations, xRange, yRange, showLegend, showGridX, showGridY, + showMarkers, markerSize, showCrosshairs, enablePeakDetection, peakDetectionOptions, + showPeakAreas, boundaryMarkers, annotationOverlapThreshold, showExportButton, + theme, rangeAnnotations, rangeAnnotationOverlapThreshold, + // resolvedAppearance included so hover multiplier stays in sync with the + // event handler closure without it being in a ref itself. + resolvedAppearance, ]); + // ── Selection update effect ──────────────────────────────────────────────── + // Runs when selectedPeakIds / selectionAppearance change (peakAnnotations + // recomputes). Uses Plotly.relayout so the user's zoom/pan state is preserved. + useEffect(() => { + const el = plotRef.current; + if (!el) return; + // Guard: skip if the chart hasn't been initialized by the main effect yet. + if (!(el as { _fullLayout?: unknown })._fullLayout) return; + + Plotly.relayout(el, { + annotations: [...peakAnnotations, ...rangeAnnotationLabelsRef.current], + } as unknown as Partial); + }, [peakAnnotations]); + return (
@@ -405,4 +579,3 @@ const ChromatogramChart: React.FC = ({ }; export { ChromatogramChart }; - diff --git a/src/components/charts/ChromatogramChart/annotations.ts b/src/components/charts/ChromatogramChart/annotations.ts index 3dc3ddfb..c9e92ce8 100644 --- a/src/components/charts/ChromatogramChart/annotations.ts +++ b/src/components/charts/ChromatogramChart/annotations.ts @@ -6,9 +6,52 @@ import { COLORS, CHART_COLORS } from "../../../utils/colors"; import { CHROMATOGRAM_ANNOTATION } from "./constants"; -import type { PeakAnnotation, PeakWithMeta } from "./types"; +import type { PeakAnnotation, PeakSelectionAppearance, PeakWithMeta } from "./types"; import type Plotly from "plotly.js-dist"; +// ── Selection appearance helpers ───────────────────────────────────────────── + +export interface ResolvedSelectionAppearance { + selected: { + borderColor: string; + backgroundColor: string; + bold: boolean; + }; + unselected: { + opacity: number; + }; + hoverLineWidthMultiplier: number; +} + +const DEFAULT_RESOLVED_APPEARANCE: ResolvedSelectionAppearance = { + selected: { + borderColor: "#3b82f6", + backgroundColor: "#dbeafe", + bold: true, + }, + unselected: { opacity: 0.4 }, + hoverLineWidthMultiplier: 5 / 3, +}; + +export function resolveSelectionAppearance( + appearance?: PeakSelectionAppearance +): ResolvedSelectionAppearance { + if (!appearance) return DEFAULT_RESOLVED_APPEARANCE; + const d = DEFAULT_RESOLVED_APPEARANCE; + return { + selected: { + borderColor: appearance.selected?.borderColor ?? d.selected.borderColor, + backgroundColor: appearance.selected?.backgroundColor ?? d.selected.backgroundColor, + bold: appearance.selected?.bold ?? d.selected.bold, + }, + unselected: { + opacity: appearance.unselected?.opacity ?? d.unselected.opacity, + }, + hoverLineWidthMultiplier: + appearance.hoverLineWidthMultiplier ?? d.hoverLineWidthMultiplier, + }; +} + /** * Annotation slot positions for peak labels */ @@ -60,6 +103,40 @@ export function groupOverlappingPeaks( return groups; } +interface PeakAnnotationOptions { + selectedPeakIds?: string[]; + anySelected?: boolean; + appearance?: ResolvedSelectionAppearance; +} + +interface AnnotationBorderStyle { + bgcolor: string; + bordercolor: string | undefined; + borderwidth: number; + opacity?: number; +} + +/** Derives border/background/opacity from selection state — extracted to keep + * createPeakAnnotation within the allowed cognitive complexity budget. */ +function resolveAnnotationBorderStyle( + isSelected: boolean, + isDimmed: boolean, + isUserDefined: boolean, + seriesColor: string, + appearance: ResolvedSelectionAppearance +): AnnotationBorderStyle { + const bgcolor = isSelected ? appearance.selected.backgroundColor : COLORS.WHITE; + let bordercolor: string | undefined; + if (isSelected) { + bordercolor = appearance.selected.borderColor; + } else { + bordercolor = isUserDefined ? undefined : seriesColor; + } + const borderwidth = isSelected ? 2 : isUserDefined ? 0 : 1; + const opacity = isDimmed ? appearance.unselected.opacity : undefined; + return { bgcolor, bordercolor, borderwidth, ...(opacity === undefined ? {} : { opacity }) }; +} + /** * Create a Plotly annotation for a peak. * seriesIndex of -1 indicates a user-defined annotation (uses grey/black styling). @@ -67,21 +144,36 @@ export function groupOverlappingPeaks( export function createPeakAnnotation( peak: PeakAnnotation, seriesIndex: number, - slot: { ax: number; ay: number } + slot: { ax: number; ay: number }, + options: PeakAnnotationOptions = {} ): Partial { + const { + selectedPeakIds = [], + anySelected = false, + appearance = DEFAULT_RESOLVED_APPEARANCE, + } = options; + const isUserDefined = seriesIndex === -1; const color = isUserDefined ? COLORS.GREY_500 : CHART_COLORS[seriesIndex % CHART_COLORS.length]; const textColor = isUserDefined ? COLORS.BLACK_900 : color; - // Use provided text or auto-generate from computed area - const text = peak.text ?? (peak._computed?.area === undefined ? "" : `Area: ${peak._computed.area.toFixed(2)}`); + const rawText = peak.text ?? (peak._computed?.area === undefined ? "" : `Area: ${peak._computed.area.toFixed(2)}`); + + const isSelected = peak.id !== undefined && selectedPeakIds.includes(peak.id); + const isDimmed = !isSelected && anySelected; + + const text = isSelected && appearance.selected.bold ? `${rawText}` : rawText; // For user-defined annotations, respect their ax/ay if provided const ax = isUserDefined && peak.ax !== undefined ? peak.ax : slot.ax; const ay = isUserDefined && peak.ay !== undefined ? peak.ay : slot.ay; + const borderStyle = resolveAnnotationBorderStyle( + isSelected, isDimmed, isUserDefined, color, appearance + ); + return { x: peak.x, y: peak.y, @@ -100,10 +192,8 @@ export function createPeakAnnotation( color: textColor, family: "Inter, sans-serif", }, - bgcolor: COLORS.WHITE, borderpad: 2, - bordercolor: isUserDefined ? undefined : color, - borderwidth: isUserDefined ? 0 : 1, + ...borderStyle, }; } @@ -111,11 +201,12 @@ export function createPeakAnnotation( * Create annotations for a group of peaks, handling overlap positioning */ export function createGroupAnnotations( - group: PeakWithMeta[] + group: PeakWithMeta[], + options: PeakAnnotationOptions = {} ): Partial[] { if (group.length === 1) { const { peak, seriesIndex } = group[0]; - return [createPeakAnnotation(peak, seriesIndex, ANNOTATION_SLOTS.default)]; + return [createPeakAnnotation(peak, seriesIndex, ANNOTATION_SLOTS.default, options)]; } // Sort by intensity (y, lowest first) so lower peaks get closer annotations @@ -124,7 +215,7 @@ export function createGroupAnnotations( return sortedGroup.map(({ peak, seriesIndex }, slotIndex) => { const slot = ANNOTATION_SLOTS.overlap[slotIndex % ANNOTATION_SLOTS.overlap.length]; - return createPeakAnnotation(peak, seriesIndex, slot); + return createPeakAnnotation(peak, seriesIndex, slot, options); }); } diff --git a/src/components/charts/ChromatogramChart/constants.ts b/src/components/charts/ChromatogramChart/constants.ts index 6250c77e..6e60ed72 100644 --- a/src/components/charts/ChromatogramChart/constants.ts +++ b/src/components/charts/ChromatogramChart/constants.ts @@ -32,6 +32,14 @@ export const CHROMATOGRAM_ANNOTATION = { AUTO_ANNOTATION_FONT_SIZE: 10, } as const; +/** + * Trace rendering constants + */ +export const CHROMATOGRAM_TRACE = { + /** Base line width in pixels for all series traces */ + BASE_LINE_WIDTH: 1.5, +} as const; + /** * Constants for range (fraction/region) annotations */ diff --git a/src/components/charts/ChromatogramChart/rangeAnnotations.ts b/src/components/charts/ChromatogramChart/rangeAnnotations.ts index 2abe8764..f93c25b9 100644 --- a/src/components/charts/ChromatogramChart/rangeAnnotations.ts +++ b/src/components/charts/ChromatogramChart/rangeAnnotations.ts @@ -175,6 +175,7 @@ export function buildRangeAnnotationElements( }, xanchor: "center", yanchor: "middle", + // @ts-ignore cliponaxis is a valid Plotly annotation property missing from the type definitions cliponaxis: false, }); }); diff --git a/src/components/charts/ChromatogramChart/types.ts b/src/components/charts/ChromatogramChart/types.ts index e7a84941..d53ccc8c 100644 --- a/src/components/charts/ChromatogramChart/types.ts +++ b/src/components/charts/ChromatogramChart/types.ts @@ -46,6 +46,13 @@ export interface PeakComputedFields { * Used for both user-provided annotations and auto-detected peaks. */ export interface PeakAnnotation { + /** + * Stable identifier used for selection (selectedPeakIds). + * Auto-generated by the component if omitted: + * - User annotations: "user-ann-{index}" + * - Auto-detected peaks: "peak-{seriesIndex}-{peakIndex}" + */ + id?: string; /** Retention time of the peak (x-axis position) */ x: number; /** Signal intensity at peak (y-axis position) */ @@ -71,6 +78,48 @@ export interface PeakAnnotation { _computed?: PeakComputedFields; } +/** + * Payload delivered to onPeakClick and onPeakHover callbacks. + */ +export interface PeakSelectEvent { + /** The resolved peak ID (matches id field on PeakAnnotation) */ + id: string; + /** The annotation object for this peak */ + peak: PeakAnnotation; + /** Index of the series this peak belongs to (0-based; -1 for legacy user annotations) */ + seriesIndex: number; + /** Display name of the series */ + seriesName: string; + /** True when the peak was found by automatic peak detection, false for user-provided annotations */ + isAutoDetected: boolean; +} + +/** + * Visual overrides for the selected / unselected / hover states. + * All fields are optional — defaults are applied for any omitted field. + * + * Defaults: + * selected.borderColor "#3b82f6" + * selected.backgroundColor "#dbeafe" + * selected.bold true + * unselected.opacity 0.4 (applied when any other peak is selected) + * hoverLineWidthMultiplier 1.67 (1.5 px → ~2.5 px) + */ +export interface PeakSelectionAppearance { + selected?: { + borderColor?: string; + backgroundColor?: string; + /** Wrap annotation text in tags when selected */ + bold?: boolean; + }; + unselected?: { + /** Opacity of annotations whose peak is NOT selected while another peak IS selected (0–1) */ + opacity?: number; + }; + /** Multiplier applied to the base line width (1.5 px) on peak hover */ + hoverLineWidthMultiplier?: number; +} + /** * Baseline correction method */ @@ -209,6 +258,37 @@ export interface ChromatogramChartProps { * separate lanes. Default: 0 (only exact overlaps trigger stacking). */ rangeAnnotationOverlapThreshold?: number; + + // ── Peak selection / interaction ──────────────────────────────────────────── + + /** + * IDs of peaks that should render in the "selected" visual state. + * Fully controlled — the component owns no selection state of its own. + * IDs match the id field on PeakAnnotation (user-provided) or the + * auto-generated IDs for auto-detected peaks ("peak-{seriesIndex}-{peakIndex}"). + */ + selectedPeakIds?: string[]; + + /** + * Called when the user clicks on a peak annotation or its invisible hit target. + * The consumer decides whether to add/remove the id from selectedPeakIds + * (toggle vs. replace behaviour is the consumer's responsibility). + */ + onPeakClick?: (event: PeakSelectEvent) => void; + + /** + * Called when the cursor enters or leaves a peak hit target. + * Receives null on unhover. As a side-effect, the series line for the + * hovered peak thickens by selectionAppearance.hoverLineWidthMultiplier. + */ + onPeakHover?: (event: PeakSelectEvent | null) => void; + + /** + * Override the default visual behaviour for selected / unselected / hover states. + * All sub-fields are optional — defaults are applied for any omitted value. + * Only has a visual effect when selectedPeakIds or onPeakClick/onPeakHover are used. + */ + selectionAppearance?: PeakSelectionAppearance; } /** From f5bdbb62518a98ad33ff6dbebe68fa619cd9bb6d Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Mon, 4 May 2026 20:57:09 -0500 Subject: [PATCH 07/30] Puts annotation closer to trace --- .../ChromatogramChart.stories.tsx | 208 +++++++++++++++++- .../ChromatogramChart/ChromatogramChart.tsx | 3 + .../charts/ChromatogramChart/annotations.ts | 42 +++- .../charts/ChromatogramChart/constants.ts | 2 + .../charts/ChromatogramChart/types.ts | 7 + 5 files changed, 257 insertions(+), 5 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index 56f39b49..b7c23fa9 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -1,5 +1,4 @@ -import React, { useState } from "react"; - +import { useState } from "react"; import { expect, within } from "storybook/test"; import { @@ -7,6 +6,7 @@ import { type ChromatogramSeries, type PeakAnnotation, type PeakSelectEvent, + type RangeAnnotation, } from "./ChromatogramChart"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -110,6 +110,21 @@ const userDefinedPeaksWithBoundaries: PeakAnnotation[] = [ }, ]; +// Range annotations marking chromatographic fractions across the x-axis +const sampleRangeAnnotations: RangeAnnotation[] = [ + { label: "Void", startX: 0, endX: 2.5, color: "#8E8E93" }, + { label: "Caffeine", startX: 4.5, endX: 7.2, color: "#007AFF" }, + { label: "Theobromine", startX: 11.0, endX: 14.0, color: "#34C759" }, + { label: "Theophylline", startX: 16.8, endX: 19.8, color: "#FF9500" }, +]; + +// Annotations with stable IDs for selection stories +const selectableAnnotations: PeakAnnotation[] = [ + { id: "caffeine", x: 5.8, y: 420, text: "Caffeine" }, + { id: "theobromine", x: 12.5, y: 180, text: "Theobromine" }, + { id: "theophylline", x: 18.3, y: 350, text: "Theophylline" }, +]; + const meta: Meta = { title: "Charts/ChromatogramChart", component: ChromatogramChart, @@ -546,3 +561,192 @@ export const CombinedAutoAndUserPeaks: Story = { zephyr: { testCaseId: "SW-T1115" }, }, }; + +/** + * Horizontal colored bars mark chromatographic fractions (Void, Caffeine, Theobromine, + * Theophylline) above the signal trace. Labels stay centred within their bar. Zoom or + * pan the chart to verify that labels reposition to the visible portion of each bar and + * disappear when a bar scrolls fully out of view. + */ +export const WithRangeAnnotations: Story = { + args: { + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Chromatogram with Fraction Windows", + rangeAnnotations: sampleRangeAnnotations, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect(canvas.getByText("Chromatogram with Fraction Windows")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Trace is rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(1); + }); + + await step("Range annotation labels are rendered", async () => { + expect(canvas.getByText("Caffeine")).toBeInTheDocument(); + expect(canvas.getByText("Theobromine")).toBeInTheDocument(); + expect(canvas.getByText("Theophylline")).toBeInTheDocument(); + expect(canvas.getByText("Void")).toBeInTheDocument(); + }); + + await step("Range annotation shapes are rendered", async () => { + const shapes = canvasElement.querySelectorAll(".shapelayer path"); + expect(shapes.length).toBeGreaterThanOrEqual(4); + }); + }, + parameters: { + docs: { + description: { + story: + "Fraction windows rendered as coloured bars above the chromatogram trace. Labels reposition to stay centred within the visible portion of each bar as the user zooms or pans.", + }, + }, + zephyr: { testCaseId: "SW-T1116" }, + }, +}; + +/** + * Range annotations combined with auto peak detection. The fraction bars sit above the + * axis area while the peak labels (with areas) annotate the signal directly. + */ +export const WithRangeAnnotationsAndPeakDetection: Story = { + args: { + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Fraction Windows + Peak Detection", + rangeAnnotations: sampleRangeAnnotations, + peakDetectionOptions: { minHeight: 0.1, prominence: 0.05, minDistance: 20 }, + showPeakAreas: true, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect(canvas.getByText("Fraction Windows + Peak Detection")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Range annotation labels are rendered", async () => { + expect(canvas.getByText("Theophylline")).toBeInTheDocument(); + expect(canvas.getByText("Void")).toBeInTheDocument(); + }); + + await step("Peak area annotations are displayed", async () => { + const annotations = canvasElement.querySelectorAll(".annotation-text"); + expect(annotations.length).toBeGreaterThan(4); + }); + }, + parameters: { + docs: { + description: { + story: + "Fraction windows coexist with auto-detected peak labels. The bars reserve space at the top of the plot; peak area annotations appear directly on the signal.", + }, + }, + zephyr: { testCaseId: "SW-T1117" }, + }, +}; + +/** + * Demonstrates click-to-select and hover feedback on individual peaks. + * Click any peak label or its invisible hit target to toggle selection (blue highlight). + * Unselected peaks dim when another is selected. Hover thickens the trace line. + */ +export const PeakHoverAndSelection: StoryObj = { + render: (args) => { + const [selectedPeakIds, setSelectedPeakIds] = useState([]); + const [hoveredPeak, setHoveredPeak] = useState(null); + + const handlePeakClick = (event: PeakSelectEvent) => { + setSelectedPeakIds((prev) => + prev.includes(event.id) ? prev.filter((id) => id !== event.id) : [...prev, event.id] + ); + }; + + return ( +
+ +
+ {hoveredPeak && ( +
Hovering: {hoveredPeak.peak.text ?? hoveredPeak.id}
+ )} + {selectedPeakIds.length > 0 && ( +
+ Selected: {selectedPeakIds.join(", ")} + {" "} + +
+ )} +
+
+ ); + }, + args: { + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Peak Hover and Selection", + annotations: selectableAnnotations, + }, + parameters: { + docs: { + description: { + story: + "Click a peak to select it (blue border, bold label). Click again to deselect. Other peaks dim while one is selected. Hover over the trace to thicken the line.", + }, + }, + zephyr: { testCaseId: "SW-T1118" }, + }, +}; + +/** + * Inline annotation style: labels float directly above the trace at the peak Y value + * with no arrow. Cleaner for dense chromatograms where arrows create visual noise. + */ +export const InlineAnnotationStyle: Story = { + args: { + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Inline Annotation Style", + annotations: selectableAnnotations, + annotationStyle: "inline", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect(canvas.getByText("Inline Annotation Style")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Inline annotation labels are displayed", async () => { + expect(canvas.getByText("Caffeine")).toBeInTheDocument(); + expect(canvas.getByText("Theobromine")).toBeInTheDocument(); + expect(canvas.getByText("Theophylline")).toBeInTheDocument(); + }); + }, + parameters: { + docs: { + description: { + story: + 'With annotationStyle="inline" labels sit 4 px above the actual trace Y value with no arrow. Useful for dense chromatograms where arrowheads clutter the signal.', + }, + }, + zephyr: { testCaseId: "SW-T1119" }, + }, +}; diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index e1b4f944..7320166f 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -80,6 +80,7 @@ const ChromatogramChart: React.FC = ({ onPeakClick, onPeakHover, selectionAppearance, + annotationStyle = "arrow", }) => { const enablePeakDetection = peakDetectionOptions !== undefined; const plotRef = useRef(null); @@ -201,6 +202,7 @@ const ChromatogramChart: React.FC = ({ selectedPeakIds: selectedPeakIds ?? [], anySelected, appearance: resolvedAppearance, + annotationStyle, }; const allPeaksWithMeta: PeakWithMeta[] = []; @@ -237,6 +239,7 @@ const ChromatogramChart: React.FC = ({ annotationOverlapThreshold, selectedPeakIds, resolvedAppearance, + annotationStyle, ]); // Keep the ref in sync every render so that closures in effects always read diff --git a/src/components/charts/ChromatogramChart/annotations.ts b/src/components/charts/ChromatogramChart/annotations.ts index c9e92ce8..9ef578bd 100644 --- a/src/components/charts/ChromatogramChart/annotations.ts +++ b/src/components/charts/ChromatogramChart/annotations.ts @@ -107,6 +107,7 @@ interface PeakAnnotationOptions { selectedPeakIds?: string[]; anySelected?: boolean; appearance?: ResolvedSelectionAppearance; + annotationStyle?: "arrow" | "inline"; } interface AnnotationBorderStyle { @@ -137,6 +138,34 @@ function resolveAnnotationBorderStyle( return { bgcolor, bordercolor, borderwidth, ...(opacity === undefined ? {} : { opacity }) }; } +/** Builds an inline-style (no-arrow) annotation that floats above the trace point. */ +function createInlineAnnotation( + peak: PeakAnnotation, + text: string, + fontSize: number, + textColor: string, + isSelected: boolean, + isDimmed: boolean, + appearance: ResolvedSelectionAppearance +): Partial { + const opacity = isDimmed ? appearance.unselected.opacity : undefined; + return { + x: peak.x, + y: peak.y, + text, + showarrow: false, + yshift: CHROMATOGRAM_ANNOTATION.INLINE_YSHIFT, + yanchor: "bottom" as const, + xanchor: "center" as const, + font: { + size: fontSize, + color: isSelected ? appearance.selected.borderColor : textColor, + family: "Inter, sans-serif", + }, + ...(opacity === undefined ? {} : { opacity }), + }; +} + /** * Create a Plotly annotation for a peak. * seriesIndex of -1 indicates a user-defined annotation (uses grey/black styling). @@ -151,6 +180,7 @@ export function createPeakAnnotation( selectedPeakIds = [], anySelected = false, appearance = DEFAULT_RESOLVED_APPEARANCE, + annotationStyle = "arrow", } = options; const isUserDefined = seriesIndex === -1; @@ -166,6 +196,14 @@ export function createPeakAnnotation( const text = isSelected && appearance.selected.bold ? `${rawText}` : rawText; + const fontSize = isUserDefined + ? CHROMATOGRAM_ANNOTATION.USER_ANNOTATION_FONT_SIZE + : CHROMATOGRAM_ANNOTATION.AUTO_ANNOTATION_FONT_SIZE; + + if (annotationStyle === "inline") { + return createInlineAnnotation(peak, text, fontSize, textColor, isSelected, isDimmed, appearance); + } + // For user-defined annotations, respect their ax/ay if provided const ax = isUserDefined && peak.ax !== undefined ? peak.ax : slot.ax; const ay = isUserDefined && peak.ay !== undefined ? peak.ay : slot.ay; @@ -186,9 +224,7 @@ export function createPeakAnnotation( ax, ay, font: { - size: isUserDefined - ? CHROMATOGRAM_ANNOTATION.USER_ANNOTATION_FONT_SIZE - : CHROMATOGRAM_ANNOTATION.AUTO_ANNOTATION_FONT_SIZE, + size: fontSize, color: textColor, family: "Inter, sans-serif", }, diff --git a/src/components/charts/ChromatogramChart/constants.ts b/src/components/charts/ChromatogramChart/constants.ts index 6e60ed72..d8708fd6 100644 --- a/src/components/charts/ChromatogramChart/constants.ts +++ b/src/components/charts/ChromatogramChart/constants.ts @@ -30,6 +30,8 @@ export const CHROMATOGRAM_ANNOTATION = { USER_ANNOTATION_FONT_SIZE: 11, /** Font size for auto-detected peak annotations */ AUTO_ANNOTATION_FONT_SIZE: 10, + /** Pixel offset above the data point for inline-style annotations (no arrow) */ + INLINE_YSHIFT: 4, } as const; /** diff --git a/src/components/charts/ChromatogramChart/types.ts b/src/components/charts/ChromatogramChart/types.ts index d53ccc8c..35956f38 100644 --- a/src/components/charts/ChromatogramChart/types.ts +++ b/src/components/charts/ChromatogramChart/types.ts @@ -289,6 +289,13 @@ export interface ChromatogramChartProps { * Only has a visual effect when selectedPeakIds or onPeakClick/onPeakHover are used. */ selectionAppearance?: PeakSelectionAppearance; + + /** + * Annotation label style (default: "arrow"). + * - "arrow" — arrowhead pointing to the peak with a floating label box + * - "inline" — no arrow; label sits 4 px above the actual trace Y value + */ + annotationStyle?: "arrow" | "inline"; } /** From ee6f756bd37e962595358a671d49aa029baf63ff Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Tue, 12 May 2026 09:38:36 -0500 Subject: [PATCH 08/30] feat(ChromatogramChart): per-peak color override Add \`color?: string\` to PeakAnnotation. When set it overrides the series-derived color for the annotation label, arrow, border, and boundary markers. Series-color remains the default when omitted, so existing consumers are unaffected. Motivated by the SST data app's pass/fail (green/red/grey) peak coloring, which today is implemented as a custom Plotly overlay. Co-Authored-By: Claude Sonnet 4.6 --- .../ChromatogramChart.stories.tsx | 178 ++++++++++++++++++ .../charts/ChromatogramChart/annotations.ts | 14 +- .../ChromatogramChart/boundaryMarkers.ts | 4 +- .../charts/ChromatogramChart/types.ts | 25 +++ 4 files changed, 214 insertions(+), 7 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index b7c23fa9..0adfe1b1 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -712,6 +712,184 @@ export const PeakHoverAndSelection: StoryObj = { }, }; +/** + * Per-peak color overrides: red (fail), green (pass), grey (excluded). + * Matches the SST runner's pass/fail coloring where each peak carries a `color` + * derived from its `passed` field. Arrows, borders, and boundary markers all + * inherit the per-peak color. + */ +export const PerPeakColorOverride: Story = { + args: { + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Per-Peak Color Override", + annotations: [ + { + id: "peak-pass", + x: 5.8, + y: 420, + text: "Caffeine (pass)", + color: "#22c55e", + startX: 5.0, + endX: 6.6, + }, + { + id: "peak-fail", + x: 12.5, + y: 180, + text: "Theobromine (fail)", + color: "#ef4444", + startX: 11.5, + endX: 13.5, + }, + { + id: "peak-excluded", + x: 18.3, + y: 350, + text: "Theophylline (N/A)", + color: "#6b7280", + startX: 17.5, + endX: 19.2, + }, + ], + boundaryMarkers: "enabled", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect(canvas.getByText("Per-Peak Color Override")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Colored annotation labels are rendered", async () => { + expect(canvas.getByText("Caffeine (pass)")).toBeInTheDocument(); + expect(canvas.getByText("Theobromine (fail)")).toBeInTheDocument(); + expect(canvas.getByText("Theophylline (N/A)")).toBeInTheDocument(); + }); + + await step("Boundary marker traces are rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBeGreaterThan(1); + }); + }, + parameters: { + docs: { + description: { + story: + "Each peak carries a `color` override (green = pass, red = fail, grey = N/A). The annotation label, arrow, border, and boundary markers all use the per-peak color. Existing peaks without a color override are unaffected.", + }, + }, + }, +}; + +/** + * Region overlay: two peaks have a thickened colored segment painted along the + * underlying trace between their startX/endX boundaries, using per-peak colors + * (green = pass, red = fail). + */ +export const WithRegionOverlay: Story = { + args: { + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Peak Region Overlays", + annotations: [ + { + id: "peak-pass", + x: 5.8, + y: 420, + text: "Caffeine (pass)", + color: "#22c55e", + startX: 5.0, + endX: 6.6, + regionOverlay: true, + regionOverlayWidth: 4, + hoverText: "Caffeine
RT: 5.80 min
Area: 1842.3
USP Tailing: 1.04
S/N: 42.1
Status: PASS", + }, + { + id: "peak-fail", + x: 12.5, + y: 180, + text: "Theobromine (fail)", + color: "#ef4444", + startX: 11.5, + endX: 13.5, + regionOverlay: true, + hoverText: "Theobromine
RT: 12.50 min
Area: 631.7
USP Tailing: 1.48
S/N: 18.3
Status: FAIL", + }, + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect(canvas.getByText("Peak Region Overlays")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Annotation labels are rendered", async () => { + expect(canvas.getByText("Caffeine (pass)")).toBeInTheDocument(); + expect(canvas.getByText("Theobromine (fail)")).toBeInTheDocument(); + }); + + await step("Region overlay traces are present (series + overlays)", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + // 1 series trace + 2 region overlay traces + 1 hit-area trace = at least 4 + expect(traces.length).toBeGreaterThanOrEqual(4); + }); + }, + parameters: { + docs: { + description: { + story: + "Each peak with `regionOverlay: true` paints a thickened colored line segment along the underlying trace between its `startX` and `endX`. Uses `peak.color` when set; falls back to the series color.", + }, + }, + }, +}; + +/** + * Compact title: 13 px font with a tighter top margin. + * Matches the per-channel panel style used by the SST runner where many charts + * are stacked vertically and a full 20 px title wastes space. + */ +export const CompactTitle: Story = { + args: { + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Channel 214 nm", + titleFontSize: 13, + titleTopMargin: 28, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Compact title is displayed", async () => { + expect(canvas.getByText("Channel 214 nm")).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Trace is rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(1); + }); + }, + parameters: { + docs: { + description: { + story: + "Use `titleFontSize` and `titleTopMargin` to shrink the title area for compact multi-panel layouts. The 13 px / 28 px combination matches the SST runner's per-channel panel style. Default stories are unaffected.", + }, + }, + }, +}; + /** * Inline annotation style: labels float directly above the trace at the peak Y value * with no arrow. Cleaner for dense chromatograms where arrows create visual noise. diff --git a/src/components/charts/ChromatogramChart/annotations.ts b/src/components/charts/ChromatogramChart/annotations.ts index 9ef578bd..c617cd60 100644 --- a/src/components/charts/ChromatogramChart/annotations.ts +++ b/src/components/charts/ChromatogramChart/annotations.ts @@ -124,16 +124,17 @@ function resolveAnnotationBorderStyle( isDimmed: boolean, isUserDefined: boolean, seriesColor: string, - appearance: ResolvedSelectionAppearance + appearance: ResolvedSelectionAppearance, + hasColorOverride: boolean ): AnnotationBorderStyle { const bgcolor = isSelected ? appearance.selected.backgroundColor : COLORS.WHITE; let bordercolor: string | undefined; if (isSelected) { bordercolor = appearance.selected.borderColor; } else { - bordercolor = isUserDefined ? undefined : seriesColor; + bordercolor = isUserDefined && !hasColorOverride ? undefined : seriesColor; } - const borderwidth = isSelected ? 2 : isUserDefined ? 0 : 1; + const borderwidth = isSelected ? 2 : isUserDefined && !hasColorOverride ? 0 : 1; const opacity = isDimmed ? appearance.unselected.opacity : undefined; return { bgcolor, bordercolor, borderwidth, ...(opacity === undefined ? {} : { opacity }) }; } @@ -184,10 +185,11 @@ export function createPeakAnnotation( } = options; const isUserDefined = seriesIndex === -1; - const color = isUserDefined + const defaultColor = isUserDefined ? COLORS.GREY_500 : CHART_COLORS[seriesIndex % CHART_COLORS.length]; - const textColor = isUserDefined ? COLORS.BLACK_900 : color; + const color = peak.color ?? defaultColor; + const textColor = isUserDefined && !peak.color ? COLORS.BLACK_900 : color; const rawText = peak.text ?? (peak._computed?.area === undefined ? "" : `Area: ${peak._computed.area.toFixed(2)}`); @@ -209,7 +211,7 @@ export function createPeakAnnotation( const ay = isUserDefined && peak.ay !== undefined ? peak.ay : slot.ay; const borderStyle = resolveAnnotationBorderStyle( - isSelected, isDimmed, isUserDefined, color, appearance + isSelected, isDimmed, isUserDefined, color, appearance, peak.color !== undefined ); return { diff --git a/src/components/charts/ChromatogramChart/boundaryMarkers.ts b/src/components/charts/ChromatogramChart/boundaryMarkers.ts index af0fdcd2..de337b7e 100644 --- a/src/components/charts/ChromatogramChart/boundaryMarkers.ts +++ b/src/components/charts/ChromatogramChart/boundaryMarkers.ts @@ -62,7 +62,7 @@ export function createBoundaryMarkerTraces( const traces: Plotly.Data[] = []; for (const { peaks, seriesIndex, x } of allPeaks) { - const color = CHART_COLORS[seriesIndex % CHART_COLORS.length]; + const seriesColor = CHART_COLORS[seriesIndex % CHART_COLORS.length]; // Separate y positions for start vs end markers to prevent overlap when peaks are adjacent // Also stagger by series index to prevent overlap between different traces const startMarkerY = BOUNDARY_MARKER_START_Y + seriesIndex * BOUNDARY_MARKER_SERIES_OFFSET; @@ -78,6 +78,8 @@ export function createBoundaryMarkerTraces( const startMarkerType = peak.startMarker ?? "triangle"; const endMarkerType = peak.endMarker ?? "diamond"; + const color = peak.color ?? seriesColor; + // Create start boundary marker (upper row, staggered by series) traces.push(...createMarkerTrace(startX, startMarkerY, startMarkerType, color)); diff --git a/src/components/charts/ChromatogramChart/types.ts b/src/components/charts/ChromatogramChart/types.ts index 35956f38..ba8ba184 100644 --- a/src/components/charts/ChromatogramChart/types.ts +++ b/src/components/charts/ChromatogramChart/types.ts @@ -71,6 +71,23 @@ export interface PeakAnnotation { startMarker?: BoundaryMarkerType; /** Marker style for end boundary (default: "diamond") */ endMarker?: BoundaryMarkerType; + /** + * Optional per-peak color override. When set, overrides the default + * series-color / grey for the annotation label, arrow, border, and boundary markers. + */ + color?: string; + /** + * When true, overlay a thickened line along the underlying trace between startX and endX. + * Requires startX/endX. Uses peak.color if set, otherwise series color. + */ + regionOverlay?: boolean; + /** Line width for the region overlay (default: 3.5) */ + regionOverlayWidth?: number; + /** + * Plotly hovertemplate HTML string used by the region overlay and the invisible + * hit-area marker for this peak. Falls back to a default summary when omitted. + */ + hoverText?: string; /** * Internal computed fields populated by the component. * @internal Do not set these directly - they are computed from startX/endX or auto-detection. @@ -296,6 +313,14 @@ export interface ChromatogramChartProps { * - "inline" — no arrow; label sits 4 px above the actual trace Y value */ annotationStyle?: "arrow" | "inline"; + + /** Title font size in pixels (default: 20) */ + titleFontSize?: number; + /** + * Top margin override when a title is shown (default: from + * CHROMATOGRAM_LAYOUT.MARGIN_TOP_WITH_TITLE). + */ + titleTopMargin?: number; } /** From a1d1e398888ccf008911e18c13e753daf3fc14c0 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Tue, 12 May 2026 09:38:47 -0500 Subject: [PATCH 09/30] feat(ChromatogramChart): peak region overlay trace Add PeakAnnotation.regionOverlay (with optional regionOverlayWidth) to paint a thickened colored segment along the underlying trace between the peak's startX and endX. Honors peak.color from the per-peak color override; falls back to the series color. This is the first-class equivalent of the ad-hoc Plotly overlay used by the SST runner's ChromatogramPanel, which marks integrated regions green/red/grey by pass/fail. Co-Authored-By: Claude Sonnet 4.6 --- .../ChromatogramChart/ChromatogramChart.tsx | 33 +++++++++++-- .../ChromatogramChart/regionOverlays.ts | 47 +++++++++++++++++++ 2 files changed, 76 insertions(+), 4 deletions(-) create mode 100644 src/components/charts/ChromatogramChart/regionOverlays.ts diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index 7320166f..289a09c2 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -19,6 +19,7 @@ import { } from "./dataProcessing"; import { detectPeaks } from "./peakDetection"; import { buildRangeAnnotationElements } from "./rangeAnnotations"; +import { createRegionOverlayTraces } from "./regionOverlays"; import type { ChromatogramSeries, @@ -81,6 +82,8 @@ const ChromatogramChart: React.FC = ({ onPeakHover, selectionAppearance, annotationStyle = "arrow", + titleFontSize = 20, + titleTopMargin, }) => { const enablePeakDetection = peakDetectionOptions !== undefined; const plotRef = useRef(null); @@ -286,17 +289,30 @@ const ChromatogramChart: React.FC = ({ } } + // Region overlay traces — thickened colored segments along the signal between + // peak boundaries. Pushed before the hit-area trace so peak interactions still work. + processedAnnotations.forEach((ann) => { + if (ann.regionOverlay && processedSeries[0]) { + plotData.push(...createRegionOverlayTraces([ann], 0, processedSeries[0])); + } + }); + allDetectedPeaks.forEach(({ peaks, seriesIndex }) => { + if (peaks.some((p) => p.regionOverlay) && processedSeries[seriesIndex]) { + plotData.push(...createRegionOverlayTraces(peaks, seriesIndex, processedSeries[seriesIndex])); + } + }); + // Invisible hit-area markers for click / hover on peaks. // hovertemplate "" suppresses the tooltip entry while still // allowing plotly_click and plotly_hover to fire for these points. if (allPeaksForInteraction.length > 0) { + const anyHoverText = allPeaksForInteraction.some((p) => p.peak.hoverText); const hitAreaTrace: Plotly.Data = { x: allPeaksForInteraction.map((p) => p.peak.x), y: allPeaksForInteraction.map((p) => p.peak.y), type: "scatter" as const, mode: "markers" as const, marker: { size: 14, opacity: 0 }, - hovertemplate: "", showlegend: false, name: "", customdata: allPeaksForInteraction.map((p) => ({ @@ -306,6 +322,13 @@ const ChromatogramChart: React.FC = ({ seriesName: p.seriesName, isAutoDetected: p.isAutoDetected, })) as unknown as Plotly.Datum[], + ...(anyHoverText + ? { + hovertemplate: allPeaksForInteraction.map((p) => + p.peak.hoverText ? `${p.peak.hoverText}` : "" + ), + } + : { hovertemplate: "" }), }; plotData.push(hitAreaTrace); } @@ -327,7 +350,7 @@ const ChromatogramChart: React.FC = ({ title: title ? { text: title, - font: { size: 20, family: "Inter, sans-serif", color: theme.textColor }, + font: { size: titleFontSize, family: "Inter, sans-serif", color: theme.textColor }, } : undefined, width, @@ -336,7 +359,9 @@ const ChromatogramChart: React.FC = ({ l: CHROMATOGRAM_LAYOUT.MARGIN_LEFT, r: CHROMATOGRAM_LAYOUT.MARGIN_RIGHT, b: CHROMATOGRAM_LAYOUT.MARGIN_BOTTOM, - t: title ? CHROMATOGRAM_LAYOUT.MARGIN_TOP_WITH_TITLE : CHROMATOGRAM_LAYOUT.MARGIN_TOP_NO_TITLE, + t: title + ? (titleTopMargin ?? CHROMATOGRAM_LAYOUT.MARGIN_TOP_WITH_TITLE) + : CHROMATOGRAM_LAYOUT.MARGIN_TOP_NO_TITLE, pad: CHROMATOGRAM_LAYOUT.MARGIN_PAD, }, paper_bgcolor: theme.paperBg, @@ -550,7 +575,7 @@ const ChromatogramChart: React.FC = ({ }; }, [ processedSeries, allDetectedPeaks, allPeaksForInteraction, series.length, - width, height, title, xAxisTitle, yAxisTitle, + width, height, title, titleFontSize, titleTopMargin, xAxisTitle, yAxisTitle, processedAnnotations, xRange, yRange, showLegend, showGridX, showGridY, showMarkers, markerSize, showCrosshairs, enablePeakDetection, peakDetectionOptions, showPeakAreas, boundaryMarkers, annotationOverlapThreshold, showExportButton, diff --git a/src/components/charts/ChromatogramChart/regionOverlays.ts b/src/components/charts/ChromatogramChart/regionOverlays.ts new file mode 100644 index 00000000..f19a25d1 --- /dev/null +++ b/src/components/charts/ChromatogramChart/regionOverlays.ts @@ -0,0 +1,47 @@ +import { CHART_COLORS } from "../../../utils/colors"; + +import type { PeakAnnotation, ChromatogramSeries } from "./types"; +import type Plotly from "plotly.js-dist"; + +const DEFAULT_REGION_OVERLAY_WIDTH = 3.5; + +/** + * Build one scatter trace per peak that has regionOverlay:true, slicing the + * underlying series data between the peak's startIndex and endIndex. + * Traces are pushed BEFORE the invisible hit-area trace so peak click/hover + * still resolves to the hit-area marker, not the overlay. + */ +export function createRegionOverlayTraces( + peaks: PeakAnnotation[], + seriesIndex: number, + series: ChromatogramSeries +): Plotly.Data[] { + const seriesColor = series.color ?? CHART_COLORS[seriesIndex % CHART_COLORS.length]; + const traces: Plotly.Data[] = []; + + for (const peak of peaks) { + if (!peak.regionOverlay) continue; + const startIdx = peak._computed?.startIndex; + const endIdx = peak._computed?.endIndex; + if (startIdx === undefined || endIdx === undefined) continue; + + const color = peak.color ?? seriesColor; + const lineWidth = peak.regionOverlayWidth ?? DEFAULT_REGION_OVERLAY_WIDTH; + const hoverProps = peak.hoverText + ? { hovertemplate: `${peak.hoverText}` } + : { hoverinfo: "skip" as const }; + + traces.push({ + x: series.x.slice(startIdx, endIdx + 1), + y: series.y.slice(startIdx, endIdx + 1), + type: "scatter" as const, + mode: "lines" as const, + line: { color, width: lineWidth }, + showlegend: false, + name: "", + ...hoverProps, + }); + } + + return traces; +} From a320da9e1e422b2698b91f37954bc109230b930f Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Tue, 12 May 2026 09:39:35 -0500 Subject: [PATCH 10/30] feat(StackedChromatogramChart): stackingOrder prop Add stackingOrder ("first-on-bottom" | "first-on-top", default "first-on-bottom") to control which end of the stack series[0] lands on. Annotations and numeric-yAnchor range annotations follow the chosen direction so they stay pinned to their trace. The default preserves existing behavior. The new "first-on-top" direction matches the convention used by the SST Injection-Viewer panel and lets consumers keep annotations in source order. Co-Authored-By: Claude Sonnet 4.6 --- .../StackedChromatogramChart.stories.tsx | 52 +++++++++++++++++++ .../StackedChromatogramChart.tsx | 6 ++- .../StackedChromatogramChart/transforms.ts | 15 ++++-- .../charts/StackedChromatogramChart/types.ts | 8 +++ 4 files changed, 75 insertions(+), 6 deletions(-) diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx index c957e4e9..6c31ef4d 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -382,6 +382,58 @@ export const StackWithRangeAnnotations: Story = { }, }; +/** + * stackingOrder="first-on-top" places series[0] at the top of the stack. + * This matches the SST Injection-Viewer convention where rank 0 (the earliest + * injection) appears at the top so time flows downward. + * Annotations follow the same direction and stay pinned to their trace. + */ +export const StackingOrderFirstOnTop: Story = { + args: { + series: stackSeriesData, + title: "Stacked — Earliest Injection on Top", + stackingMode: "stack", + stackOffset: 500, + stackingOrder: "first-on-top", + annotations: [ + [{ x: 5.8, y: 420, text: "Day 1", ay: -35 }], + [{ x: 5.9, y: 390, text: "Day 2", ay: -35 }], + [{ x: 5.75, y: 450, text: "Day 3", ay: -35 }], + ], + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Stacked — Earliest Injection on Top") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Three traces are rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(3); + }); + + await step("Annotation labels are rendered", async () => { + const annotationEls = canvasElement.querySelectorAll(".annotation-text"); + expect(annotationEls.length).toBeGreaterThanOrEqual(3); + }); + }, + parameters: { + docs: { + description: { + story: + 'With `stackingOrder="first-on-top"`, series[0] (Day 1) is shifted to the highest position and series[N-1] sits at the base. Annotations follow the same ordering. Compare with the default `StackMode` story where series[0] is at the bottom.', + }, + }, + }, +}; + /** * Drag the "Stack Offset" slider in the Controls panel to adjust the vertical * separation between traces in real time. stackingMode is locked to 'stack'. diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx index 75dc2d89..87925285 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx @@ -10,6 +10,7 @@ export function StackedChromatogramChart({ series, stackingMode = "overlay", stackOffset = 0, + stackingOrder = "first-on-bottom", annotations, rangeAnnotations, ...restProps @@ -26,9 +27,10 @@ export function StackedChromatogramChart({ annotations, rangeAnnotations, stackingMode, - stackOffset + stackOffset, + stackingOrder ), - [series, annotations, rangeAnnotations, stackingMode, stackOffset] + [series, annotations, rangeAnnotations, stackingMode, stackOffset, stackingOrder] ); return ( diff --git a/src/components/charts/StackedChromatogramChart/transforms.ts b/src/components/charts/StackedChromatogramChart/transforms.ts index 719ebb69..3d05b6c4 100644 --- a/src/components/charts/StackedChromatogramChart/transforms.ts +++ b/src/components/charts/StackedChromatogramChart/transforms.ts @@ -17,7 +17,8 @@ export function applyStackingTransform( inputAnnotations: PeakAnnotation[][] | undefined, inputRangeAnnotations: RangeAnnotation[][] | undefined, mode: StackingMode, - stackOffset: number + stackOffset: number, + stackingOrder: "first-on-bottom" | "first-on-top" = "first-on-bottom" ): TransformResult { const yValues = inputSeries.flatMap((s) => s.y); const yMin = Math.min(...yValues, 0); @@ -34,17 +35,23 @@ export function applyStackingTransform( } // 'stack' mode: shift each series up by its index × stackOffset + const N = inputSeries.length; + const yShiftForIndex = (index: number): number => + stackingOrder === "first-on-top" + ? (N - 1 - index) * stackOffset + : index * stackOffset; + const offsetSeries: ChromatogramSeries[] = inputSeries.map( (series, index) => ({ ...series, - y: series.y.map((yVal) => yVal + index * stackOffset), + y: series.y.map((yVal) => yVal + yShiftForIndex(index)), }) ); const offsetAnnotations: PeakAnnotation[] = []; if (inputAnnotations) { inputAnnotations.forEach((seriesAnnotations, seriesIndex) => { - const yShift = seriesIndex * stackOffset; + const yShift = yShiftForIndex(seriesIndex); seriesAnnotations.forEach((ann) => { offsetAnnotations.push({ ...ann, y: ann.y + yShift }); }); @@ -56,7 +63,7 @@ export function applyStackingTransform( const offsetRangeAnnotations: RangeAnnotation[] = []; if (inputRangeAnnotations) { inputRangeAnnotations.forEach((seriesRangeAnnotations, seriesIndex) => { - const yShift = seriesIndex * stackOffset; + const yShift = yShiftForIndex(seriesIndex); seriesRangeAnnotations.forEach((ann) => { offsetRangeAnnotations.push({ ...ann, diff --git a/src/components/charts/StackedChromatogramChart/types.ts b/src/components/charts/StackedChromatogramChart/types.ts index 26daf4ce..f189feb7 100644 --- a/src/components/charts/StackedChromatogramChart/types.ts +++ b/src/components/charts/StackedChromatogramChart/types.ts @@ -39,4 +39,12 @@ export interface StackedChromatogramChartProps * (they derive their position from paper-space or the already-shifted data). */ rangeAnnotations?: RangeAnnotation[][]; + + /** + * Controls which end of the stack series[0] lands on (stack mode only). + * - "first-on-bottom" (default) — series[0] sits at the base; series[N-1] is highest. + * - "first-on-top" — series[0] is shifted to the top; series[N-1] is at the base. + * Annotations and numeric-yAnchor range annotations follow the chosen direction. + */ + stackingOrder?: "first-on-bottom" | "first-on-top"; } From 2747534915b8cbd4b81069a0c936810b16d70e5c Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 12:45:12 -0500 Subject: [PATCH 11/30] Tigthen storybook stories --- .../ChromatogramChart.stories.tsx | 535 +----------------- .../StackedChromatogramChart.stories.tsx | 231 -------- 2 files changed, 31 insertions(+), 735 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index 0adfe1b1..c62a36af 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -6,7 +6,6 @@ import { type ChromatogramSeries, type PeakAnnotation, type PeakSelectEvent, - type RangeAnnotation, } from "./ChromatogramChart"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -73,51 +72,38 @@ const multiInjectionData: ChromatogramSeries[] = [ }, ]; -// Peak annotations for compound identification (simple labels without boundaries) -const sampleAnnotations: PeakAnnotation[] = [ - { x: 5.8, y: 420, text: "Caffeine", ax: 0, ay: -40 }, - { x: 12.5, y: 180, text: "Theobromine", ax: 30, ay: -55 }, - { x: 18.3, y: 350, text: "Theophylline", ax: -30, ay: -80 }, -]; - -// User-defined peaks with boundary information for boundary markers -// Users simply provide startX and endX (retention times) - the component handles the rest -const userDefinedPeaksWithBoundaries: PeakAnnotation[] = [ +// User-defined peaks with boundary information. +// Provide startX and endX (retention times) — the component computes area and boundary markers. +const userDefinedPeaks: PeakAnnotation[] = [ { x: 5.8, y: 420, - text: "Caffeine", + text: "Caffeine (pass)", + color: "#22c55e", ay: -40, - startX: 5.0, // Start retention time - endX: 6.6, // End retention time - // area is auto-computed from boundaries + startX: 5.0, + endX: 6.6, }, { x: 12.5, y: 180, - text: "Theobromine", + text: "Theobromine (fail)", + color: "#ef4444", ay: -55, - startX: 11.5, // Start retention time - endX: 13.5, // End retention time + startX: 11.5, + endX: 13.5, }, { x: 18.3, y: 350, - text: "Theophylline", + text: "Theophylline (N/A)", + color: "#6b7280", ay: -80, - startX: 17.3, // Start retention time - endX: 19.3, // End retention time + startX: 17.3, + endX: 19.3, }, ]; -// Range annotations marking chromatographic fractions across the x-axis -const sampleRangeAnnotations: RangeAnnotation[] = [ - { label: "Void", startX: 0, endX: 2.5, color: "#8E8E93" }, - { label: "Caffeine", startX: 4.5, endX: 7.2, color: "#007AFF" }, - { label: "Theobromine", startX: 11.0, endX: 14.0, color: "#34C759" }, - { label: "Theophylline", startX: 16.8, endX: 19.8, color: "#FF9500" }, -]; - // Annotations with stable IDs for selection stories const selectableAnnotations: PeakAnnotation[] = [ { id: "caffeine", x: 5.8, y: 420, text: "Caffeine" }, @@ -216,83 +202,6 @@ export const MultipleTraces: Story = { }, }; -/** - * Series with injection metadata displayed in tooltips. - */ -export const WithMetadata: Story = { - args: { - series: [ - { - ...generateChromatogramData([ - { rt: 5.8, height: 420, width: 0.4 }, - { rt: 12.5, height: 180, width: 0.5 }, - { rt: 18.3, height: 350, width: 0.45 }, - ]), - name: "Sample A - UV 254nm", - metadata: { - sampleName: "Caffeine Standard", - injectionId: "INJ-2024-001", - detectorType: "UV", - wavelength: 254, - methodName: "Caffeine_HPLC_v2", - instrumentName: "Agilent 1260", - wellPosition: "A1", - injectionVolume: 10, - }, - }, - { - ...generateChromatogramData([ - { rt: 5.9, height: 380, width: 0.42 }, - { rt: 12.6, height: 195, width: 0.48 }, - { rt: 18.4, height: 320, width: 0.47 }, - ], 0.8), - name: "Sample B - UV 254nm", - metadata: { - sampleName: "Coffee Extract", - injectionId: "INJ-2024-002", - detectorType: "UV", - wavelength: 254, - methodName: "Caffeine_HPLC_v2", - wellPosition: "A2", - }, - }, - ], - title: "Chromatogram with Injection Metadata", - showCrosshairs: true, - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - const title = canvas.getByText("Chromatogram with Injection Metadata"); - expect(title).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - const container = canvasElement.querySelector(".js-plotly-plot"); - expect(container).toBeInTheDocument(); - }); - - await step("Both traces are rendered", async () => { - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBe(2); - }); - - await step("Legend shows series names", async () => { - expect(canvas.getByText("Sample A - UV 254nm")).toBeInTheDocument(); - expect(canvas.getByText("Sample B - UV 254nm")).toBeInTheDocument(); - }); - }, - parameters: { - docs: { - description: { - story: "Hover over traces to see injection metadata in the tooltip. Metadata includes sample name, injection ID, detector type, wavelength, method name, and well position.", - }, - }, - zephyr: { testCaseId: "SW-T1110" }, - }, -}; - /** * Automatic peak detection with area calculations using trapezoidal integration. */ @@ -337,124 +246,16 @@ export const PeakDetection: Story = { }; /** - * Full featured chromatogram combining all major features. - */ -export const FullFeatured: Story = { - args: { - series: multiInjectionData, - annotations: sampleAnnotations, - title: "Full Featured Chromatogram", - showGridX: true, - showGridY: true, - showCrosshairs: true, - baselineCorrection: "linear", - peakDetectionOptions: { - minHeight: 0.15, - prominence: 0.1, - }, - showPeakAreas: true, - boundaryMarkers: "enabled", - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - const title = canvas.getByText("Full Featured Chromatogram"); - expect(title).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - const container = canvasElement.querySelector(".js-plotly-plot"); - expect(container).toBeInTheDocument(); - }); - - await step("Multiple traces are rendered", async () => { - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBeGreaterThanOrEqual(3); - }); - - await step("User annotations are displayed", async () => { - expect(canvas.getByText("Caffeine")).toBeInTheDocument(); - expect(canvas.getByText("Theobromine")).toBeInTheDocument(); - expect(canvas.getByText("Theophylline")).toBeInTheDocument(); - }); - - await step("Peak area annotations are displayed", async () => { - const annotations = canvasElement.querySelectorAll(".annotation-text"); - // User annotations are displayed (auto-detected peaks at same positions are filtered out) - expect(annotations.length).toBeGreaterThanOrEqual(3); - }); - }, - parameters: { - docs: { - description: { - story: "Combines all major features: multiple traces, grid lines, crosshairs, manual annotations, baseline correction, and automatic peak detection.", - }, - }, - zephyr: { testCaseId: "SW-T1112" }, - }, -}; - -/** - * Peak boundary markers showing triangle markers at peak start and diamond markers - * with vertical lines at peak end (the default styling). - */ -export const WithBoundaryMarkers: Story = { - args: { - series: [{ ...singleInjectionData, name: "Sample A" }], - title: "Peak Boundary Markers", - peakDetectionOptions: { - minHeight: 0.1, - prominence: 0.05, - minDistance: 20, - }, - showPeakAreas: true, - boundaryMarkers: "enabled", - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - const title = canvas.getByText("Peak Boundary Markers"); - expect(title).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - const container = canvasElement.querySelector(".js-plotly-plot"); - expect(container).toBeInTheDocument(); - }); - - await step("Boundary marker traces are rendered", async () => { - // Boundary markers add additional scatter traces for the markers - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBeGreaterThan(1); // Main trace + boundary marker traces - }); - - await step("Peak area annotations are displayed", async () => { - const annotations = canvasElement.querySelectorAll(".annotation-text"); - expect(annotations.length).toBeGreaterThan(0); - }); - }, - parameters: { - docs: { - description: { - story: "Peak boundary markers visually indicate peak start and end points. Use 'auto' to automatically choose triangle markers (▲) for isolated boundaries at baseline or diamond markers (◆) with vertical lines for overlapping peaks. Set to 'triangle' or 'diamond' to force a specific marker style.", - }, - }, - zephyr: { testCaseId: "SW-T1113" }, - }, -}; - -/** - * User-defined peaks with boundary information. Users can provide their own peak - * annotations with startIndex and endIndex to display boundary markers without - * using automatic peak detection. + * User-provided peaks with startX / endX boundaries and per-peak pass/fail colors. + * Boundary markers (triangle at start, diamond at end) and annotation label/arrow/border + * all inherit the peak color. Area is auto-computed via trapezoidal integration over the + * bounded slice — no auto-detection needed. */ -export const UserDefinedPeakBoundaries: Story = { +export const UserDefinedPeaks: Story = { args: { series: [{ ...singleInjectionData, name: "Sample A" }], title: "User-Defined Peak Boundaries", - annotations: userDefinedPeaksWithBoundaries, + annotations: userDefinedPeaks, boundaryMarkers: "enabled", }, play: async ({ canvasElement, step }) => { @@ -470,190 +271,25 @@ export const UserDefinedPeakBoundaries: Story = { expect(container).toBeInTheDocument(); }); - await step("User-defined peak annotations are displayed", async () => { - expect(canvas.getByText("Caffeine")).toBeInTheDocument(); - expect(canvas.getByText("Theobromine")).toBeInTheDocument(); - expect(canvas.getByText("Theophylline")).toBeInTheDocument(); - }); - - await step("Boundary marker traces are rendered for user-defined peaks", async () => { - // With boundary markers enabled, additional traces should be rendered - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBeGreaterThan(1); - }); - }, - parameters: { - docs: { - description: { - story: "Users can provide their own peak annotations with boundary information (startIndex, endIndex) to display boundary markers. This is useful when peak boundaries are known from external analysis or when manual peak integration is required. The annotations array accepts PeakAnnotation objects with optional index, startIndex, endIndex, and area fields.", - }, - }, - zephyr: { testCaseId: "SW-T1114" }, - }, -}; - -/** - * Combining automatic peak detection with user-defined annotations. Auto-detected peaks - * show computed areas while user annotations provide custom labels. Both can have - * boundary markers displayed. - */ -export const CombinedAutoAndUserPeaks: Story = { - args: { - series: [{ ...singleInjectionData, name: "Sample A" }], - title: "Combined Auto-Detected and User-Defined Peaks", - annotations: [ - // User-defined annotation with boundaries (will show boundary markers) - // Just provide startX and endX - area is auto-computed - { - x: 5.8, - y: 420, - text: "Caffeine (user-defined)", - ay: -40, - startX: 5.0, - endX: 6.6, - }, - // Simple user annotation without boundaries (just a label) - { x: 24.1, y: 220, text: "Unknown Peak", ay: -60 }, - ], - peakDetectionOptions: { - minHeight: 0.1, - prominence: 0.05, - }, - showPeakAreas: true, - boundaryMarkers: "enabled", - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - const title = canvas.getByText("Combined Auto-Detected and User-Defined Peaks"); - expect(title).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - const container = canvasElement.querySelector(".js-plotly-plot"); - expect(container).toBeInTheDocument(); - }); - - await step("User-defined annotations are displayed", async () => { - expect(canvas.getByText("Caffeine (user-defined)")).toBeInTheDocument(); - expect(canvas.getByText("Unknown Peak")).toBeInTheDocument(); - }); - - await step("Auto-detected peak area annotations are displayed", async () => { - // Peak areas from auto-detection + user annotations - const annotations = canvasElement.querySelectorAll(".annotation-text"); - expect(annotations.length).toBeGreaterThan(2); + await step("Colored peak annotations are displayed", async () => { + expect(canvas.getByText("Caffeine (pass)")).toBeInTheDocument(); + expect(canvas.getByText("Theobromine (fail)")).toBeInTheDocument(); + expect(canvas.getByText("Theophylline (N/A)")).toBeInTheDocument(); }); - await step("Boundary markers from both sources are rendered", async () => { - // Main trace + boundary marker traces from both auto-detected and user-defined peaks + await step("Boundary marker traces are rendered", async () => { const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); expect(traces.length).toBeGreaterThan(1); }); }, - parameters: { - docs: { - description: { - story: "This example shows automatic peak detection combined with user-provided annotations. The auto-detected peaks display computed areas, while user annotations can provide custom labels. User annotations with boundary info (startIndex, endIndex) will also display boundary markers alongside auto-detected peaks.", - }, - }, - zephyr: { testCaseId: "SW-T1115" }, - }, -}; - -/** - * Horizontal colored bars mark chromatographic fractions (Void, Caffeine, Theobromine, - * Theophylline) above the signal trace. Labels stay centred within their bar. Zoom or - * pan the chart to verify that labels reposition to the visible portion of each bar and - * disappear when a bar scrolls fully out of view. - */ -export const WithRangeAnnotations: Story = { - args: { - series: [{ ...singleInjectionData, name: "Sample A" }], - title: "Chromatogram with Fraction Windows", - rangeAnnotations: sampleRangeAnnotations, - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect(canvas.getByText("Chromatogram with Fraction Windows")).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - }); - - await step("Trace is rendered", async () => { - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBe(1); - }); - - await step("Range annotation labels are rendered", async () => { - expect(canvas.getByText("Caffeine")).toBeInTheDocument(); - expect(canvas.getByText("Theobromine")).toBeInTheDocument(); - expect(canvas.getByText("Theophylline")).toBeInTheDocument(); - expect(canvas.getByText("Void")).toBeInTheDocument(); - }); - - await step("Range annotation shapes are rendered", async () => { - const shapes = canvasElement.querySelectorAll(".shapelayer path"); - expect(shapes.length).toBeGreaterThanOrEqual(4); - }); - }, parameters: { docs: { description: { story: - "Fraction windows rendered as coloured bars above the chromatogram trace. Labels reposition to stay centred within the visible portion of each bar as the user zooms or pans.", + "Supply `startX`, `endX`, and `color` on each annotation to define peak boundaries with pass/fail coloring (green = pass, red = fail, grey = N/A). The component renders triangle markers (▲) at the start and diamond markers (◆) at the end; the label, arrow, border, and boundary markers all inherit the per-peak color. Area is auto-computed via trapezoidal integration over the bounded slice.", }, }, - zephyr: { testCaseId: "SW-T1116" }, - }, -}; - -/** - * Range annotations combined with auto peak detection. The fraction bars sit above the - * axis area while the peak labels (with areas) annotate the signal directly. - */ -export const WithRangeAnnotationsAndPeakDetection: Story = { - args: { - series: [{ ...singleInjectionData, name: "Sample A" }], - title: "Fraction Windows + Peak Detection", - rangeAnnotations: sampleRangeAnnotations, - peakDetectionOptions: { minHeight: 0.1, prominence: 0.05, minDistance: 20 }, - showPeakAreas: true, - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect(canvas.getByText("Fraction Windows + Peak Detection")).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - }); - - await step("Range annotation labels are rendered", async () => { - expect(canvas.getByText("Theophylline")).toBeInTheDocument(); - expect(canvas.getByText("Void")).toBeInTheDocument(); - }); - - await step("Peak area annotations are displayed", async () => { - const annotations = canvasElement.querySelectorAll(".annotation-text"); - expect(annotations.length).toBeGreaterThan(4); - }); - }, - parameters: { - docs: { - description: { - story: - "Fraction windows coexist with auto-detected peak labels. The bars reserve space at the top of the plot; peak area annotations appear directly on the signal.", - }, - }, - zephyr: { testCaseId: "SW-T1117" }, + zephyr: { testCaseId: "SW-T1114" }, }, }; @@ -712,79 +348,6 @@ export const PeakHoverAndSelection: StoryObj = { }, }; -/** - * Per-peak color overrides: red (fail), green (pass), grey (excluded). - * Matches the SST runner's pass/fail coloring where each peak carries a `color` - * derived from its `passed` field. Arrows, borders, and boundary markers all - * inherit the per-peak color. - */ -export const PerPeakColorOverride: Story = { - args: { - series: [{ ...singleInjectionData, name: "Sample A" }], - title: "Per-Peak Color Override", - annotations: [ - { - id: "peak-pass", - x: 5.8, - y: 420, - text: "Caffeine (pass)", - color: "#22c55e", - startX: 5.0, - endX: 6.6, - }, - { - id: "peak-fail", - x: 12.5, - y: 180, - text: "Theobromine (fail)", - color: "#ef4444", - startX: 11.5, - endX: 13.5, - }, - { - id: "peak-excluded", - x: 18.3, - y: 350, - text: "Theophylline (N/A)", - color: "#6b7280", - startX: 17.5, - endX: 19.2, - }, - ], - boundaryMarkers: "enabled", - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect(canvas.getByText("Per-Peak Color Override")).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - }); - - await step("Colored annotation labels are rendered", async () => { - expect(canvas.getByText("Caffeine (pass)")).toBeInTheDocument(); - expect(canvas.getByText("Theobromine (fail)")).toBeInTheDocument(); - expect(canvas.getByText("Theophylline (N/A)")).toBeInTheDocument(); - }); - - await step("Boundary marker traces are rendered", async () => { - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBeGreaterThan(1); - }); - }, - parameters: { - docs: { - description: { - story: - "Each peak carries a `color` override (green = pass, red = fail, grey = N/A). The annotation label, arrow, border, and boundary markers all use the per-peak color. Existing peaks without a color override are unaffected.", - }, - }, - }, -}; - /** * Region overlay: two peaks have a thickened colored segment painted along the * underlying trace between their startX/endX boundaries, using per-peak colors @@ -852,47 +415,11 @@ export const WithRegionOverlay: Story = { }, }; -/** - * Compact title: 13 px font with a tighter top margin. - * Matches the per-channel panel style used by the SST runner where many charts - * are stacked vertically and a full 20 px title wastes space. - */ -export const CompactTitle: Story = { - args: { - series: [{ ...singleInjectionData, name: "Sample A" }], - title: "Channel 214 nm", - titleFontSize: 13, - titleTopMargin: 28, - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Compact title is displayed", async () => { - expect(canvas.getByText("Channel 214 nm")).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - }); - - await step("Trace is rendered", async () => { - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBe(1); - }); - }, - parameters: { - docs: { - description: { - story: - "Use `titleFontSize` and `titleTopMargin` to shrink the title area for compact multi-panel layouts. The 13 px / 28 px combination matches the SST runner's per-channel panel style. Default stories are unaffected.", - }, - }, - }, -}; - /** * Inline annotation style: labels float directly above the trace at the peak Y value * with no arrow. Cleaner for dense chromatograms where arrows create visual noise. + * Use `titleFontSize` and `titleTopMargin` to shrink the title area for compact + * multi-panel layouts (e.g. `titleFontSize={13}` matches the SST runner panel style). */ export const InlineAnnotationStyle: Story = { args: { @@ -922,7 +449,7 @@ export const InlineAnnotationStyle: Story = { docs: { description: { story: - 'With annotationStyle="inline" labels sit 4 px above the actual trace Y value with no arrow. Useful for dense chromatograms where arrowheads clutter the signal.', + 'With `annotationStyle="inline"` labels sit 4 px above the actual trace Y value with no arrow. Useful for dense chromatograms where arrowheads clutter the signal.', }, }, zephyr: { testCaseId: "SW-T1119" }, diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx index 6c31ef4d..4f1efb0a 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -254,186 +254,6 @@ export const StackModeWithAnnotations: Story = { }, }; -/** - * Overlay with automatic peak detection enabled. Since both modes share the same - * underlying ChromatogramChart, all peak-detection and boundary-marker features work - * transparently in both modes. - */ -export const OverlayWithPeakDetection: Story = { - args: { - series: [ - { ...injection1, name: "Injection 1" }, - { ...injection2, name: "Injection 2" }, - ], - title: "Overlay + Auto Peak Detection", - stackingMode: "overlay", - peakDetectionOptions: { - minHeight: 0.1, - prominence: 0.05, - minDistance: 20, - }, - showPeakAreas: true, - boundaryMarkers: "enabled", - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect( - canvas.getByText("Overlay + Auto Peak Detection") - ).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - }); - - await step("Two data traces are rendered", async () => { - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - // Main traces + boundary marker traces - expect(traces.length).toBeGreaterThanOrEqual(2); - }); - - await step("Peak area annotations are displayed", async () => { - const annotations = canvasElement.querySelectorAll(".annotation-text"); - expect(annotations.length).toBeGreaterThan(0); - }); - }, - parameters: { - docs: { - description: { - story: - "Overlay mode with automatic peak detection, area display, and boundary markers enabled. All ChromatogramChart features (peak detection, boundary markers, range annotations, baseline correction) are available in both stacking modes.", - }, - }, - zephyr: { testCaseId: "SW-T1123" }, - }, -}; - -// Per-series fraction windows for the charge-variant stack stories. -// Each series has three adjacent fractions; yAnchor is set just above the -// local peak maximum so bars stay pinned to their trace when the offset changes. -const fractionAnnotationsPerSeries = [ - // Day 1 — unshifted; peaks at ~135 (Acidic), ~450 (Main), ~200 (Basic) - [ - { label: "Acidic", startX: 4.8, endX: 5.45, color: "#8E8E93", yAnchor: 145, barHeight: 30 }, - { label: "Main", startX: 5.45, endX: 6.25, color: "#007AFF", yAnchor: 460, barHeight: 30 }, - { label: "Basic", startX: 6.25, endX: 7.0, color: "#34C759", yAnchor: 210, barHeight: 30 }, - ], - // Day 2 — same yAnchor values; in stack mode these are shifted up by stackOffset - [ - { label: "Acidic", startX: 4.8, endX: 5.45, color: "#8E8E93", yAnchor: 145, barHeight: 30 }, - { label: "Main", startX: 5.45, endX: 6.25, color: "#007AFF", yAnchor: 460, barHeight: 30 }, - { label: "Basic", startX: 6.25, endX: 7.0, color: "#34C759", yAnchor: 210, barHeight: 30 }, - ], - // Day 3 - [ - { label: "Acidic", startX: 4.8, endX: 5.45, color: "#8E8E93", yAnchor: 145, barHeight: 30 }, - { label: "Main", startX: 5.45, endX: 6.25, color: "#007AFF", yAnchor: 460, barHeight: 30 }, - { label: "Basic", startX: 6.25, endX: 7.0, color: "#34C759", yAnchor: 210, barHeight: 30 }, - ], -]; - -/** - * Three stacked runs each labelled with Acidic / Main / Basic fraction windows. - * The bars are positioned with numeric yAnchor so they are pinned just above their - * respective trace — they shift upward by stackOffset × seriesIndex automatically. - */ -export const StackWithRangeAnnotations: Story = { - args: { - series: stackSeriesData, - title: "Charge Variant Fractions — Stacked", - stackingMode: "stack", - stackOffset: 500, - rangeAnnotations: fractionAnnotationsPerSeries, - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect( - canvas.getByText("Charge Variant Fractions — Stacked") - ).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - }); - - await step("Three data traces are rendered", async () => { - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBe(3); - }); - - await step("Range annotation labels are rendered (3 fractions × 3 series)", async () => { - const labels = canvasElement.querySelectorAll(".annotation-text"); - // 3 fractions × 3 series = 9 range annotation labels - expect(labels.length).toBeGreaterThanOrEqual(9); - }); - }, - parameters: { - docs: { - description: { - story: - "Each series carries its own set of fraction windows (Acidic / Main / Basic) defined with numeric `yAnchor` values relative to the unshifted data. In stack mode the component adds `seriesIndex × stackOffset` to each numeric `yAnchor`, so the bars stay anchored just above their own trace regardless of the offset.", - }, - }, - zephyr: { testCaseId: "SW-T1125" }, - }, -}; - -/** - * stackingOrder="first-on-top" places series[0] at the top of the stack. - * This matches the SST Injection-Viewer convention where rank 0 (the earliest - * injection) appears at the top so time flows downward. - * Annotations follow the same direction and stay pinned to their trace. - */ -export const StackingOrderFirstOnTop: Story = { - args: { - series: stackSeriesData, - title: "Stacked — Earliest Injection on Top", - stackingMode: "stack", - stackOffset: 500, - stackingOrder: "first-on-top", - annotations: [ - [{ x: 5.8, y: 420, text: "Day 1", ay: -35 }], - [{ x: 5.9, y: 390, text: "Day 2", ay: -35 }], - [{ x: 5.75, y: 450, text: "Day 3", ay: -35 }], - ], - }, - play: async ({ canvasElement, step }) => { - const canvas = within(canvasElement); - - await step("Chart title is displayed", async () => { - expect( - canvas.getByText("Stacked — Earliest Injection on Top") - ).toBeInTheDocument(); - }); - - await step("Chart container renders", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - }); - - await step("Three traces are rendered", async () => { - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBe(3); - }); - - await step("Annotation labels are rendered", async () => { - const annotationEls = canvasElement.querySelectorAll(".annotation-text"); - expect(annotationEls.length).toBeGreaterThanOrEqual(3); - }); - }, - parameters: { - docs: { - description: { - story: - 'With `stackingOrder="first-on-top"`, series[0] (Day 1) is shifted to the highest position and series[N-1] sits at the base. Annotations follow the same ordering. Compare with the default `StackMode` story where series[0] is at the bottom.', - }, - }, - }, -}; - /** * Drag the "Stack Offset" slider in the Controls panel to adjust the vertical * separation between traces in real time. stackingMode is locked to 'stack'. @@ -462,54 +282,3 @@ export const InteractiveOffset: Story = { zephyr: { testCaseId: "SW-T1124" }, }, }; - -// Fraction windows for the interactive story. -// yAnchor = peak_height - barHeight so each bar's top sits at the peak apex — -// bars are always inside the stacked y-axis range and move with their trace. -const fractionAnnotationsInteractive = [ - [ - { label: "Acidic", startX: 4.8, endX: 5.45, color: "#8E8E93", yAnchor: 90, barHeight: 25 }, - { label: "Main", startX: 5.45, endX: 6.25, color: "#007AFF", yAnchor: 390, barHeight: 25 }, - { label: "Basic", startX: 6.25, endX: 7.0, color: "#34C759", yAnchor: 150, barHeight: 25 }, - ], - [ - { label: "Acidic", startX: 4.8, endX: 5.2, color: "#FF9500", yAnchor: 65, barHeight: 25 }, - { label: "Main", startX: 5.5, endX: 6.3, color: "#007AFF", yAnchor: 362, barHeight: 25 }, - { label: "Basic", startX: 6.3, endX: 7.0, color: "#34C759", yAnchor: 130, barHeight: 25 }, - ], - [ - { label: "Acidic", startX: 4.7, endX: 5.4, color: "#8E8E93", yAnchor: 108, barHeight: 25 }, - { label: "Main", startX: 5.4, endX: 6.15, color: "#007AFF", yAnchor: 420, barHeight: 25 }, - { label: "Basic", startX: 6.15, endX: 6.8, color: "#34C759", yAnchor: 170, barHeight: 25 }, - ], -]; - -/** - * Combine the offset slider with per-series fraction windows positioned just above - * each trace. Dragging the slider moves both traces and their bars together. - */ -export const InteractiveOffsetWithRangeAnnotations: Story = { - argTypes: { - stackOffset: { - control: { type: "range", min: 0, max: 700, step: 10 }, - description: "Vertical separation between stacked traces (data units)", - }, - }, - args: { - series: stackSeriesData, - title: "Stacked Fractions — Interactive Offset", - stackingMode: "stack", - stackOffset: 500, - rangeAnnotations: fractionAnnotationsInteractive, - showCrosshairs: true, - }, - parameters: { - docs: { - description: { - story: - "Drag the **Stack Offset** slider to spread or collapse the traces. Each run's fraction bars use a numeric `yAnchor` set just above the local peak, so bars move with their trace. Day 2 splits the acidic region into Acidic-01 / Acidic-02; Day 3 uses slightly shifted boundaries.", - }, - }, - zephyr: { testCaseId: "SW-T1126" }, - }, -}; From 872af91da20cee267c3233a8625128fc5387df93 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 13:24:05 -0500 Subject: [PATCH 12/30] Fix tests --- .../StackedChromatogramChart.stories.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx index 4f1efb0a..3835c1a0 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -232,9 +232,9 @@ export const StackModeWithAnnotations: Story = { expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); }); - await step("Three traces are rendered", async () => { + await step("Four traces are rendered", async () => { const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBe(3); + expect(traces.length).toBe(4); }); await step("Annotations are rendered", async () => { From d40c928ee019905b49bd2098eb2be62111ac6cb4 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 13:36:16 -0500 Subject: [PATCH 13/30] Increase test coverage of new chromatogram features --- .../__tests__/annotations.test.ts | 239 ++++++++++++++++++ .../__tests__/boundaryMarkers.test.ts | 143 +++++++++++ .../__tests__/rangeAnnotations.test.ts | 161 ++++++++++++ .../__tests__/regionOverlays.test.ts | 129 ++++++++++ .../__tests__/transforms.test.ts | 163 ++++++++++++ 5 files changed, 835 insertions(+) create mode 100644 src/components/charts/ChromatogramChart/__tests__/annotations.test.ts create mode 100644 src/components/charts/ChromatogramChart/__tests__/boundaryMarkers.test.ts create mode 100644 src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts create mode 100644 src/components/charts/ChromatogramChart/__tests__/regionOverlays.test.ts create mode 100644 src/components/charts/StackedChromatogramChart/__tests__/transforms.test.ts diff --git a/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts b/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts new file mode 100644 index 00000000..1d4e308c --- /dev/null +++ b/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect } from "vitest"; + +import { + resolveSelectionAppearance, + groupOverlappingPeaks, + createPeakAnnotation, + createGroupAnnotations, + ANNOTATION_SLOTS, +} from "../annotations"; + +import type { PeakWithMeta } from "../types"; + +describe("resolveSelectionAppearance", () => { + it("returns defaults when called with no argument", () => { + const result = resolveSelectionAppearance(); + expect(result.selected.borderColor).toBe("#3b82f6"); + expect(result.selected.backgroundColor).toBe("#dbeafe"); + expect(result.selected.bold).toBe(true); + expect(result.unselected.opacity).toBe(0.4); + expect(result.hoverLineWidthMultiplier).toBeCloseTo(5 / 3); + }); + + it("returns defaults when called with undefined", () => { + const result = resolveSelectionAppearance(undefined); + expect(result.selected.borderColor).toBe("#3b82f6"); + }); + + it("merges partial overrides, keeping defaults for omitted fields", () => { + const result = resolveSelectionAppearance({ + selected: { borderColor: "red" }, + }); + expect(result.selected.borderColor).toBe("red"); + expect(result.selected.backgroundColor).toBe("#dbeafe"); + expect(result.selected.bold).toBe(true); + expect(result.unselected.opacity).toBe(0.4); + }); + + it("respects full override", () => { + const result = resolveSelectionAppearance({ + selected: { borderColor: "red", backgroundColor: "blue", bold: false }, + unselected: { opacity: 0.2 }, + hoverLineWidthMultiplier: 2, + }); + expect(result.selected.borderColor).toBe("red"); + expect(result.selected.backgroundColor).toBe("blue"); + expect(result.selected.bold).toBe(false); + expect(result.unselected.opacity).toBe(0.2); + expect(result.hoverLineWidthMultiplier).toBe(2); + }); +}); + +describe("groupOverlappingPeaks", () => { + const makePeak = (x: number, seriesIndex = 0): PeakWithMeta => ({ + peak: { x, y: 10 }, + seriesIndex, + }); + + it("returns empty array for empty input", () => { + expect(groupOverlappingPeaks([], 0.5)).toEqual([]); + }); + + it("returns single group for a single peak", () => { + const result = groupOverlappingPeaks([makePeak(1)], 0.5); + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(1); + }); + + it("groups peaks within the threshold", () => { + const peaks = [makePeak(1.0), makePeak(1.2), makePeak(1.3)]; + const result = groupOverlappingPeaks(peaks, 0.5); + expect(result).toHaveLength(1); + expect(result[0]).toHaveLength(3); + }); + + it("separates peaks outside the threshold", () => { + const peaks = [makePeak(1.0), makePeak(5.0)]; + const result = groupOverlappingPeaks(peaks, 0.5); + expect(result).toHaveLength(2); + }); + + it("sorts by x before grouping", () => { + const peaks = [makePeak(5.0), makePeak(1.0), makePeak(1.2)]; + const result = groupOverlappingPeaks(peaks, 0.5); + expect(result).toHaveLength(2); + expect(result[0][0].peak.x).toBe(1.0); + }); + + it("creates separate group when diff equals threshold (not strictly less)", () => { + const peaks = [makePeak(1.0), makePeak(1.5)]; + // diff = 0.5, threshold = 0.5 → NOT less than threshold → new group + const result = groupOverlappingPeaks(peaks, 0.5); + expect(result).toHaveLength(2); + }); + + it("handles peaks exactly below threshold as same group", () => { + const peaks = [makePeak(1.0), makePeak(1.49)]; + const result = groupOverlappingPeaks(peaks, 0.5); + expect(result).toHaveLength(1); + }); +}); + +describe("createPeakAnnotation", () => { + const slot = { ax: 0, ay: -35 }; + const basicPeak = { x: 5, y: 100, text: "Peak A" }; + + it("creates a basic arrow annotation", () => { + const ann = createPeakAnnotation(basicPeak, 0, slot); + expect(ann.x).toBe(5); + expect(ann.y).toBe(100); + expect(ann.text).toBe("Peak A"); + expect(ann.showarrow).toBe(true); + expect(ann.ax).toBe(0); + expect(ann.ay).toBe(-35); + }); + + it("creates inline annotation when annotationStyle is inline", () => { + const ann = createPeakAnnotation(basicPeak, 0, slot, { annotationStyle: "inline" }); + expect(ann.showarrow).toBe(false); + expect(ann.yshift).toBe(4); + expect(ann.yanchor).toBe("bottom"); + expect(ann.xanchor).toBe("center"); + }); + + it("uses grey color for user-defined annotations (seriesIndex = -1)", () => { + const ann = createPeakAnnotation(basicPeak, -1, slot); + // User-defined: border should not be set (isUserDefined, no color override) + expect(ann.borderwidth).toBe(0); + }); + + it("renders selected peak with bold text and selection border", () => { + const peak = { x: 5, y: 100, text: "Peak A", id: "peak-0-0" }; + const ann = createPeakAnnotation(peak, 0, slot, { + selectedPeakIds: ["peak-0-0"], + anySelected: true, + }); + expect(ann.text).toBe("Peak A"); + expect(ann.borderwidth).toBe(2); + expect(ann.bordercolor).toBe("#3b82f6"); + }); + + it("dims unselected peak when another is selected", () => { + const peak = { x: 5, y: 100, text: "Peak B", id: "peak-0-1" }; + const ann = createPeakAnnotation(peak, 0, slot, { + selectedPeakIds: ["peak-0-0"], + anySelected: true, + }); + expect(ann.opacity).toBe(0.4); + }); + + it("does not set opacity when nothing is selected", () => { + const ann = createPeakAnnotation(basicPeak, 0, slot, { anySelected: false }); + expect(ann.opacity).toBeUndefined(); + }); + + it("uses peak color override when provided", () => { + const peak = { x: 5, y: 100, text: "Peak A", color: "#ff0000" }; + const ann = createPeakAnnotation(peak, 0, slot); + expect(ann.arrowcolor).toBe("#ff0000"); + expect(ann.borderwidth).toBe(1); + }); + + it("auto-generates text from computed area when text is absent", () => { + const peak = { x: 5, y: 100, _computed: { area: 42.1 } }; + const ann = createPeakAnnotation(peak, 0, slot); + expect(ann.text).toBe("Area: 42.10"); + }); + + it("uses empty text when neither text nor computed area is set", () => { + const peak = { x: 5, y: 100 }; + const ann = createPeakAnnotation(peak, 0, slot); + expect(ann.text).toBe(""); + }); + + it("respects user-defined peak ax/ay override", () => { + const peak = { x: 5, y: 100, text: "Peak A", ax: 30, ay: -50 }; + const ann = createPeakAnnotation(peak, -1, { ax: 0, ay: -35 }); + expect(ann.ax).toBe(30); + expect(ann.ay).toBe(-50); + }); + + it("does not override ax/ay for non-user-defined peaks", () => { + const peak = { x: 5, y: 100, text: "Peak A", ax: 30, ay: -50 }; + const ann = createPeakAnnotation(peak, 0, { ax: 0, ay: -35 }); + expect(ann.ax).toBe(0); + expect(ann.ay).toBe(-35); + }); + + it("inline: dims unselected when another selected", () => { + const peak = { x: 5, y: 100, text: "B", id: "b" }; + const ann = createPeakAnnotation(peak, 0, slot, { + annotationStyle: "inline", + selectedPeakIds: ["a"], + anySelected: true, + }); + expect(ann.opacity).toBe(0.4); + }); + + it("inline: no opacity when nothing selected", () => { + const ann = createPeakAnnotation(basicPeak, 0, slot, { + annotationStyle: "inline", + anySelected: false, + }); + expect(ann.opacity).toBeUndefined(); + }); +}); + +describe("createGroupAnnotations", () => { + it("uses default slot for a single-peak group", () => { + const group: PeakWithMeta[] = [{ peak: { x: 5, y: 100, text: "A" }, seriesIndex: 0 }]; + const result = createGroupAnnotations(group); + expect(result).toHaveLength(1); + expect(result[0].ax).toBe(ANNOTATION_SLOTS.default.ax); + expect(result[0].ay).toBe(ANNOTATION_SLOTS.default.ay); + }); + + it("assigns overlap slots for multiple peaks, sorted by y ascending", () => { + const group: PeakWithMeta[] = [ + { peak: { x: 5, y: 200, text: "High" }, seriesIndex: 0 }, + { peak: { x: 5.1, y: 50, text: "Low" }, seriesIndex: 0 }, + ]; + const result = createGroupAnnotations(group); + expect(result).toHaveLength(2); + // First annotation (y=50, lowest) gets slot 0 + expect(result[0].ax).toBe(ANNOTATION_SLOTS.overlap[0].ax); + // Second annotation (y=200) gets slot 1 + expect(result[1].ax).toBe(ANNOTATION_SLOTS.overlap[1].ax); + }); + + it("wraps around overlap slots when more peaks than slots", () => { + const group: PeakWithMeta[] = Array.from({ length: 8 }, (_, i) => ({ + peak: { x: i * 0.05, y: i * 10, text: `P${i}` }, + seriesIndex: 0, + })); + const result = createGroupAnnotations(group); + expect(result).toHaveLength(8); + // slot index wraps: peak at slotIndex 6 → slot[6 % 6] = slot[0] + expect(result[6].ax).toBe(ANNOTATION_SLOTS.overlap[0].ax); + }); +}); diff --git a/src/components/charts/ChromatogramChart/__tests__/boundaryMarkers.test.ts b/src/components/charts/ChromatogramChart/__tests__/boundaryMarkers.test.ts new file mode 100644 index 00000000..73249f21 --- /dev/null +++ b/src/components/charts/ChromatogramChart/__tests__/boundaryMarkers.test.ts @@ -0,0 +1,143 @@ +import { describe, it, expect } from "vitest"; + +import { createBoundaryMarkerTraces } from "../boundaryMarkers"; + +describe("createBoundaryMarkerTraces", () => { + it("returns empty array when allPeaks is empty", () => { + expect(createBoundaryMarkerTraces([])).toEqual([]); + }); + + it("returns empty array when a series has no peaks", () => { + const result = createBoundaryMarkerTraces([ + { peaks: [], seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + expect(result).toEqual([]); + }); + + it("creates two marker traces per peak (start + end) with default types", () => { + const peaks = [ + { + x: 5, + y: 100, + _computed: { startIndex: 0, endIndex: 2 }, + }, + ]; + const result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + expect(result).toHaveLength(2); + }); + + it("start marker uses triangle symbol by default", () => { + const peaks = [{ x: 5, y: 100, _computed: { startIndex: 0, endIndex: 2 } }]; + const result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + const startTrace = result[0] as { marker: { symbol: string } }; + expect(startTrace.marker.symbol).toBe("triangle-up"); + }); + + it("end marker uses diamond symbol by default", () => { + const peaks = [{ x: 5, y: 100, _computed: { startIndex: 0, endIndex: 2 } }]; + const result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + const endTrace = result[1] as { marker: { symbol: string } }; + expect(endTrace.marker.symbol).toBe("diamond"); + }); + + it("skips 'none' markers", () => { + const peaks = [ + { + x: 5, + y: 100, + startMarker: "none" as const, + endMarker: "none" as const, + _computed: { startIndex: 0, endIndex: 2 }, + }, + ]; + const result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + expect(result).toHaveLength(0); + }); + + it("respects explicit startMarker and endMarker types", () => { + const peaks = [ + { + x: 5, + y: 100, + startMarker: "diamond" as const, + endMarker: "triangle" as const, + _computed: { startIndex: 0, endIndex: 2 }, + }, + ]; + const result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + const startTrace = result[0] as { marker: { symbol: string } }; + const endTrace = result[1] as { marker: { symbol: string } }; + expect(startTrace.marker.symbol).toBe("diamond"); + expect(endTrace.marker.symbol).toBe("triangle-up"); + }); + + it("uses peak color override when provided", () => { + const peaks = [ + { + x: 5, + y: 100, + color: "#abcdef", + _computed: { startIndex: 0, endIndex: 2 }, + }, + ]; + const result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + const trace = result[0] as { marker: { color: string } }; + expect(trace.marker.color).toBe("#abcdef"); + }); + + it("falls back to series color when no peak color override", () => { + const peaks = [{ x: 5, y: 100, _computed: { startIndex: 0, endIndex: 2 } }]; + const result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + const trace = result[0] as { marker: { color: string } }; + expect(typeof trace.marker.color).toBe("string"); + expect(trace.marker.color.length).toBeGreaterThan(0); + }); + + it("stacks y positions by series index", () => { + const peaks = [{ x: 5, y: 100, _computed: { startIndex: 0, endIndex: 2 } }]; + const series0Result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + const series1Result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 1, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + const y0 = (series0Result[0] as { y: number[] }).y[0]; + const y1 = (series1Result[0] as { y: number[] }).y[0]; + expect(y1).toBeLessThan(y0); + }); + + it("falls back to index 0 when _computed is undefined", () => { + const peaks = [{ x: 5, y: 100 }]; + const result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2, 3], y: [10, 20, 30] }, + ]); + // Should still produce 2 traces using x[0] for both + expect(result).toHaveLength(2); + const startTrace = result[0] as { x: number[] }; + expect(startTrace.x[0]).toBe(1); // x[0] + }); + + it("creates traces for multiple series", () => { + const peaks = [{ x: 5, y: 100, _computed: { startIndex: 0, endIndex: 1 } }]; + const result = createBoundaryMarkerTraces([ + { peaks, seriesIndex: 0, x: [1, 2], y: [10, 20] }, + { peaks, seriesIndex: 1, x: [3, 4], y: [30, 40] }, + ]); + // 2 traces per peak per series = 4 + expect(result).toHaveLength(4); + }); +}); diff --git a/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts b/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts new file mode 100644 index 00000000..15053e21 --- /dev/null +++ b/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts @@ -0,0 +1,161 @@ +import { describe, it, expect } from "vitest"; + +import { assignRangeLanes, buildRangeAnnotationElements } from "../rangeAnnotations"; +import { RANGE_ANNOTATION } from "../constants"; + +import type { RangeAnnotation } from "../types"; + +const makeAnn = (startX: number, endX: number, overrides: Partial = {}): RangeAnnotation => ({ + label: `${startX}-${endX}`, + startX, + endX, + ...overrides, +}); + +describe("assignRangeLanes", () => { + it("assigns lane 0 to a single annotation", () => { + const result = assignRangeLanes([makeAnn(0, 5)], 0); + expect(result).toEqual([0]); + }); + + it("assigns same lane to non-overlapping annotations", () => { + const anns = [makeAnn(0, 2), makeAnn(3, 5)]; + const result = assignRangeLanes(anns, 0); + expect(result[0]).toBe(0); + expect(result[1]).toBe(0); + }); + + it("assigns different lanes to overlapping annotations", () => { + const anns = [makeAnn(0, 5), makeAnn(3, 8)]; + const result = assignRangeLanes(anns, 0); + expect(result[0]).toBe(0); + expect(result[1]).toBe(1); + }); + + it("respects explicit lane values", () => { + const anns = [makeAnn(0, 5, { lane: 2 })]; + const result = assignRangeLanes(anns, 0); + expect(result[0]).toBe(2); + }); + + it("mixes explicit and auto-assigned lanes, avoiding collisions", () => { + // Explicit at lane 0, auto should go to lane 1 because overlap + const anns = [makeAnn(0, 10, { lane: 0 }), makeAnn(5, 15)]; + const result = assignRangeLanes(anns, 0); + expect(result[0]).toBe(0); + expect(result[1]).toBe(1); + }); + + it("uses overlapThreshold to widen the no-overlap zone", () => { + // Annotations don't touch but are within threshold + const anns = [makeAnn(0, 3), makeAnn(3, 6)]; + // With threshold=1, startX=3 is NOT >= endX(3)-1=2 ... wait, let me recalc: + // startX(3) >= end(3) - threshold(1) => 3 >= 2 → true → same lane + const result = assignRangeLanes(anns, 1); + // startX >= end - threshold means it fits: same lane + expect(result[0]).toBe(0); + expect(result[1]).toBe(0); + }); + + it("returns empty array for empty input", () => { + expect(assignRangeLanes([], 0)).toEqual([]); + }); + + it("auto-assigns multiple non-overlapping annotations to lane 0", () => { + const anns = [makeAnn(10, 12), makeAnn(0, 2), makeAnn(5, 7)]; + const result = assignRangeLanes(anns, 0); + // All fit in lane 0 (processed in startX order: 0-2, 5-7, 10-12) + expect(result.every((l) => l === 0)).toBe(true); + }); +}); + +describe("buildRangeAnnotationElements", () => { + const seriesData = [{ x: [0, 1, 2, 3, 4, 5], y: [0, 10, 20, 15, 5, 0] }]; + + it("returns empty shapes/annotations and yDomainMax=1 for empty input", () => { + const result = buildRangeAnnotationElements([], 0, seriesData); + expect(result.shapes).toHaveLength(0); + expect(result.annotations).toHaveLength(0); + expect(result.yDomainMax).toBe(1.0); + }); + + it("produces one shape and one annotation per range annotation", () => { + const anns = [makeAnn(1, 3, { yAnchor: "top" })]; + const result = buildRangeAnnotationElements(anns, 0, seriesData); + expect(result.shapes).toHaveLength(1); + expect(result.annotations).toHaveLength(1); + }); + + it("uses 'top' yAnchor by default and shrinks yDomainMax", () => { + const anns = [makeAnn(1, 3)]; + const result = buildRangeAnnotationElements(anns, 0, seriesData); + expect(result.yDomainMax).toBeLessThan(1.0); + expect(result.shapes[0].yref).toBe("paper"); + }); + + it("does not shrink yDomainMax for 'auto' annotations", () => { + const anns = [makeAnn(1, 3, { yAnchor: "auto" })]; + const result = buildRangeAnnotationElements(anns, 0, seriesData); + expect(result.yDomainMax).toBe(1.0); + expect(result.shapes[0].yref).toBe("paper"); + }); + + it("uses 'y' yref for numeric yAnchor", () => { + const anns = [makeAnn(1, 3, { yAnchor: 5 })]; + const result = buildRangeAnnotationElements(anns, 0, seriesData); + expect(result.shapes[0].yref).toBe("y"); + expect(result.yDomainMax).toBe(1.0); + }); + + it("stacks two overlapping 'top' annotations in different lanes", () => { + const anns = [makeAnn(0, 5), makeAnn(3, 8)]; + const result = buildRangeAnnotationElements(anns, 0, seriesData); + const [s0, s1] = result.shapes; + // Lane 0 sits at paper y=1; lane 1 is lower → y1 of shape[1] < y1 of shape[0] + expect((s1.y1 as number)).toBeLessThan((s0.y1 as number)); + }); + + it("respects custom color, opacity, fontSize, labelColor", () => { + const anns = [makeAnn(1, 3, { color: "#ff0000", opacity: 0.3, fontSize: 14, labelColor: "#0000ff" })]; + const result = buildRangeAnnotationElements(anns, 0, seriesData); + expect(result.shapes[0].fillcolor).toBe("#ff0000"); + expect(result.shapes[0].opacity).toBe(0.3); + const ann = result.annotations[0] as { font: { size: number; color: string } }; + expect(ann.font.size).toBe(14); + expect(ann.font.color).toBe("#0000ff"); + }); + + it("falls back to CHART_COLORS when no color specified", () => { + const anns = [makeAnn(1, 3)]; + const result = buildRangeAnnotationElements(anns, 0, seriesData); + expect(typeof result.shapes[0].fillcolor).toBe("string"); + }); + + it("uses default opacity from RANGE_ANNOTATION constant", () => { + const anns = [makeAnn(1, 3)]; + const result = buildRangeAnnotationElements(anns, 0, seriesData); + expect(result.shapes[0].opacity).toBe(RANGE_ANNOTATION.DEFAULT_OPACITY); + }); + + it("auto yAnchor: uses paper yref and stacks multiple lanes", () => { + const anns = [makeAnn(0, 5, { yAnchor: "auto" }), makeAnn(2, 7, { yAnchor: "auto" })]; + const result = buildRangeAnnotationElements(anns, 0, seriesData); + expect(result.shapes[0].yref).toBe("paper"); + expect(result.shapes[1].yref).toBe("paper"); + }); + + it("handles empty seriesData for auto globalMaxY", () => { + const anns = [makeAnn(1, 3, { yAnchor: "auto" })]; + const result = buildRangeAnnotationElements(anns, 0, []); + expect(result.shapes).toHaveLength(1); + // globalMaxY = 0 → uses fallback yDomainMax * 0.5 + expect(result.shapes[0].yref).toBe("paper"); + }); + + it("caps yDomainMax at 0.5 minimum for many top lanes", () => { + // 20 stacked top annotations will want to shrink yDomainMax below 0.5 + const anns = Array.from({ length: 20 }, (_, i) => makeAnn(i * 10, i * 10 + 5)); + const result = buildRangeAnnotationElements(anns, 0, seriesData); + expect(result.yDomainMax).toBeGreaterThanOrEqual(0.5); + }); +}); diff --git a/src/components/charts/ChromatogramChart/__tests__/regionOverlays.test.ts b/src/components/charts/ChromatogramChart/__tests__/regionOverlays.test.ts new file mode 100644 index 00000000..3240798d --- /dev/null +++ b/src/components/charts/ChromatogramChart/__tests__/regionOverlays.test.ts @@ -0,0 +1,129 @@ +import { describe, it, expect } from "vitest"; + +import { createRegionOverlayTraces } from "../regionOverlays"; + +import type { ChromatogramSeries, PeakAnnotation } from "../types"; + +const makeSeries = (overrides: Partial = {}): ChromatogramSeries => ({ + name: "S1", + x: [0, 1, 2, 3, 4, 5], + y: [0, 10, 20, 15, 5, 0], + ...overrides, +}); + +describe("createRegionOverlayTraces", () => { + it("returns empty array when no peaks have regionOverlay", () => { + const peaks: PeakAnnotation[] = [{ x: 2, y: 20, regionOverlay: false }]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + expect(result).toHaveLength(0); + }); + + it("skips peaks with regionOverlay but missing computed indices", () => { + const peaks: PeakAnnotation[] = [{ x: 2, y: 20, regionOverlay: true }]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + expect(result).toHaveLength(0); + }); + + it("skips peaks with only startIndex defined (endIndex missing)", () => { + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, _computed: { startIndex: 1 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + expect(result).toHaveLength(0); + }); + + it("creates one trace per peak with regionOverlay=true and valid indices", () => { + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, _computed: { startIndex: 1, endIndex: 3 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + expect(result).toHaveLength(1); + }); + + it("slices series data between startIndex and endIndex (inclusive)", () => { + const series = makeSeries(); + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, _computed: { startIndex: 1, endIndex: 3 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, series); + const trace = result[0] as { x: number[]; y: number[] }; + expect(trace.x).toEqual([1, 2, 3]); + expect(trace.y).toEqual([10, 20, 15]); + }); + + it("uses peak color when specified", () => { + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, color: "#ff0000", _computed: { startIndex: 1, endIndex: 3 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + const trace = result[0] as { line: { color: string } }; + expect(trace.line.color).toBe("#ff0000"); + }); + + it("falls back to series color when no peak color", () => { + const series = makeSeries({ color: "#00ff00" }); + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, _computed: { startIndex: 1, endIndex: 3 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, series); + const trace = result[0] as { line: { color: string } }; + expect(trace.line.color).toBe("#00ff00"); + }); + + it("falls back to CHART_COLORS when series has no color", () => { + const series = makeSeries({ color: undefined }); + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, _computed: { startIndex: 1, endIndex: 3 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, series); + const trace = result[0] as { line: { color: string } }; + expect(typeof trace.line.color).toBe("string"); + expect(trace.line.color.length).toBeGreaterThan(0); + }); + + it("uses default line width of 3.5 when regionOverlayWidth not set", () => { + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, _computed: { startIndex: 1, endIndex: 3 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + const trace = result[0] as { line: { width: number } }; + expect(trace.line.width).toBe(3.5); + }); + + it("respects custom regionOverlayWidth", () => { + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, regionOverlayWidth: 6, _computed: { startIndex: 1, endIndex: 3 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + const trace = result[0] as { line: { width: number } }; + expect(trace.line.width).toBe(6); + }); + + it("uses hovertemplate when hoverText is set", () => { + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, hoverText: "Peak info", _computed: { startIndex: 1, endIndex: 3 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + const trace = result[0] as { hovertemplate: string }; + expect(trace.hovertemplate).toBe("Peak info"); + }); + + it("sets hoverinfo skip when hoverText is absent", () => { + const peaks: PeakAnnotation[] = [ + { x: 2, y: 20, regionOverlay: true, _computed: { startIndex: 1, endIndex: 3 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + const trace = result[0] as { hoverinfo: string }; + expect(trace.hoverinfo).toBe("skip"); + }); + + it("handles multiple peaks, skipping non-overlay ones", () => { + const peaks: PeakAnnotation[] = [ + { x: 1, y: 10, regionOverlay: false, _computed: { startIndex: 0, endIndex: 1 } }, + { x: 2, y: 20, regionOverlay: true, _computed: { startIndex: 1, endIndex: 3 } }, + { x: 4, y: 5, regionOverlay: true, _computed: { startIndex: 3, endIndex: 5 } }, + ]; + const result = createRegionOverlayTraces(peaks, 0, makeSeries()); + expect(result).toHaveLength(2); + }); +}); diff --git a/src/components/charts/StackedChromatogramChart/__tests__/transforms.test.ts b/src/components/charts/StackedChromatogramChart/__tests__/transforms.test.ts new file mode 100644 index 00000000..02793507 --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/__tests__/transforms.test.ts @@ -0,0 +1,163 @@ +import { describe, it, expect } from "vitest"; + +import { applyStackingTransform } from "../transforms"; + +import type { ChromatogramSeries, PeakAnnotation, RangeAnnotation } from "../../ChromatogramChart"; + +const makeSeries = (y: number[], name = "S"): ChromatogramSeries => ({ + name, + x: y.map((_, i) => i), + y, +}); + +describe("applyStackingTransform - overlay mode", () => { + it("returns the input series unchanged", () => { + const series = [makeSeries([1, 2, 3]), makeSeries([4, 5, 6])]; + const result = applyStackingTransform(series, undefined, undefined, "overlay", 10); + expect(result.series).toBe(series); + }); + + it("flattens annotations from all series", () => { + const series = [makeSeries([1, 2]), makeSeries([3, 4])]; + const annotations: PeakAnnotation[][] = [ + [{ x: 0, y: 1 }], + [{ x: 1, y: 3 }], + ]; + const result = applyStackingTransform(series, annotations, undefined, "overlay", 10); + expect(result.annotations).toHaveLength(2); + }); + + it("returns empty annotations when undefined", () => { + const series = [makeSeries([1, 2])]; + const result = applyStackingTransform(series, undefined, undefined, "overlay", 10); + expect(result.annotations).toHaveLength(0); + }); + + it("flattens range annotations from all series", () => { + const series = [makeSeries([1]), makeSeries([2])]; + const rangeAnns: RangeAnnotation[][] = [ + [{ label: "A", startX: 0, endX: 1 }], + [{ label: "B", startX: 2, endX: 3 }], + ]; + const result = applyStackingTransform(series, undefined, rangeAnns, "overlay", 10); + expect(result.rangeAnnotations).toHaveLength(2); + }); + + it("computes yRange spanning all series values including 0 minimum", () => { + const series = [makeSeries([5, 10]), makeSeries([3, 8])]; + const result = applyStackingTransform(series, undefined, undefined, "overlay", 10); + expect(result.yRange[0]).toBe(0); // min(..., 0) + expect(result.yRange[1]).toBe(10); + }); + + it("preserves negative yMin below 0", () => { + const series = [makeSeries([-5, 10])]; + const result = applyStackingTransform(series, undefined, undefined, "overlay", 10); + expect(result.yRange[0]).toBe(-5); + }); +}); + +describe("applyStackingTransform - stack mode (first-on-bottom)", () => { + it("shifts each series y values by index * stackOffset", () => { + const series = [makeSeries([0, 5]), makeSeries([0, 5])]; + const result = applyStackingTransform(series, undefined, undefined, "stack", 10, "first-on-bottom"); + // series 0: no shift; series 1: +10 + expect(result.series[0].y).toEqual([0, 5]); + expect(result.series[1].y).toEqual([10, 15]); + }); + + it("preserves original series objects (does not mutate)", () => { + const originalY = [0, 5]; + const series = [makeSeries(originalY)]; + applyStackingTransform(series, undefined, undefined, "stack", 10); + expect(series[0].y).toEqual([0, 5]); + }); + + it("shifts peak annotation y values matching their series", () => { + const series = [makeSeries([0, 5]), makeSeries([0, 5])]; + const annotations: PeakAnnotation[][] = [ + [{ x: 1, y: 5 }], + [{ x: 1, y: 5 }], + ]; + const result = applyStackingTransform(series, annotations, undefined, "stack", 10); + expect(result.annotations[0].y).toBe(5); // series 0, no shift + expect(result.annotations[1].y).toBe(15); // series 1, +10 + }); + + it("shifts numeric yAnchor in range annotations", () => { + const series = [makeSeries([0, 5]), makeSeries([0, 5])]; + const rangeAnns: RangeAnnotation[][] = [ + [], + [{ label: "B", startX: 0, endX: 1, yAnchor: 2 }], + ]; + const result = applyStackingTransform(series, undefined, rangeAnns, "stack", 10); + // series 1 gets +10 shift; numeric yAnchor 2 → 12 + expect(result.rangeAnnotations[0].yAnchor).toBe(12); + }); + + it("does not shift string yAnchor values ('top', 'auto')", () => { + const series = [makeSeries([0, 5]), makeSeries([0, 5])]; + const rangeAnns: RangeAnnotation[][] = [ + [], + [{ label: "B", startX: 0, endX: 1, yAnchor: "top" }], + ]; + const result = applyStackingTransform(series, undefined, rangeAnns, "stack", 10); + expect(result.rangeAnnotations[0].yAnchor).toBe("top"); + }); + + it("does not shift 'auto' yAnchor", () => { + const series = [makeSeries([0, 5]), makeSeries([0, 5])]; + const rangeAnns: RangeAnnotation[][] = [ + [], + [{ label: "B", startX: 0, endX: 1, yAnchor: "auto" }], + ]; + const result = applyStackingTransform(series, undefined, rangeAnns, "stack", 10); + expect(result.rangeAnnotations[0].yAnchor).toBe("auto"); + }); + + it("handles undefined annotations gracefully", () => { + const series = [makeSeries([0, 5])]; + const result = applyStackingTransform(series, undefined, undefined, "stack", 10); + expect(result.annotations).toHaveLength(0); + expect(result.rangeAnnotations).toHaveLength(0); + }); + + it("computes stacked yRange including annotation y values", () => { + const series = [makeSeries([0, 5]), makeSeries([0, 5])]; + const anns: PeakAnnotation[][] = [[{ x: 0, y: 5 }], [{ x: 1, y: 5 }]]; + const result = applyStackingTransform(series, anns, undefined, "stack", 10); + // series[1] is shifted by 10 → max y = 15; annotations[1].y = 15 + expect(result.yRange[1]).toBe(15); + expect(result.yRange[0]).toBe(0); + }); +}); + +describe("applyStackingTransform - stack mode (first-on-top)", () => { + it("reverses stacking: last series gets no shift, first gets max shift", () => { + const series = [makeSeries([0, 5]), makeSeries([0, 5]), makeSeries([0, 5])]; + // N=3, first-on-top: index 0 → shift (3-1-0)*10=20, index 1 → 10, index 2 → 0 + const result = applyStackingTransform(series, undefined, undefined, "stack", 10, "first-on-top"); + expect(result.series[0].y).toEqual([20, 25]); + expect(result.series[1].y).toEqual([10, 15]); + expect(result.series[2].y).toEqual([0, 5]); + }); + + it("shifts annotations by first-on-top offset", () => { + const series = [makeSeries([0, 5]), makeSeries([0, 5])]; + const annotations: PeakAnnotation[][] = [ + [{ x: 0, y: 5 }], + [{ x: 1, y: 5 }], + ]; + // N=2: index 0 → (2-1-0)*10=10; index 1 → 0 + const result = applyStackingTransform(series, annotations, undefined, "stack", 10, "first-on-top"); + expect(result.annotations[0].y).toBe(15); + expect(result.annotations[1].y).toBe(5); + }); + + it("default stackingOrder is first-on-bottom", () => { + const series = [makeSeries([0, 5]), makeSeries([0, 5])]; + const resultDefault = applyStackingTransform(series, undefined, undefined, "stack", 10); + const resultExplicit = applyStackingTransform(series, undefined, undefined, "stack", 10, "first-on-bottom"); + expect(resultDefault.series[1].y).toEqual(resultExplicit.series[1].y); + }); +}); From d1d840c138a31a18221b4af7103f36d4ef8a8d60 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 14:07:42 -0500 Subject: [PATCH 14/30] Lint fixes --- .../charts/ChromatogramChart/__tests__/annotations.test.ts | 4 ++-- .../ChromatogramChart/__tests__/rangeAnnotations.test.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts b/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts index 1d4e308c..099b7623 100644 --- a/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts +++ b/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts @@ -20,8 +20,8 @@ describe("resolveSelectionAppearance", () => { expect(result.hoverLineWidthMultiplier).toBeCloseTo(5 / 3); }); - it("returns defaults when called with undefined", () => { - const result = resolveSelectionAppearance(undefined); + it("returns defaults when called with no overrides", () => { + const result = resolveSelectionAppearance(); expect(result.selected.borderColor).toBe("#3b82f6"); }); diff --git a/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts b/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts index 15053e21..43528119 100644 --- a/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts +++ b/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts @@ -1,7 +1,7 @@ import { describe, it, expect } from "vitest"; -import { assignRangeLanes, buildRangeAnnotationElements } from "../rangeAnnotations"; import { RANGE_ANNOTATION } from "../constants"; +import { assignRangeLanes, buildRangeAnnotationElements } from "../rangeAnnotations"; import type { RangeAnnotation } from "../types"; From cc050b061bb5d14acd1bbec352204b13d74f501b Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 15:27:52 -0500 Subject: [PATCH 15/30] Remove RangeAnnotation --- .../ChromatogramChart/ChromatogramChart.tsx | 109 +-------- .../__tests__/rangeAnnotations.test.ts | 161 -------------- .../charts/ChromatogramChart/constants.ts | 25 --- .../charts/ChromatogramChart/index.ts | 1 - .../ChromatogramChart/rangeAnnotations.ts | 210 ------------------ .../charts/ChromatogramChart/types.ts | 55 ----- .../StackedChromatogramChart.tsx | 6 +- .../__tests__/transforms.test.ts | 72 ++---- .../StackedChromatogramChart/transforms.ts | 28 +-- .../charts/StackedChromatogramChart/types.ts | 17 +- 10 files changed, 24 insertions(+), 660 deletions(-) delete mode 100644 src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts delete mode 100644 src/components/charts/ChromatogramChart/rangeAnnotations.ts diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index 289a09c2..6a502c59 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -18,7 +18,6 @@ import { processUserAnnotations, } from "./dataProcessing"; import { detectPeaks } from "./peakDetection"; -import { buildRangeAnnotationElements } from "./rangeAnnotations"; import { createRegionOverlayTraces } from "./regionOverlays"; import type { @@ -32,7 +31,6 @@ import type { PeakDetectionOptions, ChromatogramChartProps, PeakWithMeta, - RangeAnnotation, } from "./types"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; @@ -43,7 +41,6 @@ export type { PeakAnnotation, PeakSelectEvent, PeakSelectionAppearance, - RangeAnnotation, BaselineCorrectionMethod, BoundaryMarkerStyle, BoundaryMarkerType, @@ -75,8 +72,6 @@ const ChromatogramChart: React.FC = ({ boundaryMarkers = "none", annotationOverlapThreshold = 0.4, showExportButton = true, - rangeAnnotations = [], - rangeAnnotationOverlapThreshold = 0, selectedPeakIds, onPeakClick, onPeakHover, @@ -89,10 +84,6 @@ const ChromatogramChart: React.FC = ({ const plotRef = useRef(null); const theme = usePlotlyTheme(); - // Prevents the plotly_relayout fired by our own annotation update from - // being treated as a user-initiated zoom and triggering another update cycle. - const isAnnotationRelayout = useRef(false); - // Stable refs for callbacks — avoids including them in effect dep arrays // (consumers often pass arrow functions that change identity every render). const onPeakClickRef = useRef(onPeakClick); @@ -103,16 +94,10 @@ const ChromatogramChart: React.FC = ({ // Tracks the series index whose line is currently thickened on hover. const thickenedSeriesRef = useRef(null); - // Holds the latest peak Plotly annotations so that closures (e.g. the range - // repositioning handler) always use the current selection-styled annotations. + // Holds the latest peak Plotly annotations so that closures in effects always + // read the latest selection-styled annotations. const peakAnnotationsRef = useRef[]>([]); - // Holds the latest range annotation labels, kept in sync by both the main - // effect (initial render) and the range-repositioning handler (on pan/zoom). - // The selection effect reads this ref so it can combine peak + range labels - // in Plotly.relayout without wiping out any repositioning done by the user. - const rangeAnnotationLabelsRef = useRef[]>([]); - // Memoize processed series with baseline correction const processedSeries = useMemo(() => { return series.map((s) => { @@ -333,19 +318,6 @@ const ChromatogramChart: React.FC = ({ plotData.push(hitAreaTrace); } - // Range annotation shapes and labels - const { shapes: rangeShapes, annotations: rangeAnnotationLabels, yDomainMax } = - rangeAnnotations.length > 0 - ? buildRangeAnnotationElements( - rangeAnnotations, - rangeAnnotationOverlapThreshold, - processedSeries - ) - : { shapes: [], annotations: [], yDomainMax: 1.0 }; - - // Store initial range labels so the selection effect can combine them - rangeAnnotationLabelsRef.current = rangeAnnotationLabels; - const layout: Partial = { title: title ? { @@ -402,7 +374,6 @@ const ChromatogramChart: React.FC = ({ linewidth: 1, range: yRange, autorange: !yRange, - domain: [0, yDomainMax] as [number, number], zeroline: false, tickfont: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, showspikes: showCrosshairs, @@ -421,8 +392,7 @@ const ChromatogramChart: React.FC = ({ font: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, }, showlegend: showLegend && series.length > 1, - annotations: [...peakAnnotationsRef.current, ...rangeAnnotationLabels], - shapes: rangeShapes, + annotations: peakAnnotationsRef.current, }; const config: Partial = { @@ -500,76 +470,7 @@ const ChromatogramChart: React.FC = ({ } ); - // ── Range annotation label repositioning on pan / zoom ───────────────── - // Deferred via requestAnimationFrame to avoid re-entering Plotly's own RAF - // rendering pass. isAnnotationRelayout guards against feedback loops. - let pendingLabelUpdate: ReturnType | null = null; - - if (rangeAnnotations.length > 0) { - (currentRef as unknown as Plotly.PlotlyHTMLElement).on( - "plotly_relayout", - (eventData: Plotly.PlotRelayoutEvent) => { - const ed = eventData as Record; - if (isAnnotationRelayout.current) return; - - const isXAxisChange = - "xaxis.range[0]" in ed || - ("xaxis.range" in ed && Array.isArray(ed["xaxis.range"])) || - ed["xaxis.autorange"] === true; - if (!isXAxisChange) return; - - const isAutorange = ed["xaxis.autorange"] === true; - const xMin = isAutorange - ? null - : "xaxis.range[0]" in ed - ? (ed["xaxis.range[0]"] as number) - : (ed["xaxis.range"] as [number, number])[0]; - const xMax = isAutorange - ? null - : "xaxis.range[0]" in ed - ? (ed["xaxis.range[1]"] as number) - : (ed["xaxis.range"] as [number, number])[1]; - - if (pendingLabelUpdate !== null) cancelAnimationFrame(pendingLabelUpdate); - - pendingLabelUpdate = requestAnimationFrame(() => { - pendingLabelUpdate = null; - if (!currentRef) return; - - const updatedRangeLabels = (() => { - if (isAutorange || xMin === null || xMax === null) { - return rangeAnnotationLabels; - } - return rangeAnnotationLabels.map((labelAnn, i) => { - const rangeAnn = rangeAnnotations[i]; - if (!rangeAnn) return labelAnn; - const visibleStart = Math.max(rangeAnn.startX, xMin); - const visibleEnd = Math.min(rangeAnn.endX, xMax); - if (visibleStart >= visibleEnd) { - return { ...labelAnn, visible: false, x: (xMin + xMax) / 2 }; - } - return { ...labelAnn, x: (visibleStart + visibleEnd) / 2, visible: true }; - }); - })(); - - isAnnotationRelayout.current = true; - (Plotly.relayout(currentRef, { - annotations: [...peakAnnotationsRef.current, ...updatedRangeLabels], - } as unknown as Partial) as unknown as Promise) - .finally(() => { - // Keep rangeAnnotationLabelsRef in sync with the repositioned labels - // so the selection effect can use them correctly. - rangeAnnotationLabelsRef.current = updatedRangeLabels; - isAnnotationRelayout.current = false; - }); - }); - } - ); - } - return () => { - if (pendingLabelUpdate !== null) cancelAnimationFrame(pendingLabelUpdate); - isAnnotationRelayout.current = false; thickenedSeriesRef.current = null; if (currentRef) Plotly.purge(currentRef); }; @@ -579,7 +480,7 @@ const ChromatogramChart: React.FC = ({ processedAnnotations, xRange, yRange, showLegend, showGridX, showGridY, showMarkers, markerSize, showCrosshairs, enablePeakDetection, peakDetectionOptions, showPeakAreas, boundaryMarkers, annotationOverlapThreshold, showExportButton, - theme, rangeAnnotations, rangeAnnotationOverlapThreshold, + theme, // resolvedAppearance included so hover multiplier stays in sync with the // event handler closure without it being in a ref itself. resolvedAppearance, @@ -595,7 +496,7 @@ const ChromatogramChart: React.FC = ({ if (!(el as { _fullLayout?: unknown })._fullLayout) return; Plotly.relayout(el, { - annotations: [...peakAnnotations, ...rangeAnnotationLabelsRef.current], + annotations: peakAnnotations, } as unknown as Partial); }, [peakAnnotations]); diff --git a/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts b/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts deleted file mode 100644 index 43528119..00000000 --- a/src/components/charts/ChromatogramChart/__tests__/rangeAnnotations.test.ts +++ /dev/null @@ -1,161 +0,0 @@ -import { describe, it, expect } from "vitest"; - -import { RANGE_ANNOTATION } from "../constants"; -import { assignRangeLanes, buildRangeAnnotationElements } from "../rangeAnnotations"; - -import type { RangeAnnotation } from "../types"; - -const makeAnn = (startX: number, endX: number, overrides: Partial = {}): RangeAnnotation => ({ - label: `${startX}-${endX}`, - startX, - endX, - ...overrides, -}); - -describe("assignRangeLanes", () => { - it("assigns lane 0 to a single annotation", () => { - const result = assignRangeLanes([makeAnn(0, 5)], 0); - expect(result).toEqual([0]); - }); - - it("assigns same lane to non-overlapping annotations", () => { - const anns = [makeAnn(0, 2), makeAnn(3, 5)]; - const result = assignRangeLanes(anns, 0); - expect(result[0]).toBe(0); - expect(result[1]).toBe(0); - }); - - it("assigns different lanes to overlapping annotations", () => { - const anns = [makeAnn(0, 5), makeAnn(3, 8)]; - const result = assignRangeLanes(anns, 0); - expect(result[0]).toBe(0); - expect(result[1]).toBe(1); - }); - - it("respects explicit lane values", () => { - const anns = [makeAnn(0, 5, { lane: 2 })]; - const result = assignRangeLanes(anns, 0); - expect(result[0]).toBe(2); - }); - - it("mixes explicit and auto-assigned lanes, avoiding collisions", () => { - // Explicit at lane 0, auto should go to lane 1 because overlap - const anns = [makeAnn(0, 10, { lane: 0 }), makeAnn(5, 15)]; - const result = assignRangeLanes(anns, 0); - expect(result[0]).toBe(0); - expect(result[1]).toBe(1); - }); - - it("uses overlapThreshold to widen the no-overlap zone", () => { - // Annotations don't touch but are within threshold - const anns = [makeAnn(0, 3), makeAnn(3, 6)]; - // With threshold=1, startX=3 is NOT >= endX(3)-1=2 ... wait, let me recalc: - // startX(3) >= end(3) - threshold(1) => 3 >= 2 → true → same lane - const result = assignRangeLanes(anns, 1); - // startX >= end - threshold means it fits: same lane - expect(result[0]).toBe(0); - expect(result[1]).toBe(0); - }); - - it("returns empty array for empty input", () => { - expect(assignRangeLanes([], 0)).toEqual([]); - }); - - it("auto-assigns multiple non-overlapping annotations to lane 0", () => { - const anns = [makeAnn(10, 12), makeAnn(0, 2), makeAnn(5, 7)]; - const result = assignRangeLanes(anns, 0); - // All fit in lane 0 (processed in startX order: 0-2, 5-7, 10-12) - expect(result.every((l) => l === 0)).toBe(true); - }); -}); - -describe("buildRangeAnnotationElements", () => { - const seriesData = [{ x: [0, 1, 2, 3, 4, 5], y: [0, 10, 20, 15, 5, 0] }]; - - it("returns empty shapes/annotations and yDomainMax=1 for empty input", () => { - const result = buildRangeAnnotationElements([], 0, seriesData); - expect(result.shapes).toHaveLength(0); - expect(result.annotations).toHaveLength(0); - expect(result.yDomainMax).toBe(1.0); - }); - - it("produces one shape and one annotation per range annotation", () => { - const anns = [makeAnn(1, 3, { yAnchor: "top" })]; - const result = buildRangeAnnotationElements(anns, 0, seriesData); - expect(result.shapes).toHaveLength(1); - expect(result.annotations).toHaveLength(1); - }); - - it("uses 'top' yAnchor by default and shrinks yDomainMax", () => { - const anns = [makeAnn(1, 3)]; - const result = buildRangeAnnotationElements(anns, 0, seriesData); - expect(result.yDomainMax).toBeLessThan(1.0); - expect(result.shapes[0].yref).toBe("paper"); - }); - - it("does not shrink yDomainMax for 'auto' annotations", () => { - const anns = [makeAnn(1, 3, { yAnchor: "auto" })]; - const result = buildRangeAnnotationElements(anns, 0, seriesData); - expect(result.yDomainMax).toBe(1.0); - expect(result.shapes[0].yref).toBe("paper"); - }); - - it("uses 'y' yref for numeric yAnchor", () => { - const anns = [makeAnn(1, 3, { yAnchor: 5 })]; - const result = buildRangeAnnotationElements(anns, 0, seriesData); - expect(result.shapes[0].yref).toBe("y"); - expect(result.yDomainMax).toBe(1.0); - }); - - it("stacks two overlapping 'top' annotations in different lanes", () => { - const anns = [makeAnn(0, 5), makeAnn(3, 8)]; - const result = buildRangeAnnotationElements(anns, 0, seriesData); - const [s0, s1] = result.shapes; - // Lane 0 sits at paper y=1; lane 1 is lower → y1 of shape[1] < y1 of shape[0] - expect((s1.y1 as number)).toBeLessThan((s0.y1 as number)); - }); - - it("respects custom color, opacity, fontSize, labelColor", () => { - const anns = [makeAnn(1, 3, { color: "#ff0000", opacity: 0.3, fontSize: 14, labelColor: "#0000ff" })]; - const result = buildRangeAnnotationElements(anns, 0, seriesData); - expect(result.shapes[0].fillcolor).toBe("#ff0000"); - expect(result.shapes[0].opacity).toBe(0.3); - const ann = result.annotations[0] as { font: { size: number; color: string } }; - expect(ann.font.size).toBe(14); - expect(ann.font.color).toBe("#0000ff"); - }); - - it("falls back to CHART_COLORS when no color specified", () => { - const anns = [makeAnn(1, 3)]; - const result = buildRangeAnnotationElements(anns, 0, seriesData); - expect(typeof result.shapes[0].fillcolor).toBe("string"); - }); - - it("uses default opacity from RANGE_ANNOTATION constant", () => { - const anns = [makeAnn(1, 3)]; - const result = buildRangeAnnotationElements(anns, 0, seriesData); - expect(result.shapes[0].opacity).toBe(RANGE_ANNOTATION.DEFAULT_OPACITY); - }); - - it("auto yAnchor: uses paper yref and stacks multiple lanes", () => { - const anns = [makeAnn(0, 5, { yAnchor: "auto" }), makeAnn(2, 7, { yAnchor: "auto" })]; - const result = buildRangeAnnotationElements(anns, 0, seriesData); - expect(result.shapes[0].yref).toBe("paper"); - expect(result.shapes[1].yref).toBe("paper"); - }); - - it("handles empty seriesData for auto globalMaxY", () => { - const anns = [makeAnn(1, 3, { yAnchor: "auto" })]; - const result = buildRangeAnnotationElements(anns, 0, []); - expect(result.shapes).toHaveLength(1); - // globalMaxY = 0 → uses fallback yDomainMax * 0.5 - expect(result.shapes[0].yref).toBe("paper"); - }); - - it("caps yDomainMax at 0.5 minimum for many top lanes", () => { - // 20 stacked top annotations will want to shrink yDomainMax below 0.5 - const anns = Array.from({ length: 20 }, (_, i) => makeAnn(i * 10, i * 10 + 5)); - const result = buildRangeAnnotationElements(anns, 0, seriesData); - expect(result.yDomainMax).toBeGreaterThanOrEqual(0.5); - }); -}); diff --git a/src/components/charts/ChromatogramChart/constants.ts b/src/components/charts/ChromatogramChart/constants.ts index d8708fd6..a61a9c96 100644 --- a/src/components/charts/ChromatogramChart/constants.ts +++ b/src/components/charts/ChromatogramChart/constants.ts @@ -42,29 +42,4 @@ export const CHROMATOGRAM_TRACE = { BASE_LINE_WIDTH: 1.5, } as const; -/** - * Constants for range (fraction/region) annotations - */ -export const RANGE_ANNOTATION = { - /** Default fill opacity for the colored bar */ - DEFAULT_OPACITY: 0.5, - /** Default label font size */ - DEFAULT_FONT_SIZE: 11, - /** Bar height in paper coordinates (fraction of plot height) for "top" and "auto" anchors */ - BAR_HEIGHT_PAPER: 0.04, - /** Gap between stacked lanes in paper coordinates */ - LANE_GAP_PAPER: 0.01, - /** Multiplier on barHeight to compute the per-lane vertical stride in data coordinates */ - LANE_DATA_STRIDE_FACTOR: 1.5, - /** - * Plotly's typical autorange extension factor (Plotly renders data up to ~1/margin_factor - * of the plot height). Used to convert a data-y value to an approximate paper-y position - * when yAnchor is "auto". - */ - AUTO_YRANGE_MARGIN: 1.1, - /** Paper-space clearance added above the estimated peak paper-y for "auto" bars */ - AUTO_PAPER_CLEARANCE: 0.06, - /** Default bar height as a fraction of the global data max for number yAnchor */ - DATA_BAR_HEIGHT_FACTOR: 0.04, -} as const; diff --git a/src/components/charts/ChromatogramChart/index.ts b/src/components/charts/ChromatogramChart/index.ts index ae2d3612..5afeff59 100644 --- a/src/components/charts/ChromatogramChart/index.ts +++ b/src/components/charts/ChromatogramChart/index.ts @@ -2,7 +2,6 @@ export { ChromatogramChart } from "./ChromatogramChart"; export type { ChromatogramSeries, PeakAnnotation, - RangeAnnotation, ChromatogramChartProps, BaselineCorrectionMethod, BoundaryMarkerStyle, diff --git a/src/components/charts/ChromatogramChart/rangeAnnotations.ts b/src/components/charts/ChromatogramChart/rangeAnnotations.ts deleted file mode 100644 index f93c25b9..00000000 --- a/src/components/charts/ChromatogramChart/rangeAnnotations.ts +++ /dev/null @@ -1,210 +0,0 @@ -import { CHART_COLORS } from "../../../utils/colors"; - -import { RANGE_ANNOTATION } from "./constants"; - -import type { RangeAnnotation } from "./types"; -import type Plotly from "plotly.js-dist"; - -/** - * Assign a lane index to each RangeAnnotation. - * - * Annotations with an explicit `lane` value keep it; the rest are greedy-assigned - * (sorted by startX) to the lowest lane where they do not overlap any already-placed - * annotation (within `overlapThreshold` x-units). - */ -export function assignRangeLanes( - annotations: RangeAnnotation[], - overlapThreshold: number -): number[] { - const assignedLanes = new Array(annotations.length).fill(-1); - - // Track the rightmost endX seen in each lane (initialised lazily). - const laneEndX: number[] = []; - - const setLaneEnd = (lane: number, endX: number) => { - while (laneEndX.length <= lane) laneEndX.push(-Infinity); - laneEndX[lane] = Math.max(laneEndX[lane], endX); - }; - - // First pass: honour explicit lane values so they reserve space. - annotations.forEach((ann, i) => { - if (ann.lane !== undefined) { - assignedLanes[i] = ann.lane; - setLaneEnd(ann.lane, ann.endX); - } - }); - - // Second pass: greedy auto-assign, processed in startX order. - const autoIndices = annotations - .map((_, i) => i) - .filter((i) => assignedLanes[i] === -1) - .sort((a, b) => annotations[a].startX - annotations[b].startX); - - for (const idx of autoIndices) { - const ann = annotations[idx]; - - // Find the lowest lane where this bar fits (no overlap with threshold). - let chosenLane = laneEndX.findIndex( - (end) => ann.startX >= end - overlapThreshold - ); - - if (chosenLane === -1) { - chosenLane = laneEndX.length; - } - - assignedLanes[idx] = chosenLane; - setLaneEnd(chosenLane, ann.endX); - } - - return assignedLanes; -} - -/** - * Build Plotly shapes (colored bars) and annotations (labels) for all range annotations. - * - * Also returns `yDomainMax`: the fraction of the plot height that the y-axis should - * occupy. When "top" bars are present the y-axis is shrunk so that the reserved zone - * above it (paper-y from yDomainMax to 1.0) is always blank, preventing the bars from - * visually overlapping with the signal no matter how tall the data is. - */ -export function buildRangeAnnotationElements( - rangeAnnotations: RangeAnnotation[], - overlapThreshold: number, - seriesData: { x: number[]; y: number[] }[] -): { - shapes: Partial[]; - annotations: Partial[]; - yDomainMax: number; -} { - const lanes = assignRangeLanes(rangeAnnotations, overlapThreshold); - const shapes: Partial[] = []; - const annotations: Partial[] = []; - const globalMaxY = computeGlobalMaxY(seriesData); - - // Determine how many "top" lanes are used and shrink the y-axis domain accordingly. - // Each lane needs BAR_HEIGHT_PAPER + LANE_GAP_PAPER of vertical space. - let maxTopLane = -1; - rangeAnnotations.forEach((ann, i) => { - if ((ann.yAnchor ?? "top") === "top") { - maxTopLane = Math.max(maxTopLane, lanes[i]); - } - }); - const topLaneCount = maxTopLane + 1; - const defaultStride = RANGE_ANNOTATION.BAR_HEIGHT_PAPER + RANGE_ANNOTATION.LANE_GAP_PAPER; - // Add one extra gap below the lowest bar so it doesn't sit flush against the data. - const yDomainMax = - topLaneCount > 0 - ? Math.max(1.0 - topLaneCount * defaultStride - RANGE_ANNOTATION.LANE_GAP_PAPER, 0.5) - : 1.0; - - rangeAnnotations.forEach((ann, i) => { - const lane = lanes[i]; - const color = ann.color ?? CHART_COLORS[i % CHART_COLORS.length]; - const opacity = ann.opacity ?? RANGE_ANNOTATION.DEFAULT_OPACITY; - const fontSize = ann.fontSize ?? RANGE_ANNOTATION.DEFAULT_FONT_SIZE; - const labelColor = ann.labelColor ?? color; - const yAnchor = ann.yAnchor ?? "top"; - const centerX = (ann.startX + ann.endX) / 2; - - let shapeY0: number; - let shapeY1: number; - let yref: "paper" | "y"; - - if (yAnchor === "top") { - const barHeight = ann.barHeight ?? RANGE_ANNOTATION.BAR_HEIGHT_PAPER; - const stride = barHeight + RANGE_ANNOTATION.LANE_GAP_PAPER; - // Lane 0 sits flush with paper-y 1.0; higher lanes step downward. - // Because the y-axis domain ends at yDomainMax, these bars render in the - // reserved blank zone above the axes — always clear of the signal. - shapeY1 = 1.0 - lane * stride; - shapeY0 = shapeY1 - barHeight; - yref = "paper"; - } else if (yAnchor === "auto") { - // Estimate where the local peak sits in paper-space, scaled to yDomainMax so the - // estimate is accurate after domain adjustment. - const localMaxY = computeLocalMaxY(ann.startX, ann.endX, seriesData); - const barHeight = ann.barHeight ?? RANGE_ANNOTATION.BAR_HEIGHT_PAPER; - const stride = barHeight + RANGE_ANNOTATION.LANE_GAP_PAPER; - const estimatedPeakPaperY = - globalMaxY > 0 - ? (localMaxY / (globalMaxY * RANGE_ANNOTATION.AUTO_YRANGE_MARGIN)) * yDomainMax - : yDomainMax * 0.5; - const baseY = Math.min( - estimatedPeakPaperY + RANGE_ANNOTATION.AUTO_PAPER_CLEARANCE, - yDomainMax - barHeight - ); - // Lane 0 = closest to the peak; higher lanes stack upward (away from data). - shapeY1 = Math.min(baseY + lane * stride + barHeight, yDomainMax); - shapeY0 = shapeY1 - barHeight; - yref = "paper"; - } else { - // Explicit data-coordinate placement. - const barHeight = ann.barHeight ?? globalMaxY * RANGE_ANNOTATION.DATA_BAR_HEIGHT_FACTOR; - const laneOffset = lane * barHeight * RANGE_ANNOTATION.LANE_DATA_STRIDE_FACTOR; - shapeY0 = (yAnchor as number) + laneOffset; - shapeY1 = shapeY0 + barHeight; - yref = "y"; - } - - const labelY = (shapeY0 + shapeY1) / 2; - - shapes.push({ - type: "rect", - xref: "x", - yref, - x0: ann.startX, - x1: ann.endX, - y0: shapeY0, - y1: shapeY1, - fillcolor: color, - opacity, - line: { width: 0 }, - }); - - annotations.push({ - x: centerX, - y: labelY, - xref: "x", - yref, - text: ann.label, - showarrow: false, - font: { - size: fontSize, - color: labelColor, - family: "Inter, sans-serif", - }, - xanchor: "center", - yanchor: "middle", - // @ts-ignore cliponaxis is a valid Plotly annotation property missing from the type definitions - cliponaxis: false, - }); - }); - - return { shapes, annotations, yDomainMax }; -} - -function computeLocalMaxY( - startX: number, - endX: number, - seriesData: { x: number[]; y: number[] }[] -): number { - let maxY = 0; - for (const s of seriesData) { - for (let j = 0; j < s.x.length; j++) { - if (s.x[j] >= startX && s.x[j] <= endX && s.y[j] > maxY) { - maxY = s.y[j]; - } - } - } - return maxY; -} - -function computeGlobalMaxY(seriesData: { x: number[]; y: number[] }[]): number { - let maxY = 0; - for (const s of seriesData) { - for (const y of s.y) { - if (y > maxY) maxY = y; - } - } - return maxY; -} diff --git a/src/components/charts/ChromatogramChart/types.ts b/src/components/charts/ChromatogramChart/types.ts index ba8ba184..3e934d00 100644 --- a/src/components/charts/ChromatogramChart/types.ts +++ b/src/components/charts/ChromatogramChart/types.ts @@ -163,50 +163,6 @@ export interface PeakDetectionOptions { relativeThreshold?: boolean; } -/** - * A horizontal colored bar spanning [startX, endX] with a centered label. - * Used to annotate chromatographic fractions, compound windows, or regions of interest. - */ -export interface RangeAnnotation { - /** Label text displayed centered within the bar */ - label: string; - /** Left edge of the range (x-axis units, same as series data) */ - startX: number; - /** Right edge of the range (x-axis units, same as series data) */ - endX: number; - /** CSS color for the bar fill (defaults to next CHART_COLOR in sequence) */ - color?: string; - /** Fill opacity for the bar (0–1, default: 0.5) */ - opacity?: number; - /** - * Vertical placement of the bar: - * - "top" (default) — fixed at the top of the plot area in paper-space; all bars - * line up at the same height regardless of the underlying signal - * - "auto" — paper-space position estimated proportionally from the local peak height - * relative to the global maximum; bars float visibly above each individual peak - * without overlapping the signal - * - number — exact y data-coordinate for the bottom edge of the bar; use when you - * need pixel-precise placement and control the yRange yourself - */ - yAnchor?: "auto" | "top" | number; - /** - * Height of the colored bar. - * - When yAnchor is "top" or "auto": fraction of plot height in paper-space (default: 0.04) - * - When yAnchor is a number: y data units (default: 4% of global data max) - */ - barHeight?: number; - /** Font size of the label (default: 11) */ - fontSize?: number; - /** Label text color (defaults to the bar color) */ - labelColor?: string; - /** - * Vertical stacking lane (0 = closest to data, higher = further away for "auto"/number; - * 0 = topmost, higher = lower for "top"). - * When omitted, lane is auto-assigned to avoid overlapping bars. - */ - lane?: number; -} - /** * Props for ChromatogramChart component */ @@ -264,17 +220,6 @@ export interface ChromatogramChartProps { annotationOverlapThreshold?: number; /** Show export button in modebar (default: true) */ showExportButton?: boolean; - /** - * Horizontal range annotations displayed as colored bars with centered labels. - * Rendered as Plotly shapes; independent of the peak annotation system. - */ - rangeAnnotations?: RangeAnnotation[]; - /** - * x-axis overlap threshold for auto lane assignment (same units as series x data). - * Two range annotations whose ranges overlap by more than this amount are placed in - * separate lanes. Default: 0 (only exact overlaps trigger stacking). - */ - rangeAnnotationOverlapThreshold?: number; // ── Peak selection / interaction ──────────────────────────────────────────── diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx index 87925285..28192cd8 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx @@ -12,25 +12,22 @@ export function StackedChromatogramChart({ stackOffset = 0, stackingOrder = "first-on-bottom", annotations, - rangeAnnotations, ...restProps }: StackedChromatogramChartProps) { const { series: transformedSeries, annotations: transformedAnnotations, - rangeAnnotations: transformedRangeAnnotations, yRange, } = useMemo( () => applyStackingTransform( series, annotations, - rangeAnnotations, stackingMode, stackOffset, stackingOrder ), - [series, annotations, rangeAnnotations, stackingMode, stackOffset, stackingOrder] + [series, annotations, stackingMode, stackOffset, stackingOrder] ); return ( @@ -38,7 +35,6 @@ export function StackedChromatogramChart({ {...restProps} series={transformedSeries} annotations={transformedAnnotations} - rangeAnnotations={transformedRangeAnnotations} yRange={yRange} /> ); diff --git a/src/components/charts/StackedChromatogramChart/__tests__/transforms.test.ts b/src/components/charts/StackedChromatogramChart/__tests__/transforms.test.ts index 02793507..7cb7aea1 100644 --- a/src/components/charts/StackedChromatogramChart/__tests__/transforms.test.ts +++ b/src/components/charts/StackedChromatogramChart/__tests__/transforms.test.ts @@ -2,7 +2,7 @@ import { describe, it, expect } from "vitest"; import { applyStackingTransform } from "../transforms"; -import type { ChromatogramSeries, PeakAnnotation, RangeAnnotation } from "../../ChromatogramChart"; +import type { ChromatogramSeries, PeakAnnotation } from "../../ChromatogramChart"; const makeSeries = (y: number[], name = "S"): ChromatogramSeries => ({ name, @@ -13,7 +13,7 @@ const makeSeries = (y: number[], name = "S"): ChromatogramSeries => ({ describe("applyStackingTransform - overlay mode", () => { it("returns the input series unchanged", () => { const series = [makeSeries([1, 2, 3]), makeSeries([4, 5, 6])]; - const result = applyStackingTransform(series, undefined, undefined, "overlay", 10); + const result = applyStackingTransform(series, undefined, "overlay", 10); expect(result.series).toBe(series); }); @@ -23,36 +23,26 @@ describe("applyStackingTransform - overlay mode", () => { [{ x: 0, y: 1 }], [{ x: 1, y: 3 }], ]; - const result = applyStackingTransform(series, annotations, undefined, "overlay", 10); + const result = applyStackingTransform(series, annotations, "overlay", 10); expect(result.annotations).toHaveLength(2); }); it("returns empty annotations when undefined", () => { const series = [makeSeries([1, 2])]; - const result = applyStackingTransform(series, undefined, undefined, "overlay", 10); + const result = applyStackingTransform(series, undefined, "overlay", 10); expect(result.annotations).toHaveLength(0); }); - it("flattens range annotations from all series", () => { - const series = [makeSeries([1]), makeSeries([2])]; - const rangeAnns: RangeAnnotation[][] = [ - [{ label: "A", startX: 0, endX: 1 }], - [{ label: "B", startX: 2, endX: 3 }], - ]; - const result = applyStackingTransform(series, undefined, rangeAnns, "overlay", 10); - expect(result.rangeAnnotations).toHaveLength(2); - }); - it("computes yRange spanning all series values including 0 minimum", () => { const series = [makeSeries([5, 10]), makeSeries([3, 8])]; - const result = applyStackingTransform(series, undefined, undefined, "overlay", 10); + const result = applyStackingTransform(series, undefined, "overlay", 10); expect(result.yRange[0]).toBe(0); // min(..., 0) expect(result.yRange[1]).toBe(10); }); it("preserves negative yMin below 0", () => { const series = [makeSeries([-5, 10])]; - const result = applyStackingTransform(series, undefined, undefined, "overlay", 10); + const result = applyStackingTransform(series, undefined, "overlay", 10); expect(result.yRange[0]).toBe(-5); }); }); @@ -60,7 +50,7 @@ describe("applyStackingTransform - overlay mode", () => { describe("applyStackingTransform - stack mode (first-on-bottom)", () => { it("shifts each series y values by index * stackOffset", () => { const series = [makeSeries([0, 5]), makeSeries([0, 5])]; - const result = applyStackingTransform(series, undefined, undefined, "stack", 10, "first-on-bottom"); + const result = applyStackingTransform(series, undefined, "stack", 10, "first-on-bottom"); // series 0: no shift; series 1: +10 expect(result.series[0].y).toEqual([0, 5]); expect(result.series[1].y).toEqual([10, 15]); @@ -69,7 +59,7 @@ describe("applyStackingTransform - stack mode (first-on-bottom)", () => { it("preserves original series objects (does not mutate)", () => { const originalY = [0, 5]; const series = [makeSeries(originalY)]; - applyStackingTransform(series, undefined, undefined, "stack", 10); + applyStackingTransform(series, undefined, "stack", 10); expect(series[0].y).toEqual([0, 5]); }); @@ -79,53 +69,21 @@ describe("applyStackingTransform - stack mode (first-on-bottom)", () => { [{ x: 1, y: 5 }], [{ x: 1, y: 5 }], ]; - const result = applyStackingTransform(series, annotations, undefined, "stack", 10); + const result = applyStackingTransform(series, annotations, "stack", 10); expect(result.annotations[0].y).toBe(5); // series 0, no shift expect(result.annotations[1].y).toBe(15); // series 1, +10 }); - it("shifts numeric yAnchor in range annotations", () => { - const series = [makeSeries([0, 5]), makeSeries([0, 5])]; - const rangeAnns: RangeAnnotation[][] = [ - [], - [{ label: "B", startX: 0, endX: 1, yAnchor: 2 }], - ]; - const result = applyStackingTransform(series, undefined, rangeAnns, "stack", 10); - // series 1 gets +10 shift; numeric yAnchor 2 → 12 - expect(result.rangeAnnotations[0].yAnchor).toBe(12); - }); - - it("does not shift string yAnchor values ('top', 'auto')", () => { - const series = [makeSeries([0, 5]), makeSeries([0, 5])]; - const rangeAnns: RangeAnnotation[][] = [ - [], - [{ label: "B", startX: 0, endX: 1, yAnchor: "top" }], - ]; - const result = applyStackingTransform(series, undefined, rangeAnns, "stack", 10); - expect(result.rangeAnnotations[0].yAnchor).toBe("top"); - }); - - it("does not shift 'auto' yAnchor", () => { - const series = [makeSeries([0, 5]), makeSeries([0, 5])]; - const rangeAnns: RangeAnnotation[][] = [ - [], - [{ label: "B", startX: 0, endX: 1, yAnchor: "auto" }], - ]; - const result = applyStackingTransform(series, undefined, rangeAnns, "stack", 10); - expect(result.rangeAnnotations[0].yAnchor).toBe("auto"); - }); - it("handles undefined annotations gracefully", () => { const series = [makeSeries([0, 5])]; - const result = applyStackingTransform(series, undefined, undefined, "stack", 10); + const result = applyStackingTransform(series, undefined, "stack", 10); expect(result.annotations).toHaveLength(0); - expect(result.rangeAnnotations).toHaveLength(0); }); it("computes stacked yRange including annotation y values", () => { const series = [makeSeries([0, 5]), makeSeries([0, 5])]; const anns: PeakAnnotation[][] = [[{ x: 0, y: 5 }], [{ x: 1, y: 5 }]]; - const result = applyStackingTransform(series, anns, undefined, "stack", 10); + const result = applyStackingTransform(series, anns, "stack", 10); // series[1] is shifted by 10 → max y = 15; annotations[1].y = 15 expect(result.yRange[1]).toBe(15); expect(result.yRange[0]).toBe(0); @@ -136,7 +94,7 @@ describe("applyStackingTransform - stack mode (first-on-top)", () => { it("reverses stacking: last series gets no shift, first gets max shift", () => { const series = [makeSeries([0, 5]), makeSeries([0, 5]), makeSeries([0, 5])]; // N=3, first-on-top: index 0 → shift (3-1-0)*10=20, index 1 → 10, index 2 → 0 - const result = applyStackingTransform(series, undefined, undefined, "stack", 10, "first-on-top"); + const result = applyStackingTransform(series, undefined, "stack", 10, "first-on-top"); expect(result.series[0].y).toEqual([20, 25]); expect(result.series[1].y).toEqual([10, 15]); expect(result.series[2].y).toEqual([0, 5]); @@ -149,15 +107,15 @@ describe("applyStackingTransform - stack mode (first-on-top)", () => { [{ x: 1, y: 5 }], ]; // N=2: index 0 → (2-1-0)*10=10; index 1 → 0 - const result = applyStackingTransform(series, annotations, undefined, "stack", 10, "first-on-top"); + const result = applyStackingTransform(series, annotations, "stack", 10, "first-on-top"); expect(result.annotations[0].y).toBe(15); expect(result.annotations[1].y).toBe(5); }); it("default stackingOrder is first-on-bottom", () => { const series = [makeSeries([0, 5]), makeSeries([0, 5])]; - const resultDefault = applyStackingTransform(series, undefined, undefined, "stack", 10); - const resultExplicit = applyStackingTransform(series, undefined, undefined, "stack", 10, "first-on-bottom"); + const resultDefault = applyStackingTransform(series, undefined, "stack", 10); + const resultExplicit = applyStackingTransform(series, undefined, "stack", 10, "first-on-bottom"); expect(resultDefault.series[1].y).toEqual(resultExplicit.series[1].y); }); }); diff --git a/src/components/charts/StackedChromatogramChart/transforms.ts b/src/components/charts/StackedChromatogramChart/transforms.ts index 3d05b6c4..f8416531 100644 --- a/src/components/charts/StackedChromatogramChart/transforms.ts +++ b/src/components/charts/StackedChromatogramChart/transforms.ts @@ -1,21 +1,15 @@ -import type { - ChromatogramSeries, - PeakAnnotation, - RangeAnnotation, -} from "../ChromatogramChart"; +import type { ChromatogramSeries, PeakAnnotation } from "../ChromatogramChart"; import type { StackingMode } from "./types"; interface TransformResult { series: ChromatogramSeries[]; annotations: PeakAnnotation[]; - rangeAnnotations: RangeAnnotation[]; yRange: [number, number]; } export function applyStackingTransform( inputSeries: ChromatogramSeries[], inputAnnotations: PeakAnnotation[][] | undefined, - inputRangeAnnotations: RangeAnnotation[][] | undefined, mode: StackingMode, stackOffset: number, stackingOrder: "first-on-bottom" | "first-on-top" = "first-on-bottom" @@ -29,7 +23,6 @@ export function applyStackingTransform( return { series: inputSeries, annotations: inputAnnotations ? inputAnnotations.flat() : [], - rangeAnnotations: inputRangeAnnotations ? inputRangeAnnotations.flat() : [], yRange: consistentYRange, }; } @@ -58,24 +51,6 @@ export function applyStackingTransform( }); } - // Shift numeric yAnchor values; "top" and "auto" anchors are unaffected - // because they derive position from paper-space or the already-shifted data. - const offsetRangeAnnotations: RangeAnnotation[] = []; - if (inputRangeAnnotations) { - inputRangeAnnotations.forEach((seriesRangeAnnotations, seriesIndex) => { - const yShift = yShiftForIndex(seriesIndex); - seriesRangeAnnotations.forEach((ann) => { - offsetRangeAnnotations.push({ - ...ann, - yAnchor: - typeof ann.yAnchor === "number" - ? ann.yAnchor + yShift - : ann.yAnchor, - }); - }); - }); - } - const allYValues = offsetSeries.flatMap((s) => s.y); const annotationYValues = offsetAnnotations.map((a) => a.y); const stackedYMin = Math.min(...allYValues, ...annotationYValues, 0); @@ -84,7 +59,6 @@ export function applyStackingTransform( return { series: offsetSeries, annotations: offsetAnnotations, - rangeAnnotations: offsetRangeAnnotations, yRange: [stackedYMin, stackedYMax], }; } diff --git a/src/components/charts/StackedChromatogramChart/types.ts b/src/components/charts/StackedChromatogramChart/types.ts index f189feb7..ac8e4951 100644 --- a/src/components/charts/StackedChromatogramChart/types.ts +++ b/src/components/charts/StackedChromatogramChart/types.ts @@ -2,16 +2,12 @@ import type { ChromatogramChartProps, ChromatogramSeries, PeakAnnotation, - RangeAnnotation, } from "../ChromatogramChart"; export type StackingMode = "overlay" | "stack"; export interface StackedChromatogramChartProps - extends Omit< - ChromatogramChartProps, - "series" | "annotations" | "yRange" | "rangeAnnotations" - > { + extends Omit { /** Array of data series to display */ series: ChromatogramSeries[]; @@ -31,20 +27,11 @@ export interface StackedChromatogramChartProps */ annotations?: PeakAnnotation[][]; - /** - * Range annotations per series, parallel to the series array. - * rangeAnnotations[i] corresponds to series[i]. - * In stack mode, numeric yAnchor values are shifted by the series offset so - * bars stay pinned to their trace. "top" and "auto" anchors are unaffected - * (they derive their position from paper-space or the already-shifted data). - */ - rangeAnnotations?: RangeAnnotation[][]; - /** * Controls which end of the stack series[0] lands on (stack mode only). * - "first-on-bottom" (default) — series[0] sits at the base; series[N-1] is highest. * - "first-on-top" — series[0] is shifted to the top; series[N-1] is at the base. - * Annotations and numeric-yAnchor range annotations follow the chosen direction. + * Annotations follow the chosen direction. */ stackingOrder?: "first-on-bottom" | "first-on-top"; } From 25583dfcbf98bb86db38c26f5a0ed589d5195361 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 15:49:18 -0500 Subject: [PATCH 16/30] Adds play function for PeakHoverAndSelection --- .../ChromatogramChart.stories.tsx | 24 +++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index c62a36af..d968e703 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -337,6 +337,30 @@ export const PeakHoverAndSelection: StoryObj = { title: "Peak Hover and Selection", annotations: selectableAnnotations, }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart renders with peak annotations", async () => { + expect(canvas.getByText("Peak Hover and Selection")).toBeInTheDocument(); + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Mouse events exercise hover, unhover, and click handler paths", async () => { + const dragRect = canvasElement.querySelector(".xy.drag") as Element | null; + if (!dragRect) return; + + const { left, top, width, height } = dragRect.getBoundingClientRect(); + const cx = Math.round(left + width * 0.35); + const cy = Math.round(top + height * 0.5); + + // mousemove → plotly_hover fires (trace thickening + peak hover callback paths) + dragRect.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: cx, clientY: cy })); + // mouseout → plotly_unhover fires (unhover cleanup paths) + dragRect.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })); + // click → plotly_click fires (click handler paths, no-peak early-return branch) + dragRect.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: cx, clientY: cy })); + }); + }, parameters: { docs: { description: { From 3a241924164a0f851195b7cc6af1f3a9fe410d85 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 16:14:54 -0500 Subject: [PATCH 17/30] Adds test coverage for both the Pointer Events and Mouse Events APIs that Plotly may use internally --- .../ChromatogramChart.stories.tsx | 21 ++++++++----------- 1 file changed, 9 insertions(+), 12 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index d968e703..f086edad 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -1,5 +1,5 @@ import { useState } from "react"; -import { expect, within } from "storybook/test"; +import { expect, userEvent, within } from "storybook/test"; import { ChromatogramChart, @@ -346,19 +346,16 @@ export const PeakHoverAndSelection: StoryObj = { }); await step("Mouse events exercise hover, unhover, and click handler paths", async () => { - const dragRect = canvasElement.querySelector(".xy.drag") as Element | null; - if (!dragRect) return; + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBeGreaterThan(0); - const { left, top, width, height } = dragRect.getBoundingClientRect(); - const cx = Math.round(left + width * 0.35); - const cy = Math.round(top + height * 0.5); + const dragRect = canvasElement.querySelector(".xy.drag") as HTMLElement | null; + if (!dragRect) return; - // mousemove → plotly_hover fires (trace thickening + peak hover callback paths) - dragRect.dispatchEvent(new MouseEvent("mousemove", { bubbles: true, clientX: cx, clientY: cy })); - // mouseout → plotly_unhover fires (unhover cleanup paths) - dragRect.dispatchEvent(new MouseEvent("mouseout", { bubbles: true })); - // click → plotly_click fires (click handler paths, no-peak early-return branch) - dragRect.dispatchEvent(new MouseEvent("click", { bubbles: true, clientX: cx, clientY: cy })); + // userEvent fires the full pointer+mouse event sequence that Plotly responds to + await userEvent.hover(dragRect); + await userEvent.unhover(dragRect); + await userEvent.click(dragRect); }); }, parameters: { From bd0f2454f9d053bc77149059947bc98269e933c0 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 16:41:33 -0500 Subject: [PATCH 18/30] Increase test coverage of ChromatogramChart and adds story for hover --- .../ChromatogramChart.stories.tsx | 79 ++++++++++++ .../__tests__/dataProcessing.test.ts | 114 ++++++++++++++++++ .../__tests__/peakDetection.test.ts | 62 ++++++++++ 3 files changed, 255 insertions(+) create mode 100644 src/components/charts/ChromatogramChart/__tests__/dataProcessing.test.ts create mode 100644 src/components/charts/ChromatogramChart/__tests__/peakDetection.test.ts diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index f086edad..e08de037 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -436,6 +436,85 @@ export const WithRegionOverlay: Story = { }, }; +/** + * Directly exercises the plotly_hover / plotly_unhover event handlers registered in + * ChromatogramChart. userEvent.hover cannot reliably land on a data point in headless + * Playwright, so we emit the Plotly events directly on the graph element — the same + * path Plotly's own hit-detection uses internally. + * + * Test cases: + * 1. Hover trace 0 with no prior thickening → thicken trace 0 (null→0 branch) + * 2. Hover trace 1 → restore trace 0, thicken trace 1 (0→1 branch) + * 3. Hover trace 1 again → no-op (same-trace guard) + * 4. Hover a peak hit-area marker (curveNumber ≥ series count) → skip thickening + * 5. Unhover with a thickened series → restore + clear ref + * 6. Unhover with nothing thickened → no restyle call needed + */ +export const TraceHoverThickening: Story = { + args: { + series: multiInjectionData, + title: "Trace Hover Thickening", + annotations: selectableAnnotations, + onPeakHover: () => {}, + }, + play: async ({ canvasElement, step }) => { + await step("Chart renders with multiple traces", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBeGreaterThan(1); + }); + + await step("Hover / unhover events exercise trace thickening branches", async () => { + const plotEl = canvasElement.querySelector(".js-plotly-plot") as any; + if (!plotEl?.emit) return; + + // multiInjectionData has 3 series → processedSeries.length = 3 + // curveNumbers 0-2 are series traces; higher numbers are hit-area / marker traces + + // 1. First hover: no prior thickening (thickenedSeriesRef === null → false branch of line 442) + plotEl.emit("plotly_hover", { points: [{ curveNumber: 0, pointNumber: 30, customdata: null }] }); + + // 2. Hover different trace: restore trace 0, thicken trace 1 (true branch of line 442) + plotEl.emit("plotly_hover", { points: [{ curveNumber: 1, pointNumber: 30, customdata: null }] }); + + // 3. Hover same trace again: same-trace guard (false branch of line 441) + plotEl.emit("plotly_hover", { points: [{ curveNumber: 1, pointNumber: 40, customdata: null }] }); + + // 4. Hover a non-series trace (peak hit-area): skips thickening (false branch of line 439) + plotEl.emit("plotly_hover", { points: [{ curveNumber: 99, pointNumber: 0, customdata: null }] }); + + // 5. Unhover with a thickened series still active → restore + null out ref + plotEl.emit("plotly_unhover", {}); + + // 6. Unhover again with nothing thickened → executes line 465, skips 466-469 + plotEl.emit("plotly_unhover", {}); + }); + + await step("Peak hit-area hover fires onPeakHover callback", async () => { + const plotEl = canvasElement.querySelector(".js-plotly-plot") as any; + if (!plotEl?.emit) return; + + const mockPeakEvent: PeakSelectEvent = { + id: "caffeine", + peak: selectableAnnotations[0], + seriesIndex: 0, + seriesName: "Sample A", + isAutoDetected: false, + }; + plotEl.emit("plotly_hover", { points: [{ curveNumber: 99, customdata: mockPeakEvent }] }); + plotEl.emit("plotly_unhover", {}); + }); + }, + parameters: { + docs: { + description: { + story: + "Exercises the internal plotly_hover / plotly_unhover event handlers: trace thickening on hover, restoration when moving between traces, and cleanup on unhover.", + }, + }, + }, +}; + /** * Inline annotation style: labels float directly above the trace at the peak Y value * with no arrow. Cleaner for dense chromatograms where arrows create visual noise. diff --git a/src/components/charts/ChromatogramChart/__tests__/dataProcessing.test.ts b/src/components/charts/ChromatogramChart/__tests__/dataProcessing.test.ts new file mode 100644 index 00000000..083a7acc --- /dev/null +++ b/src/components/charts/ChromatogramChart/__tests__/dataProcessing.test.ts @@ -0,0 +1,114 @@ +import { describe, it, expect } from "vitest"; + +import { + buildHoverExtraContent, + applyBaselineCorrection, +} from "../dataProcessing"; + +describe("buildHoverExtraContent", () => { + it("returns seriesName when no metadata provided", () => { + expect(buildHoverExtraContent("Sample A")).toBe("Sample A"); + }); + + it("returns seriesName when metadata is undefined", () => { + expect(buildHoverExtraContent("Sample A", undefined)).toBe("Sample A"); + }); + + it("returns seriesName when metadata is empty object", () => { + expect(buildHoverExtraContent("Sample A", {})).toBe("Sample A"); + }); + + it("returns seriesName when all metadata values are empty/null/undefined", () => { + expect( + buildHoverExtraContent("Sample A", { a: null, b: undefined, c: "" }) + ).toBe("Sample A"); + }); + + it("appends metadata fields as HTML key: value lines", () => { + const result = buildHoverExtraContent("Sample A", { concentration: "10 mM" }); + expect(result).toBe("Sample A
Concentration: 10 mM"); + }); + + it("converts camelCase keys to Title Case", () => { + const result = buildHoverExtraContent("S", { sampleName: "X", batchId: "B1" }); + expect(result).toContain("Sample Name: X"); + expect(result).toContain("Batch Id: B1"); + }); + + it("joins multiple metadata fields with
", () => { + const result = buildHoverExtraContent("S", { a: "1", b: "2", c: "3" }); + const lines = result.split("
"); + expect(lines).toHaveLength(4); // seriesName + 3 fields + }); + + it("skips null, undefined, and empty-string values but keeps valid ones", () => { + const result = buildHoverExtraContent("S", { + good: "yes", + bad: null, + ugly: undefined, + empty: "", + }); + expect(result).toContain("Good: yes"); + expect(result).not.toContain("Bad"); + expect(result).not.toContain("Ugly"); + expect(result).not.toContain("Empty"); + }); + + it("coerces numeric metadata values to strings", () => { + const result = buildHoverExtraContent("S", { retention: 4.5 }); + expect(result).toContain("Retention: 4.5"); + }); +}); + +describe("applyBaselineCorrection", () => { + describe("rolling method", () => { + it("returns array of the same length", () => { + const y = [1, 2, 5, 2, 1, 2, 6, 2, 1]; + const result = applyBaselineCorrection(y, "rolling", 3); + expect(result).toHaveLength(y.length); + }); + + it("subtracts the rolling minimum so all values are >= 0 for a flat baseline", () => { + // Flat baseline of 1, peaks on top + const y = [1, 1, 3, 1, 1, 1, 4, 1, 1]; + const result = applyBaselineCorrection(y, "rolling", 3); + result.forEach((v) => expect(v).toBeGreaterThanOrEqual(0)); + }); + + it("corrects a signal with a sloped baseline", () => { + // Linearly increasing baseline; peak in the middle should still be visible + const y = [0, 1, 2, 5, 4, 5, 6, 7, 8]; + const result = applyBaselineCorrection(y, "rolling", 3); + // The peak at index 3 (value 5) should have a larger corrected value than its neighbors + expect(result[3]).toBeGreaterThan(result[0]); + }); + + it("handles windowSize larger than the array", () => { + const y = [2, 3, 2]; + const result = applyBaselineCorrection(y, "rolling", 100); + expect(result).toHaveLength(3); + // With full-array window, baseline is the global minimum (2) everywhere + expect(result[1]).toBeCloseTo(1); // 3 - 2 + expect(result[0]).toBeCloseTo(0); // 2 - 2 + }); + + it("returns empty array unchanged", () => { + expect(applyBaselineCorrection([], "rolling")).toEqual([]); + }); + }); + + describe("none method", () => { + it("returns the original array unchanged", () => { + const y = [1, 2, 3]; + expect(applyBaselineCorrection(y, "none")).toBe(y); + }); + }); + + describe("linear method", () => { + it("corrects a linearly rising signal to near-zero", () => { + const y = [0, 1, 2, 3, 4]; + const result = applyBaselineCorrection(y, "linear"); + result.forEach((v) => expect(v).toBeCloseTo(0, 10)); + }); + }); +}); diff --git a/src/components/charts/ChromatogramChart/__tests__/peakDetection.test.ts b/src/components/charts/ChromatogramChart/__tests__/peakDetection.test.ts new file mode 100644 index 00000000..f0aa87e9 --- /dev/null +++ b/src/components/charts/ChromatogramChart/__tests__/peakDetection.test.ts @@ -0,0 +1,62 @@ +import { describe, it, expect } from "vitest"; + +import { filterPeaksByDistance } from "../peakDetection"; + +import type { PeakAnnotation } from "../types"; + +function makePeak(x: number, y: number, index: number): PeakAnnotation { + return { x, y, _computed: { index, area: 0, startIndex: 0, endIndex: 0, widthAtHalfMax: 0 } }; +} + +describe("filterPeaksByDistance", () => { + it("keeps all peaks that are far enough apart", () => { + const peaks = [ + makePeak(1, 5, 0), + makePeak(10, 3, 10), + makePeak(20, 7, 20), + ]; + const result = filterPeaksByDistance(peaks, 5); + expect(result).toHaveLength(3); + }); + + it("keeps only the first peak when second is closer but shorter", () => { + const peaks = [ + makePeak(1, 8, 0), + makePeak(2, 3, 2), // within minDistance=5, shorter → discarded + ]; + const result = filterPeaksByDistance(peaks, 5); + expect(result).toHaveLength(1); + expect(result[0].y).toBe(8); + }); + + it("replaces a peak with a closer but taller one", () => { + const peaks = [ + makePeak(1, 3, 0), + makePeak(2, 8, 2), // within minDistance=5, taller → replaces previous + ]; + const result = filterPeaksByDistance(peaks, 5); + expect(result).toHaveLength(1); + expect(result[0].y).toBe(8); + }); + + it("returns peaks sorted by x after filtering", () => { + // Two far-apart peaks passed in reverse x order + const peaks = [ + makePeak(20, 5, 20), + makePeak(1, 3, 0), + ]; + const result = filterPeaksByDistance(peaks, 5); + expect(result[0].x).toBe(1); + expect(result[1].x).toBe(20); + }); + + it("returns empty array for empty input", () => { + expect(filterPeaksByDistance([], 5)).toEqual([]); + }); + + it("handles a single peak", () => { + const peaks = [makePeak(5, 10, 5)]; + const result = filterPeaksByDistance(peaks, 5); + expect(result).toHaveLength(1); + }); +}); From 8c76357d6ecf311e677d82746960f3cc4333646b Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 16:57:54 -0500 Subject: [PATCH 19/30] refactor(chromatogram): extract buildTraceData/buildLayout/buildConfig into pure builder functions --- .../ChromatogramChart/ChromatogramChart.tsx | 201 ++--------- .../__tests__/dataProcessing.test.ts | 2 +- .../__tests__/plotBuilder.test.ts | 332 ++++++++++++++++++ .../charts/ChromatogramChart/plotBuilder.ts | 264 ++++++++++++++ 4 files changed, 626 insertions(+), 173 deletions(-) create mode 100644 src/components/charts/ChromatogramChart/__tests__/plotBuilder.test.ts create mode 100644 src/components/charts/ChromatogramChart/plotBuilder.ts diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index 6a502c59..bc6245a8 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -1,24 +1,19 @@ import Plotly from "plotly.js-dist"; import React, { useEffect, useMemo, useRef } from "react"; -import { CHART_COLORS } from "../../../utils/colors"; - import { groupOverlappingPeaks, createGroupAnnotations, resolveSelectionAppearance, } from "./annotations"; -import { createBoundaryMarkerTraces } from "./boundaryMarkers"; -import { CHROMATOGRAM_LAYOUT, CHROMATOGRAM_TRACE } from "./constants"; +import { CHROMATOGRAM_TRACE } from "./constants"; import { validateSeriesData, applyBaselineCorrection, - buildHoverExtraContent, - collectPeaksWithBoundaryData, processUserAnnotations, } from "./dataProcessing"; import { detectPeaks } from "./peakDetection"; -import { createRegionOverlayTraces } from "./regionOverlays"; +import { buildTraceData, buildLayout, buildConfig } from "./plotBuilder"; import type { ChromatogramSeries, @@ -243,176 +238,38 @@ const ChromatogramChart: React.FC = ({ const currentRef = plotRef.current; if (!currentRef || series.length === 0) return; - // Build trace data - const plotData: Plotly.Data[] = processedSeries.map((s, index) => { - const traceColor = s.color || CHART_COLORS[index % CHART_COLORS.length]; - const extraContent = buildHoverExtraContent(s.name, s.metadata); - - const trace: Plotly.Data = { - x: s.x, - y: s.y, - type: "scatter" as const, - mode: showMarkers ? "lines+markers" as const : "lines" as const, - name: s.name, - line: { - color: traceColor, - width: CHROMATOGRAM_TRACE.BASE_LINE_WIDTH, - }, - hovertemplate: `%{x:.2f} ${xAxisTitle}
%{y:.2f} ${yAxisTitle}${extraContent}`, - }; - if (showMarkers) { - trace.marker = { size: markerSize, color: traceColor }; - } - return trace; - }); - - // Peak boundary markers - if (boundaryMarkers !== "none") { - const peaksWithData = collectPeaksWithBoundaryData(allDetectedPeaks, processedAnnotations, processedSeries); - if (peaksWithData.length > 0) { - plotData.push(...createBoundaryMarkerTraces(peaksWithData)); - } - } - - // Region overlay traces — thickened colored segments along the signal between - // peak boundaries. Pushed before the hit-area trace so peak interactions still work. - processedAnnotations.forEach((ann) => { - if (ann.regionOverlay && processedSeries[0]) { - plotData.push(...createRegionOverlayTraces([ann], 0, processedSeries[0])); - } - }); - allDetectedPeaks.forEach(({ peaks, seriesIndex }) => { - if (peaks.some((p) => p.regionOverlay) && processedSeries[seriesIndex]) { - plotData.push(...createRegionOverlayTraces(peaks, seriesIndex, processedSeries[seriesIndex])); - } + const plotData = buildTraceData({ + processedSeries, + processedAnnotations, + allDetectedPeaks, + allPeaksForInteraction, + showMarkers, + markerSize, + xAxisTitle, + yAxisTitle, + boundaryMarkers, }); - // Invisible hit-area markers for click / hover on peaks. - // hovertemplate "" suppresses the tooltip entry while still - // allowing plotly_click and plotly_hover to fire for these points. - if (allPeaksForInteraction.length > 0) { - const anyHoverText = allPeaksForInteraction.some((p) => p.peak.hoverText); - const hitAreaTrace: Plotly.Data = { - x: allPeaksForInteraction.map((p) => p.peak.x), - y: allPeaksForInteraction.map((p) => p.peak.y), - type: "scatter" as const, - mode: "markers" as const, - marker: { size: 14, opacity: 0 }, - showlegend: false, - name: "", - customdata: allPeaksForInteraction.map((p) => ({ - id: p.peak.id, - peak: p.peak, - seriesIndex: p.seriesIndex, - seriesName: p.seriesName, - isAutoDetected: p.isAutoDetected, - })) as unknown as Plotly.Datum[], - ...(anyHoverText - ? { - hovertemplate: allPeaksForInteraction.map((p) => - p.peak.hoverText ? `${p.peak.hoverText}` : "" - ), - } - : { hovertemplate: "" }), - }; - plotData.push(hitAreaTrace); - } - - const layout: Partial = { - title: title - ? { - text: title, - font: { size: titleFontSize, family: "Inter, sans-serif", color: theme.textColor }, - } - : undefined, + const layout = buildLayout({ + title, + titleFontSize, + titleTopMargin, width, height, - margin: { - l: CHROMATOGRAM_LAYOUT.MARGIN_LEFT, - r: CHROMATOGRAM_LAYOUT.MARGIN_RIGHT, - b: CHROMATOGRAM_LAYOUT.MARGIN_BOTTOM, - t: title - ? (titleTopMargin ?? CHROMATOGRAM_LAYOUT.MARGIN_TOP_WITH_TITLE) - : CHROMATOGRAM_LAYOUT.MARGIN_TOP_NO_TITLE, - pad: CHROMATOGRAM_LAYOUT.MARGIN_PAD, - }, - paper_bgcolor: theme.paperBg, - plot_bgcolor: theme.plotBg, - font: { family: "Inter, sans-serif" }, - hovermode: showCrosshairs ? "x" as const : "x unified" as const, - dragmode: "zoom" as const, - xaxis: { - title: { - text: xAxisTitle, - font: { size: 14, color: theme.textSecondary, family: "Inter, sans-serif" }, - standoff: 15, - }, - showgrid: showGridX, - gridcolor: theme.gridColor, - linecolor: theme.lineColor, - linewidth: 1, - range: xRange, - autorange: !xRange, - zeroline: false, - tickfont: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, - showspikes: showCrosshairs, - spikemode: "across" as const, - spikesnap: "cursor" as const, - spikecolor: theme.spikeColor, - spikethickness: 1, - spikedash: "dot" as const, - }, - yaxis: { - title: { - text: yAxisTitle, - font: { size: 14, color: theme.textSecondary, family: "Inter, sans-serif" }, - standoff: 10, - }, - showgrid: showGridY, - gridcolor: theme.gridColor, - linecolor: theme.lineColor, - linewidth: 1, - range: yRange, - autorange: !yRange, - zeroline: false, - tickfont: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, - showspikes: showCrosshairs, - spikemode: "across" as const, - spikesnap: "cursor" as const, - spikecolor: theme.spikeColor, - spikethickness: 1, - spikedash: "dot" as const, - }, - legend: { - x: 0.5, - y: -0.15, - xanchor: "center" as const, - yanchor: "top" as const, - orientation: "h" as const, - font: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, - }, - showlegend: showLegend && series.length > 1, - annotations: peakAnnotationsRef.current, - }; + xAxisTitle, + yAxisTitle, + xRange, + yRange, + showLegend, + seriesCount: series.length, + showGridX, + showGridY, + showCrosshairs, + theme, + peakAnnotations: peakAnnotationsRef.current, + }); - const config: Partial = { - responsive: true, - displayModeBar: true, - displaylogo: false, - modeBarButtonsToRemove: [ - "lasso2d", - "select2d", - ...(showExportButton ? [] : ["toImage"] as Plotly.ModeBarDefaultButtons[]), - ] as Plotly.ModeBarDefaultButtons[], - ...(showExportButton && { - toImageButtonOptions: { - format: "png", - filename: "chromatogram", - width, - height, - }, - }), - }; + const config = buildConfig({ showExportButton, width, height }); Plotly.newPlot(currentRef, plotData, layout, config); diff --git a/src/components/charts/ChromatogramChart/__tests__/dataProcessing.test.ts b/src/components/charts/ChromatogramChart/__tests__/dataProcessing.test.ts index 083a7acc..f4c07e97 100644 --- a/src/components/charts/ChromatogramChart/__tests__/dataProcessing.test.ts +++ b/src/components/charts/ChromatogramChart/__tests__/dataProcessing.test.ts @@ -11,7 +11,7 @@ describe("buildHoverExtraContent", () => { }); it("returns seriesName when metadata is undefined", () => { - expect(buildHoverExtraContent("Sample A", undefined)).toBe("Sample A"); + expect(buildHoverExtraContent("Sample A")).toBe("Sample A"); }); it("returns seriesName when metadata is empty object", () => { diff --git a/src/components/charts/ChromatogramChart/__tests__/plotBuilder.test.ts b/src/components/charts/ChromatogramChart/__tests__/plotBuilder.test.ts new file mode 100644 index 00000000..9686042e --- /dev/null +++ b/src/components/charts/ChromatogramChart/__tests__/plotBuilder.test.ts @@ -0,0 +1,332 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +import { CHROMATOGRAM_LAYOUT } from "../constants"; +import { buildConfig, buildLayout, buildTraceData } from "../plotBuilder"; + +import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; + +vi.mock("../dataProcessing", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + collectPeaksWithBoundaryData: vi.fn(() => []), + }; +}); + +vi.mock("../boundaryMarkers", () => ({ + createBoundaryMarkerTraces: vi.fn(() => [{ type: "scatter", name: "__boundary__" }]), +})); + +vi.mock("../regionOverlays", () => ({ + createRegionOverlayTraces: vi.fn(() => [{ type: "scatter", name: "__region__" }]), +})); + +const mockTheme: PlotlyThemeColors = { + paperBg: "transparent", + plotBg: "transparent", + textColor: "rgba(26,26,26,1)", + textSecondary: "rgba(26,26,26,0.6)", + gridColor: "#e1e7ef", + lineColor: "#1a1a1a", + tickColor: "#e1e7ef", + legendColor: "#04263f", + spikeColor: "#64748b", + isDark: false, +}; + +const baseSeries = { x: [1, 2, 3], y: [10, 20, 30], name: "Series A" }; + +// ── buildTraceData ────────────────────────────────────────────────────────── + +describe("buildTraceData", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("returns one trace for a single series with no markers, boundaries, or peaks", () => { + const result = buildTraceData({ + processedSeries: [baseSeries], + processedAnnotations: [], + allDetectedPeaks: [], + allPeaksForInteraction: [], + showMarkers: false, + markerSize: 4, + xAxisTitle: "Time", + yAxisTitle: "Signal", + boundaryMarkers: "none", + }); + + expect(result).toHaveLength(1); + expect(result[0].mode).toBe("lines"); + expect((result[0] as { marker?: unknown }).marker).toBeUndefined(); + }); + + it("sets mode to lines+markers and adds marker property when showMarkers is true", () => { + const result = buildTraceData({ + processedSeries: [baseSeries], + processedAnnotations: [], + allDetectedPeaks: [], + allPeaksForInteraction: [], + showMarkers: true, + markerSize: 6, + xAxisTitle: "Time", + yAxisTitle: "Signal", + boundaryMarkers: "none", + }); + + expect(result[0].mode).toBe("lines+markers"); + expect((result[0] as { marker?: { size: number } }).marker).toMatchObject({ size: 6 }); + }); + + it("appends boundary marker traces when boundaryMarkers is enabled and data exists", async () => { + const { collectPeaksWithBoundaryData } = await import("../dataProcessing"); + vi.mocked(collectPeaksWithBoundaryData).mockReturnValueOnce([ + // non-empty to trigger createBoundaryMarkerTraces + {} as ReturnType[number], + ]); + + const result = buildTraceData({ + processedSeries: [baseSeries], + processedAnnotations: [], + allDetectedPeaks: [{ peaks: [{ x: 2, y: 20 }], seriesIndex: 0 }], + allPeaksForInteraction: [], + showMarkers: false, + markerSize: 4, + xAxisTitle: "Time", + yAxisTitle: "Signal", + boundaryMarkers: "enabled", + }); + + expect(result.some((t) => (t as { name?: string }).name === "__boundary__")).toBe(true); + }); + + it("does not append boundary traces when boundaryMarkers is none", async () => { + const result = buildTraceData({ + processedSeries: [baseSeries], + processedAnnotations: [], + allDetectedPeaks: [{ peaks: [{ x: 2, y: 20 }], seriesIndex: 0 }], + allPeaksForInteraction: [], + showMarkers: false, + markerSize: 4, + xAxisTitle: "Time", + yAxisTitle: "Signal", + boundaryMarkers: "none", + }); + + expect(result.every((t) => (t as { name?: string }).name !== "__boundary__")).toBe(true); + }); + + it("appends a region overlay trace for a user annotation with regionOverlay: true", () => { + const result = buildTraceData({ + processedSeries: [baseSeries], + processedAnnotations: [{ x: 2, y: 20, regionOverlay: true, startX: 1, endX: 3 }], + allDetectedPeaks: [], + allPeaksForInteraction: [], + showMarkers: false, + markerSize: 4, + xAxisTitle: "Time", + yAxisTitle: "Signal", + boundaryMarkers: "none", + }); + + expect(result.some((t) => (t as { name?: string }).name === "__region__")).toBe(true); + }); + + it("appends region overlay traces for auto-detected peaks with regionOverlay: true", () => { + const result = buildTraceData({ + processedSeries: [baseSeries], + processedAnnotations: [], + allDetectedPeaks: [ + { peaks: [{ x: 2, y: 20, regionOverlay: true, _computed: { startIndex: 0, endIndex: 2 } }], seriesIndex: 0 }, + ], + allPeaksForInteraction: [], + showMarkers: false, + markerSize: 4, + xAxisTitle: "Time", + yAxisTitle: "Signal", + boundaryMarkers: "none", + }); + + expect(result.some((t) => (t as { name?: string }).name === "__region__")).toBe(true); + }); + + it("appends a hit-area trace as the last trace when allPeaksForInteraction is populated", () => { + const result = buildTraceData({ + processedSeries: [baseSeries], + processedAnnotations: [], + allDetectedPeaks: [], + allPeaksForInteraction: [ + { peak: { x: 2, y: 20, id: "peak-0-0" }, seriesIndex: 0, seriesName: "Series A", isAutoDetected: true }, + ], + showMarkers: false, + markerSize: 4, + xAxisTitle: "Time", + yAxisTitle: "Signal", + boundaryMarkers: "none", + }); + + expect(result).toHaveLength(2); + const hitArea = result[result.length - 1] as { mode: string; marker: { opacity: number } }; + expect(hitArea.mode).toBe("markers"); + expect(hitArea.marker.opacity).toBe(0); + }); + + it("uses per-point hovertemplate array when any peak has hoverText", () => { + const result = buildTraceData({ + processedSeries: [baseSeries], + processedAnnotations: [], + allDetectedPeaks: [], + allPeaksForInteraction: [ + { peak: { x: 2, y: 20, id: "peak-0-0", hoverText: "Peak A" }, seriesIndex: 0, seriesName: "S", isAutoDetected: true }, + { peak: { x: 3, y: 30, id: "peak-0-1" }, seriesIndex: 0, seriesName: "S", isAutoDetected: true }, + ], + showMarkers: false, + markerSize: 4, + xAxisTitle: "Time", + yAxisTitle: "Signal", + boundaryMarkers: "none", + }); + + const hitArea = result[result.length - 1] as { hovertemplate: unknown }; + expect(Array.isArray(hitArea.hovertemplate)).toBe(true); + const templates = hitArea.hovertemplate as string[]; + expect(templates[0]).toContain("Peak A"); + expect(templates[1]).toBe(""); + }); + + it("uses a single string hovertemplate when no peak has hoverText", () => { + const result = buildTraceData({ + processedSeries: [baseSeries], + processedAnnotations: [], + allDetectedPeaks: [], + allPeaksForInteraction: [ + { peak: { x: 2, y: 20, id: "peak-0-0" }, seriesIndex: 0, seriesName: "S", isAutoDetected: true }, + ], + showMarkers: false, + markerSize: 4, + xAxisTitle: "Time", + yAxisTitle: "Signal", + boundaryMarkers: "none", + }); + + const hitArea = result[result.length - 1] as { hovertemplate: unknown }; + expect(typeof hitArea.hovertemplate).toBe("string"); + expect(hitArea.hovertemplate).toBe(""); + }); +}); + +// ── buildLayout ───────────────────────────────────────────────────────────── + +describe("buildLayout", () => { + const baseParams = { + titleFontSize: 20, + width: 900, + height: 500, + xAxisTitle: "Time", + yAxisTitle: "Signal", + showLegend: true, + seriesCount: 2, + showGridX: true, + showGridY: true, + showCrosshairs: false, + theme: mockTheme, + peakAnnotations: [], + }; + + it("sets title text and font when title is provided", () => { + const layout = buildLayout({ ...baseParams, title: "My Chart" }); + + const title = layout.title as { text: string; font: { size: number } }; + expect(title.text).toBe("My Chart"); + expect(title.font.size).toBe(20); + }); + + it("uses MARGIN_TOP_WITH_TITLE when title is provided", () => { + const layout = buildLayout({ ...baseParams, title: "My Chart" }); + + expect(layout.margin?.t).toBe(CHROMATOGRAM_LAYOUT.MARGIN_TOP_WITH_TITLE); + }); + + it("uses provided titleTopMargin when set, overriding the constant", () => { + const layout = buildLayout({ ...baseParams, title: "My Chart", titleTopMargin: 80 }); + + expect(layout.margin?.t).toBe(80); + }); + + it("sets title to undefined when no title is given", () => { + const layout = buildLayout({ ...baseParams }); + + expect(layout.title).toBeUndefined(); + }); + + it("uses MARGIN_TOP_NO_TITLE when no title is provided", () => { + const layout = buildLayout({ ...baseParams }); + + expect(layout.margin?.t).toBe(CHROMATOGRAM_LAYOUT.MARGIN_TOP_NO_TITLE); + }); + + it("sets hovermode to x and enables spikes when showCrosshairs is true", () => { + const layout = buildLayout({ ...baseParams, showCrosshairs: true }); + + expect(layout.hovermode).toBe("x"); + expect(layout.xaxis?.showspikes).toBe(true); + expect(layout.yaxis?.showspikes).toBe(true); + }); + + it("sets hovermode to x unified and disables spikes when showCrosshairs is false", () => { + const layout = buildLayout({ ...baseParams, showCrosshairs: false }); + + expect(layout.hovermode).toBe("x unified"); + expect(layout.xaxis?.showspikes).toBe(false); + expect(layout.yaxis?.showspikes).toBe(false); + }); + + it("sets xaxis range and autorange false when xRange is provided", () => { + const layout = buildLayout({ ...baseParams, xRange: [0, 10] }); + + expect(layout.xaxis?.range).toEqual([0, 10]); + expect(layout.xaxis?.autorange).toBe(false); + }); + + it("sets autorange to true when xRange is absent", () => { + const layout = buildLayout({ ...baseParams }); + + expect(layout.xaxis?.autorange).toBe(true); + }); + + it("shows legend when showLegend is true and seriesCount > 1", () => { + const layout = buildLayout({ ...baseParams, showLegend: true, seriesCount: 2 }); + + expect(layout.showlegend).toBe(true); + }); + + it("hides legend when seriesCount is 1 even if showLegend is true", () => { + const layout = buildLayout({ ...baseParams, showLegend: true, seriesCount: 1 }); + + expect(layout.showlegend).toBe(false); + }); + + it("hides xaxis grid when showGridX is false", () => { + const layout = buildLayout({ ...baseParams, showGridX: false }); + + expect(layout.xaxis?.showgrid).toBe(false); + }); +}); + +// ── buildConfig ───────────────────────────────────────────────────────────── + +describe("buildConfig", () => { + it("does not include toImage in removed buttons and adds toImageButtonOptions when showExportButton is true", () => { + const config = buildConfig({ showExportButton: true, width: 900, height: 500 }); + + expect(config.modeBarButtonsToRemove).not.toContain("toImage"); + expect(config.toImageButtonOptions).toBeDefined(); + }); + + it("includes toImage in removed buttons and omits toImageButtonOptions when showExportButton is false", () => { + const config = buildConfig({ showExportButton: false, width: 900, height: 500 }); + + expect(config.modeBarButtonsToRemove).toContain("toImage"); + expect(config.toImageButtonOptions).toBeUndefined(); + }); +}); diff --git a/src/components/charts/ChromatogramChart/plotBuilder.ts b/src/components/charts/ChromatogramChart/plotBuilder.ts new file mode 100644 index 00000000..e11ff119 --- /dev/null +++ b/src/components/charts/ChromatogramChart/plotBuilder.ts @@ -0,0 +1,264 @@ +import { CHART_COLORS } from "../../../utils/colors"; + +import { createBoundaryMarkerTraces } from "./boundaryMarkers"; +import { CHROMATOGRAM_LAYOUT, CHROMATOGRAM_TRACE } from "./constants"; +import { buildHoverExtraContent, collectPeaksWithBoundaryData } from "./dataProcessing"; +import { createRegionOverlayTraces } from "./regionOverlays"; + +import type { ChromatogramSeries, PeakAnnotation, BoundaryMarkerStyle } from "./types"; +import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; +import type Plotly from "plotly.js-dist"; + +type PeakForInteraction = { + peak: PeakAnnotation & { id: string }; + seriesIndex: number; + seriesName: string; + isAutoDetected: boolean; +}; + +type BuildTraceDataParams = { + processedSeries: ChromatogramSeries[]; + processedAnnotations: PeakAnnotation[]; + allDetectedPeaks: { peaks: PeakAnnotation[]; seriesIndex: number }[]; + allPeaksForInteraction: PeakForInteraction[]; + showMarkers: boolean; + markerSize: number; + xAxisTitle: string; + yAxisTitle: string; + boundaryMarkers: BoundaryMarkerStyle; +}; + +type BuildLayoutParams = { + title?: string; + titleFontSize: number; + titleTopMargin?: number; + width: number; + height: number; + xAxisTitle: string; + yAxisTitle: string; + xRange?: [number, number]; + yRange?: [number, number]; + showLegend: boolean; + seriesCount: number; + showGridX: boolean; + showGridY: boolean; + showCrosshairs: boolean; + theme: PlotlyThemeColors; + peakAnnotations: Partial[]; +}; + +type BuildConfigParams = { + showExportButton: boolean; + width: number; + height: number; +}; + +export type { PeakForInteraction, BuildTraceDataParams, BuildLayoutParams, BuildConfigParams }; + +export function buildTraceData(params: BuildTraceDataParams): Plotly.Data[] { + const { + processedSeries, + processedAnnotations, + allDetectedPeaks, + allPeaksForInteraction, + showMarkers, + markerSize, + xAxisTitle, + yAxisTitle, + boundaryMarkers, + } = params; + + const plotData: Plotly.Data[] = processedSeries.map((s, index) => { + const traceColor = s.color || CHART_COLORS[index % CHART_COLORS.length]; + const extraContent = buildHoverExtraContent(s.name, s.metadata); + + const trace: Plotly.Data = { + x: s.x, + y: s.y, + type: "scatter" as const, + mode: showMarkers ? ("lines+markers" as const) : ("lines" as const), + name: s.name, + line: { + color: traceColor, + width: CHROMATOGRAM_TRACE.BASE_LINE_WIDTH, + }, + hovertemplate: `%{x:.2f} ${xAxisTitle}
%{y:.2f} ${yAxisTitle}${extraContent}`, + }; + if (showMarkers) { + trace.marker = { size: markerSize, color: traceColor }; + } + return trace; + }); + + if (boundaryMarkers !== "none") { + const peaksWithData = collectPeaksWithBoundaryData( + allDetectedPeaks, + processedAnnotations, + processedSeries + ); + if (peaksWithData.length > 0) { + plotData.push(...createBoundaryMarkerTraces(peaksWithData)); + } + } + + processedAnnotations.forEach((ann) => { + if (ann.regionOverlay && processedSeries[0]) { + plotData.push(...createRegionOverlayTraces([ann], 0, processedSeries[0])); + } + }); + allDetectedPeaks.forEach(({ peaks, seriesIndex }) => { + if (peaks.some((p) => p.regionOverlay) && processedSeries[seriesIndex]) { + plotData.push(...createRegionOverlayTraces(peaks, seriesIndex, processedSeries[seriesIndex])); + } + }); + + if (allPeaksForInteraction.length > 0) { + const anyHoverText = allPeaksForInteraction.some((p) => p.peak.hoverText); + const hitAreaTrace: Plotly.Data = { + x: allPeaksForInteraction.map((p) => p.peak.x), + y: allPeaksForInteraction.map((p) => p.peak.y), + type: "scatter" as const, + mode: "markers" as const, + marker: { size: 14, opacity: 0 }, + showlegend: false, + name: "", + customdata: allPeaksForInteraction.map((p) => ({ + id: p.peak.id, + peak: p.peak, + seriesIndex: p.seriesIndex, + seriesName: p.seriesName, + isAutoDetected: p.isAutoDetected, + })) as unknown as Plotly.Datum[], + ...(anyHoverText + ? { + hovertemplate: allPeaksForInteraction.map((p) => + p.peak.hoverText ? `${p.peak.hoverText}` : "" + ), + } + : { hovertemplate: "" }), + }; + plotData.push(hitAreaTrace); + } + + return plotData; +} + +export function buildLayout(params: BuildLayoutParams): Partial { + const { + title, + titleFontSize, + titleTopMargin, + width, + height, + xAxisTitle, + yAxisTitle, + xRange, + yRange, + showLegend, + seriesCount, + showGridX, + showGridY, + showCrosshairs, + theme, + peakAnnotations, + } = params; + + return { + title: title + ? { + text: title, + font: { size: titleFontSize, family: "Inter, sans-serif", color: theme.textColor }, + } + : undefined, + width, + height, + margin: { + l: CHROMATOGRAM_LAYOUT.MARGIN_LEFT, + r: CHROMATOGRAM_LAYOUT.MARGIN_RIGHT, + b: CHROMATOGRAM_LAYOUT.MARGIN_BOTTOM, + t: title + ? (titleTopMargin ?? CHROMATOGRAM_LAYOUT.MARGIN_TOP_WITH_TITLE) + : CHROMATOGRAM_LAYOUT.MARGIN_TOP_NO_TITLE, + pad: CHROMATOGRAM_LAYOUT.MARGIN_PAD, + }, + paper_bgcolor: theme.paperBg, + plot_bgcolor: theme.plotBg, + font: { family: "Inter, sans-serif" }, + hovermode: showCrosshairs ? ("x" as const) : ("x unified" as const), + dragmode: "zoom" as const, + xaxis: { + title: { + text: xAxisTitle, + font: { size: 14, color: theme.textSecondary, family: "Inter, sans-serif" }, + standoff: 15, + }, + showgrid: showGridX, + gridcolor: theme.gridColor, + linecolor: theme.lineColor, + linewidth: 1, + range: xRange, + autorange: !xRange, + zeroline: false, + tickfont: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, + showspikes: showCrosshairs, + spikemode: "across" as const, + spikesnap: "cursor" as const, + spikecolor: theme.spikeColor, + spikethickness: 1, + spikedash: "dot" as const, + }, + yaxis: { + title: { + text: yAxisTitle, + font: { size: 14, color: theme.textSecondary, family: "Inter, sans-serif" }, + standoff: 10, + }, + showgrid: showGridY, + gridcolor: theme.gridColor, + linecolor: theme.lineColor, + linewidth: 1, + range: yRange, + autorange: !yRange, + zeroline: false, + tickfont: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, + showspikes: showCrosshairs, + spikemode: "across" as const, + spikesnap: "cursor" as const, + spikecolor: theme.spikeColor, + spikethickness: 1, + spikedash: "dot" as const, + }, + legend: { + x: 0.5, + y: -0.15, + xanchor: "center" as const, + yanchor: "top" as const, + orientation: "h" as const, + font: { size: 12, color: theme.textColor, family: "Inter, sans-serif" }, + }, + showlegend: showLegend && seriesCount > 1, + annotations: peakAnnotations, + }; +} + +export function buildConfig(params: BuildConfigParams): Partial { + const { showExportButton, width, height } = params; + return { + responsive: true, + displayModeBar: true, + displaylogo: false, + modeBarButtonsToRemove: [ + "lasso2d", + "select2d", + ...(showExportButton ? [] : (["toImage"] as Plotly.ModeBarDefaultButtons[])), + ] as Plotly.ModeBarDefaultButtons[], + ...(showExportButton && { + toImageButtonOptions: { + format: "png", + filename: "chromatogram", + width, + height, + }, + }), + }; +} From 9ad82b087e05e3691a47fce7bb34b470e541a367 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 17:12:07 -0500 Subject: [PATCH 20/30] Removes raceHoverThickening story --- .../ChromatogramChart.stories.tsx | 79 ------------------- 1 file changed, 79 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index e08de037..f086edad 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -436,85 +436,6 @@ export const WithRegionOverlay: Story = { }, }; -/** - * Directly exercises the plotly_hover / plotly_unhover event handlers registered in - * ChromatogramChart. userEvent.hover cannot reliably land on a data point in headless - * Playwright, so we emit the Plotly events directly on the graph element — the same - * path Plotly's own hit-detection uses internally. - * - * Test cases: - * 1. Hover trace 0 with no prior thickening → thicken trace 0 (null→0 branch) - * 2. Hover trace 1 → restore trace 0, thicken trace 1 (0→1 branch) - * 3. Hover trace 1 again → no-op (same-trace guard) - * 4. Hover a peak hit-area marker (curveNumber ≥ series count) → skip thickening - * 5. Unhover with a thickened series → restore + clear ref - * 6. Unhover with nothing thickened → no restyle call needed - */ -export const TraceHoverThickening: Story = { - args: { - series: multiInjectionData, - title: "Trace Hover Thickening", - annotations: selectableAnnotations, - onPeakHover: () => {}, - }, - play: async ({ canvasElement, step }) => { - await step("Chart renders with multiple traces", async () => { - expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBeGreaterThan(1); - }); - - await step("Hover / unhover events exercise trace thickening branches", async () => { - const plotEl = canvasElement.querySelector(".js-plotly-plot") as any; - if (!plotEl?.emit) return; - - // multiInjectionData has 3 series → processedSeries.length = 3 - // curveNumbers 0-2 are series traces; higher numbers are hit-area / marker traces - - // 1. First hover: no prior thickening (thickenedSeriesRef === null → false branch of line 442) - plotEl.emit("plotly_hover", { points: [{ curveNumber: 0, pointNumber: 30, customdata: null }] }); - - // 2. Hover different trace: restore trace 0, thicken trace 1 (true branch of line 442) - plotEl.emit("plotly_hover", { points: [{ curveNumber: 1, pointNumber: 30, customdata: null }] }); - - // 3. Hover same trace again: same-trace guard (false branch of line 441) - plotEl.emit("plotly_hover", { points: [{ curveNumber: 1, pointNumber: 40, customdata: null }] }); - - // 4. Hover a non-series trace (peak hit-area): skips thickening (false branch of line 439) - plotEl.emit("plotly_hover", { points: [{ curveNumber: 99, pointNumber: 0, customdata: null }] }); - - // 5. Unhover with a thickened series still active → restore + null out ref - plotEl.emit("plotly_unhover", {}); - - // 6. Unhover again with nothing thickened → executes line 465, skips 466-469 - plotEl.emit("plotly_unhover", {}); - }); - - await step("Peak hit-area hover fires onPeakHover callback", async () => { - const plotEl = canvasElement.querySelector(".js-plotly-plot") as any; - if (!plotEl?.emit) return; - - const mockPeakEvent: PeakSelectEvent = { - id: "caffeine", - peak: selectableAnnotations[0], - seriesIndex: 0, - seriesName: "Sample A", - isAutoDetected: false, - }; - plotEl.emit("plotly_hover", { points: [{ curveNumber: 99, customdata: mockPeakEvent }] }); - plotEl.emit("plotly_unhover", {}); - }); - }, - parameters: { - docs: { - description: { - story: - "Exercises the internal plotly_hover / plotly_unhover event handlers: trace thickening on hover, restoration when moving between traces, and cleanup on unhover.", - }, - }, - }, -}; - /** * Inline annotation style: labels float directly above the trace at the peak Y value * with no arrow. Cleaner for dense chromatograms where arrows create visual noise. From f34fe237c8d203e1daf0d38e7cccb0286daa113c Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 17:14:41 -0500 Subject: [PATCH 21/30] Makes annotation for peak region overlays inline --- .../charts/ChromatogramChart/ChromatogramChart.stories.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index f086edad..e9399471 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -378,6 +378,7 @@ export const WithRegionOverlay: Story = { args: { series: [{ ...singleInjectionData, name: "Sample A" }], title: "Peak Region Overlays", + annotationStyle: "inline", annotations: [ { id: "peak-pass", @@ -430,7 +431,7 @@ export const WithRegionOverlay: Story = { docs: { description: { story: - "Each peak with `regionOverlay: true` paints a thickened colored line segment along the underlying trace between its `startX` and `endX`. Uses `peak.color` when set; falls back to the series color.", + "Each peak with `regionOverlay: true` paints a thickened colored line segment along the underlying trace between its `startX` and `endX`. Uses `peak.color` when set; falls back to the series color. Labels use `annotationStyle=\"inline\"` — floating directly above the trace with no arrow.", }, }, }, From 71e0a43c89c59fc769701957d250158ad257b243 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 17:50:34 -0500 Subject: [PATCH 22/30] Extract hover/unhover handlers into testable factory functions in plotBuilder --- .../ChromatogramChart/ChromatogramChart.tsx | 40 +---- .../__tests__/plotBuilder.test.ts | 148 +++++++++++++++++- .../charts/ChromatogramChart/plotBuilder.ts | 46 +++++- 3 files changed, 193 insertions(+), 41 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index bc6245a8..666735c5 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -6,14 +6,13 @@ import { createGroupAnnotations, resolveSelectionAppearance, } from "./annotations"; -import { CHROMATOGRAM_TRACE } from "./constants"; import { validateSeriesData, applyBaselineCorrection, processUserAnnotations, } from "./dataProcessing"; import { detectPeaks } from "./peakDetection"; -import { buildTraceData, buildLayout, buildConfig } from "./plotBuilder"; +import { buildTraceData, buildLayout, buildConfig, createHoverHandler, createUnhoverHandler } from "./plotBuilder"; import type { ChromatogramSeries, @@ -284,47 +283,14 @@ const ChromatogramChart: React.FC = ({ } ); - // ── Event: trace hover / peak hover ─────────────────────────────────── - // Trace thickening fires on hover over any point on a series trace (matches - // SST ChromatogramPanel behaviour). The onPeakHover callback fires only when - // the cursor is over an invisible peak hit-area marker. (currentRef as unknown as Plotly.PlotlyHTMLElement).on( "plotly_hover", - (eventData: Plotly.PlotHoverEvent) => { - // General trace thickening — first hovered point that belongs to a series trace - const pt = eventData.points[0]; - if (pt && pt.curveNumber < processedSeries.length) { - const targetIdx = pt.curveNumber; - if (thickenedSeriesRef.current !== targetIdx) { - if (thickenedSeriesRef.current !== null) { - Plotly.restyle(currentRef, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH } as Plotly.Data, [thickenedSeriesRef.current]); - } - Plotly.restyle( - currentRef, - { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH * resolvedAppearance.hoverLineWidthMultiplier } as Plotly.Data, - [targetIdx] - ); - thickenedSeriesRef.current = targetIdx; - } - } - - // Peak-specific callback — only when near an invisible hit-area marker - const peakPoint = eventData.points.find((p) => p.customdata != null); - if (peakPoint) { - onPeakHoverRef.current?.(peakPoint.customdata as unknown as PeakSelectEvent); - } - } + createHoverHandler(currentRef, processedSeries.length, thickenedSeriesRef, onPeakHoverRef, resolvedAppearance.hoverLineWidthMultiplier) ); (currentRef as unknown as Plotly.PlotlyHTMLElement).on( "plotly_unhover", - () => { - onPeakHoverRef.current?.(null); - if (thickenedSeriesRef.current !== null) { - Plotly.restyle(currentRef, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH } as Plotly.Data, [thickenedSeriesRef.current]); - thickenedSeriesRef.current = null; - } - } + createUnhoverHandler(currentRef, thickenedSeriesRef, onPeakHoverRef) ); return () => { diff --git a/src/components/charts/ChromatogramChart/__tests__/plotBuilder.test.ts b/src/components/charts/ChromatogramChart/__tests__/plotBuilder.test.ts index 9686042e..4dfb8f5d 100644 --- a/src/components/charts/ChromatogramChart/__tests__/plotBuilder.test.ts +++ b/src/components/charts/ChromatogramChart/__tests__/plotBuilder.test.ts @@ -1,10 +1,14 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; -import { CHROMATOGRAM_LAYOUT } from "../constants"; -import { buildConfig, buildLayout, buildTraceData } from "../plotBuilder"; +import { CHROMATOGRAM_LAYOUT, CHROMATOGRAM_TRACE } from "../constants"; +import { buildConfig, buildLayout, buildTraceData, createHoverHandler, createUnhoverHandler } from "../plotBuilder"; import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; +vi.mock("plotly.js-dist", () => ({ + default: { restyle: vi.fn() }, +})); + vi.mock("../dataProcessing", async (importOriginal) => { const actual = await importOriginal(); return { @@ -330,3 +334,143 @@ describe("buildConfig", () => { expect(config.toImageButtonOptions).toBeUndefined(); }); }); + +// ── createHoverHandler ────────────────────────────────────────────────────── + +describe("createHoverHandler", () => { + let mockRestyle: ReturnType; + let domElement: HTMLElement; + + beforeEach(async () => { + vi.clearAllMocks(); + const Plotly = (await import("plotly.js-dist")).default; + mockRestyle = vi.mocked(Plotly.restyle); + domElement = document.createElement("div"); + }); + + function makeEvent(curveNumber: number, customdata?: unknown) { + return { + points: [{ curveNumber, customdata }], + } as unknown as import("plotly.js-dist").PlotHoverEvent; + } + + it("thickens the hovered series trace and sets the ref when nothing was thickened before", () => { + const thickenedRef = { current: null as number | null }; + const onPeakHoverRef = { current: undefined as ((e: unknown) => void) | undefined }; + const handler = createHoverHandler(domElement, 2, thickenedRef, onPeakHoverRef, 2); + + handler(makeEvent(0)); + + expect(mockRestyle).toHaveBeenCalledTimes(1); + expect(mockRestyle).toHaveBeenCalledWith( + domElement, + { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH * 2 }, + [0] + ); + expect(thickenedRef.current).toBe(0); + }); + + it("restores the previous trace and thickens the new one when a different trace is hovered", () => { + const thickenedRef = { current: 0 as number | null }; + const onPeakHoverRef = { current: undefined as ((e: unknown) => void) | undefined }; + const handler = createHoverHandler(domElement, 2, thickenedRef, onPeakHoverRef, 2); + + handler(makeEvent(1)); + + expect(mockRestyle).toHaveBeenCalledTimes(2); + expect(mockRestyle).toHaveBeenNthCalledWith(1, domElement, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH }, [0]); + expect(mockRestyle).toHaveBeenNthCalledWith(2, domElement, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH * 2 }, [1]); + expect(thickenedRef.current).toBe(1); + }); + + it("does nothing when the hovered trace is already thickened", () => { + const thickenedRef = { current: 0 as number | null }; + const onPeakHoverRef = { current: undefined as ((e: unknown) => void) | undefined }; + const handler = createHoverHandler(domElement, 2, thickenedRef, onPeakHoverRef, 2); + + handler(makeEvent(0)); + + expect(mockRestyle).not.toHaveBeenCalled(); + }); + + it("does not call restyle when curveNumber is a non-series trace", () => { + const thickenedRef = { current: null as number | null }; + const onPeakHoverRef = { current: undefined as ((e: unknown) => void) | undefined }; + const handler = createHoverHandler(domElement, 2, thickenedRef, onPeakHoverRef, 2); + + handler(makeEvent(5)); // curveNumber >= processedSeriesLength + + expect(mockRestyle).not.toHaveBeenCalled(); + }); + + it("calls onPeakHoverRef when a point has customdata", () => { + const thickenedRef = { current: null as number | null }; + const callback = vi.fn(); + const onPeakHoverRef = { current: callback }; + const handler = createHoverHandler(domElement, 2, thickenedRef, onPeakHoverRef, 2); + const fakeEvent = { + points: [{ curveNumber: 5, customdata: { id: "peak-0-0" } }], + } as unknown as import("plotly.js-dist").PlotHoverEvent; + + handler(fakeEvent); + + expect(callback).toHaveBeenCalledWith({ id: "peak-0-0" }); + }); + + it("does not throw when eventData.points is empty", () => { + const thickenedRef = { current: null as number | null }; + const onPeakHoverRef = { current: undefined as ((e: unknown) => void) | undefined }; + const handler = createHoverHandler(domElement, 2, thickenedRef, onPeakHoverRef, 2); + const emptyEvent = { points: [] } as unknown as import("plotly.js-dist").PlotHoverEvent; + + expect(() => handler(emptyEvent)).not.toThrow(); + expect(mockRestyle).not.toHaveBeenCalled(); + }); +}); + +// ── createUnhoverHandler ──────────────────────────────────────────────────── + +describe("createUnhoverHandler", () => { + let mockRestyle: ReturnType; + let domElement: HTMLElement; + + beforeEach(async () => { + vi.clearAllMocks(); + const Plotly = (await import("plotly.js-dist")).default; + mockRestyle = vi.mocked(Plotly.restyle); + domElement = document.createElement("div"); + }); + + it("restores line width, clears the ref, and calls onPeakHoverRef with null when a trace was thickened", () => { + const thickenedRef = { current: 1 as number | null }; + const callback = vi.fn(); + const onPeakHoverRef = { current: callback }; + const handler = createUnhoverHandler(domElement, thickenedRef, onPeakHoverRef); + + handler(); + + expect(callback).toHaveBeenCalledWith(null); + expect(mockRestyle).toHaveBeenCalledWith(domElement, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH }, [1]); + expect(thickenedRef.current).toBeNull(); + }); + + it("does not call restyle when no trace was thickened, but still calls onPeakHoverRef", () => { + const thickenedRef = { current: null as number | null }; + const callback = vi.fn(); + const onPeakHoverRef = { current: callback }; + const handler = createUnhoverHandler(domElement, thickenedRef, onPeakHoverRef); + + handler(); + + expect(mockRestyle).not.toHaveBeenCalled(); + expect(callback).toHaveBeenCalledWith(null); + }); + + it("does not throw when onPeakHoverRef.current is undefined", () => { + const thickenedRef = { current: null as number | null }; + const onPeakHoverRef = { current: undefined as ((e: unknown) => void) | undefined }; + const handler = createUnhoverHandler(domElement, thickenedRef, onPeakHoverRef); + + expect(() => handler()).not.toThrow(); + }); +}); diff --git a/src/components/charts/ChromatogramChart/plotBuilder.ts b/src/components/charts/ChromatogramChart/plotBuilder.ts index e11ff119..ce07d44c 100644 --- a/src/components/charts/ChromatogramChart/plotBuilder.ts +++ b/src/components/charts/ChromatogramChart/plotBuilder.ts @@ -5,9 +5,9 @@ import { CHROMATOGRAM_LAYOUT, CHROMATOGRAM_TRACE } from "./constants"; import { buildHoverExtraContent, collectPeaksWithBoundaryData } from "./dataProcessing"; import { createRegionOverlayTraces } from "./regionOverlays"; -import type { ChromatogramSeries, PeakAnnotation, BoundaryMarkerStyle } from "./types"; +import type { ChromatogramSeries, PeakAnnotation, BoundaryMarkerStyle, PeakSelectEvent } from "./types"; import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; -import type Plotly from "plotly.js-dist"; +import Plotly from "plotly.js-dist"; type PeakForInteraction = { peak: PeakAnnotation & { id: string }; @@ -262,3 +262,45 @@ export function buildConfig(params: BuildConfigParams): Partial { }), }; } + +type MutableRef = { current: T }; + +export function createHoverHandler( + domElement: HTMLElement, + processedSeriesLength: number, + thickenedSeriesRef: MutableRef, + onPeakHoverRef: MutableRef<((event: PeakSelectEvent | null) => void) | undefined>, + hoverLineWidthMultiplier: number +): (eventData: Plotly.PlotHoverEvent) => void { + return (eventData) => { + const pt = eventData.points[0]; + if (pt && pt.curveNumber < processedSeriesLength) { + const targetIdx = pt.curveNumber; + if (thickenedSeriesRef.current !== targetIdx) { + if (thickenedSeriesRef.current !== null) { + Plotly.restyle(domElement, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH } as Plotly.Data, [thickenedSeriesRef.current]); + } + Plotly.restyle(domElement, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH * hoverLineWidthMultiplier } as Plotly.Data, [targetIdx]); + thickenedSeriesRef.current = targetIdx; + } + } + const peakPoint = eventData.points.find((p) => p.customdata != null); + if (peakPoint) { + onPeakHoverRef.current?.(peakPoint.customdata as unknown as PeakSelectEvent); + } + }; +} + +export function createUnhoverHandler( + domElement: HTMLElement, + thickenedSeriesRef: MutableRef, + onPeakHoverRef: MutableRef<((event: PeakSelectEvent | null) => void) | undefined> +): () => void { + return () => { + onPeakHoverRef.current?.(null); + if (thickenedSeriesRef.current !== null) { + Plotly.restyle(domElement, { "line.width": CHROMATOGRAM_TRACE.BASE_LINE_WIDTH } as Plotly.Data, [thickenedSeriesRef.current]); + thickenedSeriesRef.current = null; + } + }; +} From 4503b159837ce0b5205159bb97e84739d51b1280 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 13 May 2026 18:18:31 -0500 Subject: [PATCH 23/30] Lint fixes --- src/components/charts/ChromatogramChart/plotBuilder.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/components/charts/ChromatogramChart/plotBuilder.ts b/src/components/charts/ChromatogramChart/plotBuilder.ts index ce07d44c..1152cdeb 100644 --- a/src/components/charts/ChromatogramChart/plotBuilder.ts +++ b/src/components/charts/ChromatogramChart/plotBuilder.ts @@ -1,3 +1,5 @@ +import Plotly from "plotly.js-dist"; + import { CHART_COLORS } from "../../../utils/colors"; import { createBoundaryMarkerTraces } from "./boundaryMarkers"; @@ -7,7 +9,6 @@ import { createRegionOverlayTraces } from "./regionOverlays"; import type { ChromatogramSeries, PeakAnnotation, BoundaryMarkerStyle, PeakSelectEvent } from "./types"; import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; -import Plotly from "plotly.js-dist"; type PeakForInteraction = { peak: PeakAnnotation & { id: string }; From cc28aae9dbfd47822453761424c6e8beb4298f24 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Wed, 20 May 2026 09:53:08 -0500 Subject: [PATCH 24/30] lint fix --- .../composed/PlateMapEditor/PlateMapEditor.stories.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/components/composed/PlateMapEditor/PlateMapEditor.stories.tsx b/src/components/composed/PlateMapEditor/PlateMapEditor.stories.tsx index 2200a232..e51d4df9 100644 --- a/src/components/composed/PlateMapEditor/PlateMapEditor.stories.tsx +++ b/src/components/composed/PlateMapEditor/PlateMapEditor.stories.tsx @@ -337,11 +337,11 @@ function PlateMapEditorDefault({ format = "96" }: { format?: PlateFormat } = {}) const [templateId, setTemplateId] = React.useState(); const handleImportCsv = React.useCallback((file: File, triage?: PlateMapCsvTriage) => { - // eslint-disable-next-line no-console + console.log("[story] import CSV", file.name, triage?.plates.length ?? 0, "plate(s)"); }, []); const handleExportCsv = React.useCallback(() => { - // eslint-disable-next-line no-console + console.log("[story] export CSV", state.values.size, "wells"); }, [state.values]); const handleClearTemplate = React.useCallback(() => { @@ -403,7 +403,7 @@ function PlateMapEditorDefault({ format = "96" }: { format?: PlateFormat } = {}) { - // eslint-disable-next-line no-console + console.log("[story] Query LIMS", ids); }} /> @@ -487,7 +487,7 @@ function PlateMapEditorDragDrop() { { - // eslint-disable-next-line no-console + console.log("[story] Query LIMS", ids); }} /> From f2122affae53f19292c66d576b5e8fb3f1d7b84a Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Fri, 22 May 2026 16:18:04 -0500 Subject: [PATCH 25/30] Groups ChromatogramChart stories together --- .../charts/ChromatogramChart/ChromatogramChart.stories.tsx | 2 +- .../StackedChromatogramChart.stories.tsx | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index e9399471..28738c27 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -112,7 +112,7 @@ const selectableAnnotations: PeakAnnotation[] = [ ]; const meta: Meta = { - title: "Charts/ChromatogramChart", + title: "Charts/ChromatogramChart/Default", component: ChromatogramChart, parameters: { layout: "centered", diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx index 3835c1a0..0effc052 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -84,7 +84,7 @@ const stackSeriesData: StackedChromatogramChartProps["series"] = [ ]; const meta: Meta = { - title: "Charts/StackedChromatogramChart", + title: "Charts/ChromatogramChart/Stacked", component: StackedChromatogramChart, parameters: { layout: "centered", From 02cf1fe1d51f5d1442645963e09d0cb95b306947 Mon Sep 17 00:00:00 2001 From: Sutee Dee <262220779+sdee-tetra@users.noreply.github.com> Date: Fri, 22 May 2026 16:36:16 -0500 Subject: [PATCH 26/30] Adds test to improve coverage --- .../ChromatogramChart.stories.tsx | 92 +++++++++++++++++++ .../__tests__/annotations.test.ts | 47 ++++++++++ .../StackedChromatogramChart.stories.tsx | 41 +++++++++ 3 files changed, 180 insertions(+) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index 28738c27..aa07463a 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -437,6 +437,98 @@ export const WithRegionOverlay: Story = { }, }; +/** + * Selection toggled programmatically via a Clear button — exercises the + * selection-update effect (Plotly.relayout) without depending on Plotly's + * internal click handler firing under the test runner. + */ +export const SelectionTogglesViaButton: StoryObj = { + render: (args) => { + const [selectedPeakIds, setSelectedPeakIds] = useState(["caffeine"]); + return ( +
+ +
+ {" "} + +
+
+ ); + }, + args: { + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "Selection Update Effect", + annotations: selectableAnnotations, + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart mounts with initial selection", async () => { + expect(canvas.getByText("Selection Update Effect")).toBeInTheDocument(); + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Switching selection triggers the relayout effect", async () => { + const setBtn = canvas.getByTestId("set-selection"); + await userEvent.click(setBtn); + }); + + await step("Clearing selection triggers the relayout effect again", async () => { + const clearBtn = canvas.getByTestId("clear-selection"); + await userEvent.click(clearBtn); + }); + }, + parameters: { + docs: { + description: { + story: + "Verifies that changing `selectedPeakIds` after the chart has mounted re-runs the lightweight selection-update effect (which calls `Plotly.relayout` rather than rebuilding the chart).", + }, + }, + }, +}; + +/** + * Empty series — the component should mount cleanly and not attempt to call + * Plotly.newPlot. Exercises the `series.length === 0` early return in the main + * effect. + */ +export const EmptySeries: Story = { + args: { + series: [], + title: "Empty Series", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Title renders even with empty series", async () => { + // Title comes from the chart wrapper outside Plotly; container exists + // but no plot is initialized. + const container = canvasElement.querySelector(".chromatogram-chart-container"); + expect(container).toBeInTheDocument(); + // No Plotly plot should be initialized when there are no series. + expect(canvasElement.querySelector(".js-plotly-plot")).not.toBeInTheDocument(); + // Avoid unused-var lint + expect(canvas).toBeTruthy(); + }); + }, + parameters: { + docs: { + description: { + story: + "When `series` is empty the component mounts the container but skips Plotly initialization — useful when data is still loading.", + }, + }, + }, +}; + /** * Inline annotation style: labels float directly above the trace at the peak Y value * with no arrow. Cleaner for dense chromatograms where arrows create visual noise. diff --git a/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts b/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts index 099b7623..9b029c0b 100644 --- a/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts +++ b/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts @@ -47,6 +47,21 @@ describe("resolveSelectionAppearance", () => { expect(result.unselected.opacity).toBe(0.2); expect(result.hoverLineWidthMultiplier).toBe(2); }); + + it("falls back to defaults when appearance has no selected field", () => { + // exercises the ?? fallback branches for selected.{borderColor,backgroundColor,bold} + const result = resolveSelectionAppearance({ unselected: { opacity: 0.3 } }); + expect(result.selected.borderColor).toBe("#3b82f6"); + expect(result.selected.backgroundColor).toBe("#dbeafe"); + expect(result.selected.bold).toBe(true); + expect(result.unselected.opacity).toBe(0.3); + }); + + it("falls back to default unselected opacity when appearance has no unselected field", () => { + const result = resolveSelectionAppearance({ hoverLineWidthMultiplier: 3 }); + expect(result.unselected.opacity).toBe(0.4); + expect(result.hoverLineWidthMultiplier).toBe(3); + }); }); describe("groupOverlappingPeaks", () => { @@ -202,6 +217,38 @@ describe("createPeakAnnotation", () => { }); expect(ann.opacity).toBeUndefined(); }); + + it("inline: applies selected color and bold wrap when peak is selected", () => { + // exercises the isSelected=true branch inside createInlineAnnotation + const peak = { x: 5, y: 100, text: "Peak A", id: "peak-0-0" }; + const ann = createPeakAnnotation(peak, 0, slot, { + annotationStyle: "inline", + selectedPeakIds: ["peak-0-0"], + anySelected: true, + }); + expect(ann.text).toBe("Peak A"); + const font = ann.font as { color: string }; + expect(font.color).toBe("#3b82f6"); + expect(ann.opacity).toBeUndefined(); + }); + + it("inline: honors custom selected.borderColor for selected font color", () => { + const peak = { x: 5, y: 100, text: "P", id: "p" }; + const ann = createPeakAnnotation(peak, 0, slot, { + annotationStyle: "inline", + selectedPeakIds: ["p"], + anySelected: true, + appearance: { + selected: { borderColor: "#ff00ff", backgroundColor: "#fff", bold: false }, + unselected: { opacity: 0.4 }, + hoverLineWidthMultiplier: 1, + }, + }); + const font = ann.font as { color: string }; + expect(font.color).toBe("#ff00ff"); + // bold:false → no wrap + expect(ann.text).toBe("P"); + }); }); describe("createGroupAnnotations", () => { diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx index 0effc052..a798386a 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -254,6 +254,47 @@ export const StackModeWithAnnotations: Story = { }, }; +/** + * Stack mode with `stackingOrder: "first-on-top"` — the first series is offset + * the most, so it sits at the top of the waterfall. Mirror image of the default + * `first-on-bottom` layout. + */ +export const StackModeFirstOnTop: Story = { + args: { + series: stackSeriesData, + title: "Charge Variant Runs — First On Top", + stackingMode: "stack", + stackOffset: 500, + stackingOrder: "first-on-top", + }, + play: async ({ canvasElement, step }) => { + const canvas = within(canvasElement); + + await step("Chart title is displayed", async () => { + expect( + canvas.getByText("Charge Variant Runs — First On Top") + ).toBeInTheDocument(); + }); + + await step("Chart container renders", async () => { + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); + }); + + await step("Three traces are rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(3); + }); + }, + parameters: { + docs: { + description: { + story: + "With `stackingOrder: \"first-on-top\"` the first series gets the largest vertical offset, placing it at the top of the waterfall. Annotations follow the same offset so labels stay anchored to their peaks.", + }, + }, + }, +}; + /** * Drag the "Stack Offset" slider in the Controls panel to adjust the vertical * separation between traces in real time. stackingMode is locked to 'stack'. From 1fdacfb69580a084617a99387dca724c7b001d8f Mon Sep 17 00:00:00 2001 From: Jesus Medellin Date: Tue, 23 Jun 2026 19:44:47 -0500 Subject: [PATCH 27/30] chore: address PR review on chromatogram enhancements - ChromatogramChart: normalize selection-appearance fields into primitives before the resolvedAppearance memo so its dependency array is exhaustive-deps clean. Removes the react-hooks/exhaustive-deps eslint-disable. Behavior unchanged. - Clear the Zephyr testCaseIds added by this PR to "" so the sync-storybook-zephyr action assigns real IDs. The Stacked IDs (SW-T1120/1121/1122/1124) were colliding with TdpSearchServer stories on main. Co-Authored-By: Claude Opus 4.8 --- .../ChromatogramChart.stories.tsx | 6 ++-- .../ChromatogramChart/ChromatogramChart.tsx | 36 ++++++++++++++----- .../StackedChromatogramChart.stories.tsx | 8 ++--- 3 files changed, 34 insertions(+), 16 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index aa07463a..e384e96d 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -289,7 +289,7 @@ export const UserDefinedPeaks: Story = { "Supply `startX`, `endX`, and `color` on each annotation to define peak boundaries with pass/fail coloring (green = pass, red = fail, grey = N/A). The component renders triangle markers (▲) at the start and diamond markers (◆) at the end; the label, arrow, border, and boundary markers all inherit the per-peak color. Area is auto-computed via trapezoidal integration over the bounded slice.", }, }, - zephyr: { testCaseId: "SW-T1114" }, + zephyr: { testCaseId: "" }, }, }; @@ -365,7 +365,7 @@ export const PeakHoverAndSelection: StoryObj = { "Click a peak to select it (blue border, bold label). Click again to deselect. Other peaks dim while one is selected. Hover over the trace to thicken the line.", }, }, - zephyr: { testCaseId: "SW-T1118" }, + zephyr: { testCaseId: "" }, }, }; @@ -566,6 +566,6 @@ export const InlineAnnotationStyle: Story = { 'With `annotationStyle="inline"` labels sit 4 px above the actual trace Y value with no arrow. Useful for dense chromatograms where arrowheads clutter the signal.', }, }, - zephyr: { testCaseId: "SW-T1119" }, + zephyr: { testCaseId: "" }, }, }; diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index 666735c5..0a238458 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -127,17 +127,35 @@ const ChromatogramChart: React.FC = ({ return peaks; }, [processedSeries, enablePeakDetection, peakDetectionOptions]); - // Resolve selection appearance defaults once (stable as long as individual - // fields don't change — consumers should memoize selectionAppearance if needed). + // Normalize the selection appearance into primitive fields up front so the + // memo below depends on stable values rather than the (possibly unstable) + // selectionAppearance object reference. This keeps the dependency array + // exhaustive-deps clean without requiring callers to memoize the prop. + const selectedBorderColor = selectionAppearance?.selected?.borderColor; + const selectedBackgroundColor = selectionAppearance?.selected?.backgroundColor; + const selectedBold = selectionAppearance?.selected?.bold; + const unselectedOpacity = selectionAppearance?.unselected?.opacity; + const hoverLineWidthMultiplier = selectionAppearance?.hoverLineWidthMultiplier; + + // Resolve selection appearance defaults once (stable as long as the + // individual fields above don't change). const resolvedAppearance = useMemo( - () => resolveSelectionAppearance(selectionAppearance), - // eslint-disable-next-line react-hooks/exhaustive-deps + () => + resolveSelectionAppearance({ + selected: { + borderColor: selectedBorderColor, + backgroundColor: selectedBackgroundColor, + bold: selectedBold, + }, + unselected: { opacity: unselectedOpacity }, + hoverLineWidthMultiplier, + }), [ - selectionAppearance?.selected?.borderColor, - selectionAppearance?.selected?.backgroundColor, - selectionAppearance?.selected?.bold, - selectionAppearance?.unselected?.opacity, - selectionAppearance?.hoverLineWidthMultiplier, + selectedBorderColor, + selectedBackgroundColor, + selectedBold, + unselectedOpacity, + hoverLineWidthMultiplier, ] ); diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx index a798386a..6c88b914 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -137,7 +137,7 @@ export const OverlayMode: Story = { "Overlay mode (default). All traces occupy the same y-axis range so retention times and peak heights can be compared directly. Crosshairs are enabled to aid comparison.", }, }, - zephyr: { testCaseId: "SW-T1120" }, + zephyr: { testCaseId: "" }, }, }; @@ -183,7 +183,7 @@ export const StackMode: Story = { "Stack mode shifts each series up by stackOffset data units, creating a waterfall layout. The y-axis range automatically expands to fit all offset traces.", }, }, - zephyr: { testCaseId: "SW-T1121" }, + zephyr: { testCaseId: "" }, }, }; @@ -250,7 +250,7 @@ export const StackModeWithAnnotations: Story = { "Per-series annotations passed as a 2-D array (annotations[i] → series[i]). In stack mode the component automatically shifts each annotation's y-value by the same offset applied to its trace, so labels stay anchored to their peaks.", }, }, - zephyr: { testCaseId: "SW-T1122" }, + zephyr: { testCaseId: "" }, }, }; @@ -320,6 +320,6 @@ export const InteractiveOffset: Story = { "Use the **Stack Offset** slider in the Controls panel to adjust vertical separation between traces live. At 0 the traces overlap (equivalent to overlay mode); increasing the offset creates a waterfall layout.", }, }, - zephyr: { testCaseId: "SW-T1124" }, + zephyr: { testCaseId: "" }, }, }; From 38d880f33e4cc4767664899bddfed7f60d6fa330 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:06:23 +0000 Subject: [PATCH 28/30] chore: merge main and resolve conflicts --- .../charts/ChromatogramChart/annotations.ts | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/src/components/charts/ChromatogramChart/annotations.ts b/src/components/charts/ChromatogramChart/annotations.ts index 0431519b..a9afe009 100644 --- a/src/components/charts/ChromatogramChart/annotations.ts +++ b/src/components/charts/ChromatogramChart/annotations.ts @@ -2,7 +2,7 @@ * Annotation utilities for ChromatogramChart */ -import { COLORS, CHART_COLORS } from "../../../utils/colors"; +import { CHART_COLORS } from "../../../utils/colors"; import { CHROMATOGRAM_ANNOTATION } from "./constants"; @@ -127,7 +127,9 @@ function resolveAnnotationBorderStyle( appearance: ResolvedSelectionAppearance, hasColorOverride: boolean ): AnnotationBorderStyle { - const bgcolor = isSelected ? appearance.selected.backgroundColor : COLORS.WHITE; + const bgcolor = isSelected + ? appearance.selected.backgroundColor + : CHROMATOGRAM_ANNOTATION.BACKGROUND_COLOR; let bordercolor: string | undefined; if (isSelected) { bordercolor = appearance.selected.borderColor; @@ -186,10 +188,12 @@ export function createPeakAnnotation( const isUserDefined = seriesIndex === -1; const defaultColor = isUserDefined - ? COLORS.GREY_500 + ? CHROMATOGRAM_ANNOTATION.USER_ANNOTATION_COLOR : CHART_COLORS[seriesIndex % CHART_COLORS.length]; const color = peak.color ?? defaultColor; - const textColor = isUserDefined && !peak.color ? COLORS.BLACK_900 : color; + const textColor = isUserDefined && !peak.color + ? CHROMATOGRAM_ANNOTATION.USER_ANNOTATION_TEXT_COLOR + : color; const rawText = peak.text ?? (peak._computed?.area === undefined ? "" : `Area: ${peak._computed.area.toFixed(2)}`); From e1982a1642d84c6c0cf093da0b15b7a276ff1a29 Mon Sep 17 00:00:00 2001 From: Jesus Medellin Date: Tue, 23 Jun 2026 20:12:41 -0500 Subject: [PATCH 29/30] feat(ChromatogramChart): render empty state instead of blank canvas When series is empty the component skipped Plotly init and left an uninitialized container, which read as a broken/blank chart. Now it renders the EmptyState (no-data variant) sized and centered to the chart box, so empty/loading data shows a clear "No chromatogram data" placeholder. The EmptySeries play test now asserts the placeholder is visible (the prior step name claimed to check the title but never did). Co-Authored-By: Claude Opus 4.8 --- .../ChromatogramChart.stories.tsx | 12 +++++------- .../ChromatogramChart/ChromatogramChart.tsx | 19 +++++++++++++++++++ 2 files changed, 24 insertions(+), 7 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index e384e96d..9ec31398 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -508,22 +508,20 @@ export const EmptySeries: Story = { play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - await step("Title renders even with empty series", async () => { - // Title comes from the chart wrapper outside Plotly; container exists - // but no plot is initialized. + await step("Empty state renders instead of a blank plot", async () => { + // Container mounts but Plotly is never initialized when series is empty. const container = canvasElement.querySelector(".chromatogram-chart-container"); expect(container).toBeInTheDocument(); - // No Plotly plot should be initialized when there are no series. expect(canvasElement.querySelector(".js-plotly-plot")).not.toBeInTheDocument(); - // Avoid unused-var lint - expect(canvas).toBeTruthy(); + // A clear "no data" placeholder is shown rather than a blank canvas. + expect(canvas.getByText("No chromatogram data")).toBeVisible(); }); }, parameters: { docs: { description: { story: - "When `series` is empty the component mounts the container but skips Plotly initialization — useful when data is still loading.", + "When `series` is empty the component skips Plotly initialization and renders a 'no data' empty state instead of a blank canvas — useful when data is still loading.", }, }, }, diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index 9fe29d47..e0f10d1a 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -29,6 +29,7 @@ import type { PeakWithMeta, } from "./types"; +import { EmptyState } from "@/components/composed/EmptyState"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; // Re-export types for external use @@ -354,6 +355,24 @@ const ChromatogramChart: React.FC = ({ } as unknown as Partial); }, [peakAnnotations]); + // No data: render an explicit empty state instead of an uninitialized + // (blank) Plotly container, so the chart reads as "no data yet" rather + // than looking broken while data is still loading. + if (series.length === 0) { + return ( +
+ +
+ ); + } + return (
From d13c6a123a6637d480e1381266c45405db809d11 Mon Sep 17 00:00:00 2001 From: 54321jenn-ts <54321jenn-ts@users.noreply.github.com> Date: Wed, 24 Jun 2026 01:18:00 +0000 Subject: [PATCH 30/30] chore: add Zephyr test case IDs to stories --- .../ChromatogramChart/ChromatogramChart.stories.tsx | 9 ++++++--- .../InteractiveScatter/InteractiveScatter.stories.tsx | 9 +++++++++ .../StackedChromatogramChart.stories.tsx | 9 +++++---- 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index 9ec31398..6ddb9000 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -289,7 +289,7 @@ export const UserDefinedPeaks: Story = { "Supply `startX`, `endX`, and `color` on each annotation to define peak boundaries with pass/fail coloring (green = pass, red = fail, grey = N/A). The component renders triangle markers (▲) at the start and diamond markers (◆) at the end; the label, arrow, border, and boundary markers all inherit the per-peak color. Area is auto-computed via trapezoidal integration over the bounded slice.", }, }, - zephyr: { testCaseId: "" }, + zephyr: { testCaseId: "SW-T5420" }, }, }; @@ -365,7 +365,7 @@ export const PeakHoverAndSelection: StoryObj = { "Click a peak to select it (blue border, bold label). Click again to deselect. Other peaks dim while one is selected. Hover over the trace to thicken the line.", }, }, - zephyr: { testCaseId: "" }, + zephyr: { testCaseId: "SW-T5421" }, }, }; @@ -428,6 +428,7 @@ export const WithRegionOverlay: Story = { }); }, parameters: { + zephyr: { testCaseId: "SW-T5422" }, docs: { description: { story: @@ -486,6 +487,7 @@ export const SelectionTogglesViaButton: StoryObj = { }); }, parameters: { + zephyr: { testCaseId: "SW-T5423" }, docs: { description: { story: @@ -518,6 +520,7 @@ export const EmptySeries: Story = { }); }, parameters: { + zephyr: { testCaseId: "SW-T5424" }, docs: { description: { story: @@ -564,6 +567,6 @@ export const InlineAnnotationStyle: Story = { 'With `annotationStyle="inline"` labels sit 4 px above the actual trace Y value with no arrow. Useful for dense chromatograms where arrowheads clutter the signal.', }, }, - zephyr: { testCaseId: "" }, + zephyr: { testCaseId: "SW-T5425" }, }, }; diff --git a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx index 929c76af..7e840f8d 100644 --- a/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx +++ b/src/components/charts/InteractiveScatter/InteractiveScatter.stories.tsx @@ -773,6 +773,9 @@ export const ContinuousColorMapping: Story = { }); }); }, + parameters: { + zephyr: { testCaseId: "SW-T5412" }, + }, }; const EVENT_DATA: ScatterPoint[] = Array.from({ length: 6 }, (_, i) => ({ @@ -858,6 +861,9 @@ export const SelectionEvents: Story = { }); }); }, + parameters: { + zephyr: { testCaseId: "SW-T5413" }, + }, }; /** @@ -917,4 +923,7 @@ export const ThemedTooltip: Story = { }); }); }, + parameters: { + zephyr: { testCaseId: "SW-T5414" }, + }, }; diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx index 6c88b914..9d6b07b1 100644 --- a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -137,7 +137,7 @@ export const OverlayMode: Story = { "Overlay mode (default). All traces occupy the same y-axis range so retention times and peak heights can be compared directly. Crosshairs are enabled to aid comparison.", }, }, - zephyr: { testCaseId: "" }, + zephyr: { testCaseId: "SW-T5426" }, }, }; @@ -183,7 +183,7 @@ export const StackMode: Story = { "Stack mode shifts each series up by stackOffset data units, creating a waterfall layout. The y-axis range automatically expands to fit all offset traces.", }, }, - zephyr: { testCaseId: "" }, + zephyr: { testCaseId: "SW-T5427" }, }, }; @@ -250,7 +250,7 @@ export const StackModeWithAnnotations: Story = { "Per-series annotations passed as a 2-D array (annotations[i] → series[i]). In stack mode the component automatically shifts each annotation's y-value by the same offset applied to its trace, so labels stay anchored to their peaks.", }, }, - zephyr: { testCaseId: "" }, + zephyr: { testCaseId: "SW-T5428" }, }, }; @@ -286,6 +286,7 @@ export const StackModeFirstOnTop: Story = { }); }, parameters: { + zephyr: { testCaseId: "SW-T5429" }, docs: { description: { story: @@ -320,6 +321,6 @@ export const InteractiveOffset: Story = { "Use the **Stack Offset** slider in the Controls panel to adjust vertical separation between traces live. At 0 the traces overlap (equivalent to overlay mode); increasing the offset creates a waterfall layout.", }, }, - zephyr: { testCaseId: "" }, + zephyr: { testCaseId: "SW-T5430" }, }, };