diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx index 887235af..6ddb9000 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx @@ -1,9 +1,11 @@ -import { expect, within } from "storybook/test"; +import { useState } from "react"; +import { expect, userEvent, within } from "storybook/test"; import { ChromatogramChart, type ChromatogramSeries, type PeakAnnotation, + type PeakSelectEvent, } from "./ChromatogramChart"; import type { Meta, StoryObj } from "@storybook/react-vite"; @@ -70,45 +72,47 @@ 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, }, ]; +// 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/Chromatogram Chart", + title: "Charts/ChromatogramChart/Default", component: ChromatogramChart, parameters: { layout: "centered", @@ -198,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. */ @@ -319,29 +246,23 @@ export const PeakDetection: Story = { }; /** - * Full featured chromatogram combining all major features. + * 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 FullFeatured: Story = { +export const UserDefinedPeaks: 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, + series: [{ ...singleInjectionData, name: "Sample A" }], + title: "User-Defined Peak Boundaries", + annotations: userDefinedPeaks, boundaryMarkers: "enabled", }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); await step("Chart title is displayed", async () => { - const title = canvas.getByText("Full Featured Chromatogram"); + const title = canvas.getByText("User-Defined Peak Boundaries"); expect(title).toBeInTheDocument(); }); @@ -350,196 +271,302 @@ export const FullFeatured: Story = { expect(container).toBeInTheDocument(); }); - await step("Multiple traces are rendered", async () => { + 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 marker traces are rendered", async () => { const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBeGreaterThanOrEqual(3); + expect(traces.length).toBeGreaterThan(1); }); + }, + parameters: { + docs: { + description: { + 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-T5420" }, + }, +}; - await step("User annotations are displayed", async () => { - expect(canvas.getByText("Caffeine")).toBeInTheDocument(); - expect(canvas.getByText("Theobromine")).toBeInTheDocument(); - expect(canvas.getByText("Theophylline")).toBeInTheDocument(); +/** + * 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, + }, + 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("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); + await step("Mouse events exercise hover, unhover, and click handler paths", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBeGreaterThan(0); + + const dragRect = canvasElement.querySelector(".xy.drag") as HTMLElement | null; + if (!dragRect) return; + + // 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: { docs: { description: { - story: "Combines all major features: multiple traces, grid lines, crosshairs, manual annotations, baseline correction, and automatic peak detection.", + 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-T1112" }, + zephyr: { testCaseId: "SW-T5421" }, }, }; /** - * Peak boundary markers showing triangle markers at peak start and diamond markers - * with vertical lines at peak end (the default styling). + * 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 WithBoundaryMarkers: Story = { +export const WithRegionOverlay: Story = { args: { series: [{ ...singleInjectionData, name: "Sample A" }], - title: "Peak Boundary Markers", - peakDetectionOptions: { - minHeight: 0.1, - prominence: 0.05, - minDistance: 20, - }, - showPeakAreas: true, - boundaryMarkers: "enabled", + title: "Peak Region Overlays", + annotationStyle: "inline", + 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 () => { - const title = canvas.getByText("Peak Boundary Markers"); - expect(title).toBeInTheDocument(); + expect(canvas.getByText("Peak Region Overlays")).toBeInTheDocument(); }); await step("Chart container renders", async () => { - const container = canvasElement.querySelector(".js-plotly-plot"); - expect(container).toBeInTheDocument(); + expect(canvasElement.querySelector(".js-plotly-plot")).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("Annotation labels are rendered", async () => { + expect(canvas.getByText("Caffeine (pass)")).toBeInTheDocument(); + expect(canvas.getByText("Theobromine (fail)")).toBeInTheDocument(); }); - await step("Peak area annotations are displayed", async () => { - const annotations = canvasElement.querySelectorAll(".annotation-text"); - expect(annotations.length).toBeGreaterThan(0); + 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: { + zephyr: { testCaseId: "SW-T5422" }, 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.", + 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. Labels use `annotationStyle=\"inline\"` — floating directly above the trace with no arrow.", }, }, - 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. + * 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 UserDefinedPeakBoundaries: Story = { +export const SelectionTogglesViaButton: StoryObj = { + render: (args) => { + const [selectedPeakIds, setSelectedPeakIds] = useState(["caffeine"]); + return ( +
+ +
+ {" "} + +
+
+ ); + }, args: { series: [{ ...singleInjectionData, name: "Sample A" }], - title: "User-Defined Peak Boundaries", - annotations: userDefinedPeaksWithBoundaries, - boundaryMarkers: "enabled", + title: "Selection Update Effect", + annotations: selectableAnnotations, }, play: async ({ canvasElement, step }) => { const canvas = within(canvasElement); - await step("Chart title is displayed", async () => { - const title = canvas.getByText("User-Defined Peak Boundaries"); - expect(title).toBeInTheDocument(); + await step("Chart mounts with initial selection", async () => { + expect(canvas.getByText("Selection Update Effect")).toBeInTheDocument(); + expect(canvasElement.querySelector(".js-plotly-plot")).toBeInTheDocument(); }); - await step("Chart container renders", async () => { - const container = canvasElement.querySelector(".js-plotly-plot"); - expect(container).toBeInTheDocument(); + await step("Switching selection triggers the relayout effect", async () => { + const setBtn = canvas.getByTestId("set-selection"); + await userEvent.click(setBtn); }); - 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("Clearing selection triggers the relayout effect again", async () => { + const clearBtn = canvas.getByTestId("clear-selection"); + await userEvent.click(clearBtn); }); + }, + parameters: { + zephyr: { testCaseId: "SW-T5423" }, + 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).", + }, + }, + }, +}; - 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); +/** + * 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("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(); + expect(canvasElement.querySelector(".js-plotly-plot")).not.toBeInTheDocument(); + // A clear "no data" placeholder is shown rather than a blank canvas. + expect(canvas.getByText("No chromatogram data")).toBeVisible(); }); }, parameters: { + zephyr: { testCaseId: "SW-T5424" }, 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.", + story: + "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.", }, }, - 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. + * 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 CombinedAutoAndUserPeaks: Story = { +export const InlineAnnotationStyle: 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", + title: "Inline Annotation Style", + annotations: selectableAnnotations, + annotationStyle: "inline", }, 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(); + expect(canvas.getByText("Inline Annotation Style")).toBeInTheDocument(); }); await step("Chart container renders", async () => { - const container = canvasElement.querySelector(".js-plotly-plot"); - expect(container).toBeInTheDocument(); + expect(canvasElement.querySelector(".js-plotly-plot")).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("Boundary markers from both sources are rendered", async () => { - // Main trace + boundary marker traces from both auto-detected and user-defined peaks - const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); - expect(traces.length).toBeGreaterThan(1); + 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: "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.", + 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-T1115" }, + zephyr: { testCaseId: "SW-T5425" }, }, }; diff --git a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx index d63558bb..e0f10d1a 100644 --- a/src/components/charts/ChromatogramChart/ChromatogramChart.tsx +++ b/src/components/charts/ChromatogramChart/ChromatogramChart.tsx @@ -1,26 +1,26 @@ import Plotly from "plotly.js-dist"; import React, { useEffect, useMemo, useRef } from "react"; -import { seriesColor } from "../../../utils/colors"; import { useChartTooltip } from "../ChartTooltip"; import { groupOverlappingPeaks, createGroupAnnotations, + resolveSelectionAppearance, } from "./annotations"; -import { createBoundaryMarkerTraces } from "./boundaryMarkers"; -import { CHROMATOGRAM_LAYOUT } from "./constants"; import { validateSeriesData, applyBaselineCorrection, - collectPeaksWithBoundaryData, processUserAnnotations, } from "./dataProcessing"; import { detectPeaks } from "./peakDetection"; +import { buildTraceData, buildLayout, buildConfig, createHoverHandler, createUnhoverHandler } from "./plotBuilder"; import type { ChromatogramSeries, PeakAnnotation, + PeakSelectEvent, + PeakSelectionAppearance, BaselineCorrectionMethod, BoundaryMarkerStyle, BoundaryMarkerType, @@ -29,12 +29,15 @@ import type { PeakWithMeta, } from "./types"; +import { EmptyState } from "@/components/composed/EmptyState"; import { usePlotlyTheme } from "@/hooks/use-plotly-theme"; // Re-export types for external use export type { ChromatogramSeries, PeakAnnotation, + PeakSelectEvent, + PeakSelectionAppearance, BaselineCorrectionMethod, BoundaryMarkerStyle, BoundaryMarkerType, @@ -71,8 +74,14 @@ const ChromatogramChart: React.FC = ({ boundaryMarkers = "none", annotationOverlapThreshold = 0.4, showExportButton = true, + selectedPeakIds, + onPeakClick, + onPeakHover, + selectionAppearance, + annotationStyle = "arrow", + titleFontSize = 20, + titleTopMargin, }) => { - // Derive peak detection state from options const enablePeakDetection = peakDetectionOptions !== undefined; const plotRef = useRef(null); const theme = usePlotlyTheme(); @@ -81,6 +90,20 @@ const ChromatogramChart: React.FC = ({ yLabel: yAxisTitle, }); + // 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 in effects always + // read the latest selection-styled annotations. + const peakAnnotationsRef = useRef[]>([]); + // Memoize processed series with baseline correction const processedSeries = useMemo(() => { return series.map((s) => { @@ -98,7 +121,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,182 +139,240 @@ const ChromatogramChart: React.FC = ({ return peaks; }, [processedSeries, enablePeakDetection, peakDetectionOptions]); - useEffect(() => { - const currentRef = plotRef.current; - if (!currentRef || series.length === 0) return; + // 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; - // Build trace data with auto-assigned colors - const plotData: Plotly.Data[] = processedSeries.map((s, index) => { - const traceColor = seriesColor(index, s.color); - - 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: 1.5, + // Resolve selection appearance defaults once (stable as long as the + // individual fields above don't change). + const resolvedAppearance = useMemo( + () => + resolveSelectionAppearance({ + selected: { + borderColor: selectedBorderColor, + backgroundColor: selectedBackgroundColor, + bold: selectedBold, }, - hoverinfo: "none" as const, - }; - if (showMarkers) { - trace.marker = { - size: markerSize, - color: traceColor, - }; - } - return trace; + unselected: { opacity: unselectedOpacity }, + hoverLineWidthMultiplier, + }), + [ + selectedBorderColor, + selectedBackgroundColor, + selectedBold, + unselectedOpacity, + 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, + }); }); - // Add peak boundary markers if enabled - if (boundaryMarkers !== "none") { - const peaksWithData = collectPeaksWithBoundaryData(allDetectedPeaks, processedAnnotations, processedSeries); - if (peaksWithData.length > 0) { - const boundaryTraces = createBoundaryMarkerTraces(peaksWithData); - plotData.push(...boundaryTraces); - } - } + 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, + annotationStyle, + }; - // 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 }); + processedAnnotations.forEach((ann, i) => { + allPeaksWithMeta.push({ + peak: { ...ann, id: ann.id ?? `user-ann-${i}` }, + seriesIndex: -1, + }); }); - // Add auto-detected peaks if enabled if (showPeakAreas && enablePeakDetection) { allDetectedPeaks.forEach(({ peaks, seriesIndex }) => { - peaks.forEach((peak) => { - allPeaksWithMeta.push({ peak, seriesIndex }); + peaks.forEach((peak, peakIndex) => { + allPeaksWithMeta.push({ + peak: { ...peak, id: `peak-${seriesIndex}-${peakIndex}` }, + seriesIndex, + }); }); }); } - // Group all overlapping peaks and create annotations with staggering const groups = groupOverlappingPeaks(allPeaksWithMeta, annotationOverlapThreshold); - const plotlyAnnotations: Partial[] = []; - + const result: Partial[] = []; for (const group of groups) { - plotlyAnnotations.push(...createGroupAnnotations(group)); + result.push(...createGroupAnnotations(group, options)); } + return result; + }, [ + processedAnnotations, + allDetectedPeaks, + showPeakAreas, + enablePeakDetection, + annotationOverlapThreshold, + selectedPeakIds, + resolvedAppearance, + annotationStyle, + ]); - const layout: Partial = { - title: title - ? { - text: title, - font: { - size: 20, - family: "Inter, sans-serif", - color: theme.textColor, - }, - } - : undefined, + // 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; + + const plotData = buildTraceData({ + processedSeries, + processedAnnotations, + allDetectedPeaks, + allPeaksForInteraction, + showMarkers, + markerSize, + xAxisTitle, + yAxisTitle, + boundaryMarkers, + }); + + 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 ? 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: plotlyAnnotations, - }; + 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: width, - height: height, - }, - }), - }; + const config = buildConfig({ showExportButton, width, height }); Plotly.newPlot(currentRef, plotData, layout, config); bindTooltip(currentRef); - return () => { - if (currentRef) { - Plotly.purge(currentRef); + // ── 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); } + ); + + (currentRef as unknown as Plotly.PlotlyHTMLElement).on( + "plotly_hover", + createHoverHandler(currentRef, processedSeries.length, thickenedSeriesRef, onPeakHoverRef, resolvedAppearance.hoverLineWidthMultiplier) + ); + + (currentRef as unknown as Plotly.PlotlyHTMLElement).on( + "plotly_unhover", + createUnhoverHandler(currentRef, thickenedSeriesRef, onPeakHoverRef) + ); + + return () => { + 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, bindTooltip, + processedSeries, allDetectedPeaks, allPeaksForInteraction, series.length, + width, height, title, titleFontSize, titleTopMargin, xAxisTitle, yAxisTitle, + processedAnnotations, xRange, yRange, showLegend, showGridX, showGridY, + showMarkers, markerSize, showCrosshairs, enablePeakDetection, peakDetectionOptions, + showPeakAreas, boundaryMarkers, annotationOverlapThreshold, showExportButton, + theme, + // resolvedAppearance included so hover multiplier stays in sync with the + // event handler closure without it being in a ref itself. + resolvedAppearance, + bindTooltip, ]); + // ── 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, + } 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 (
@@ -302,4 +382,3 @@ const ChromatogramChart: React.FC = ({ }; export { ChromatogramChart }; - 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..9b029c0b --- /dev/null +++ b/src/components/charts/ChromatogramChart/__tests__/annotations.test.ts @@ -0,0 +1,286 @@ +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 no overrides", () => { + const result = resolveSelectionAppearance(); + 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); + }); + + 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", () => { + 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(); + }); + + 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", () => { + 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__/dataProcessing.test.ts b/src/components/charts/ChromatogramChart/__tests__/dataProcessing.test.ts new file mode 100644 index 00000000..f4c07e97 --- /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")).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); + }); +}); 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..4dfb8f5d --- /dev/null +++ b/src/components/charts/ChromatogramChart/__tests__/plotBuilder.test.ts @@ -0,0 +1,476 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +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 { + ...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(); + }); +}); + +// ── 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/__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/ChromatogramChart/annotations.ts b/src/components/charts/ChromatogramChart/annotations.ts index 0d8b1c21..a9afe009 100644 --- a/src/components/charts/ChromatogramChart/annotations.ts +++ b/src/components/charts/ChromatogramChart/annotations.ts @@ -2,13 +2,56 @@ * Annotation utilities for ChromatogramChart */ -import { seriesColor } from "../../../utils/colors"; +import { 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,72 @@ export function groupOverlappingPeaks( return groups; } +interface PeakAnnotationOptions { + selectedPeakIds?: string[]; + anySelected?: boolean; + appearance?: ResolvedSelectionAppearance; + annotationStyle?: "arrow" | "inline"; +} + +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, + hasColorOverride: boolean +): AnnotationBorderStyle { + const bgcolor = isSelected + ? appearance.selected.backgroundColor + : CHROMATOGRAM_ANNOTATION.BACKGROUND_COLOR; + let bordercolor: string | undefined; + if (isSelected) { + bordercolor = appearance.selected.borderColor; + } else { + bordercolor = isUserDefined && !hasColorOverride ? undefined : seriesColor; + } + const borderwidth = isSelected ? 2 : isUserDefined && !hasColorOverride ? 0 : 1; + const opacity = isDimmed ? appearance.unselected.opacity : undefined; + 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). @@ -67,23 +176,48 @@ 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, + annotationStyle = "arrow", + } = options; + const isUserDefined = seriesIndex === -1; - const color = isUserDefined + const defaultColor = isUserDefined ? CHROMATOGRAM_ANNOTATION.USER_ANNOTATION_COLOR - : seriesColor(seriesIndex); - const textColor = isUserDefined + : CHART_COLORS[seriesIndex % CHART_COLORS.length]; + const color = peak.color ?? defaultColor; + const textColor = isUserDefined && !peak.color ? CHROMATOGRAM_ANNOTATION.USER_ANNOTATION_TEXT_COLOR : 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; + + 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; + const borderStyle = resolveAnnotationBorderStyle( + isSelected, isDimmed, isUserDefined, color, appearance, peak.color !== undefined + ); + return { x: peak.x, y: peak.y, @@ -96,16 +230,12 @@ 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", }, - bgcolor: CHROMATOGRAM_ANNOTATION.BACKGROUND_COLOR, borderpad: 2, - bordercolor: isUserDefined ? undefined : color, - borderwidth: isUserDefined ? 0 : 1, + ...borderStyle, }; } @@ -113,11 +243,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 @@ -126,7 +257,6 @@ 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/boundaryMarkers.ts b/src/components/charts/ChromatogramChart/boundaryMarkers.ts index 00d29236..d8366b73 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 = seriesColor(seriesIndex); + const defaultSeriesColor = seriesColor(seriesIndex); // 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 ?? defaultSeriesColor; + // Create start boundary marker (upper row, staggered by series) traces.push(...createMarkerTrace(startX, startMarkerY, startMarkerType, color)); @@ -88,4 +90,3 @@ export function createBoundaryMarkerTraces( return traces; } - diff --git a/src/components/charts/ChromatogramChart/constants.ts b/src/components/charts/ChromatogramChart/constants.ts index b40f7016..ea2a7ffa 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, /** Arrow color for user-defined annotations (grey 500) */ USER_ANNOTATION_COLOR: "rgba(100, 116, 139, 1)", /** Text color for user-defined annotations (black 900) */ @@ -38,3 +40,11 @@ export const CHROMATOGRAM_ANNOTATION = { BACKGROUND_COLOR: "#ffffff", } 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; + diff --git a/src/components/charts/ChromatogramChart/plotBuilder.ts b/src/components/charts/ChromatogramChart/plotBuilder.ts new file mode 100644 index 00000000..1152cdeb --- /dev/null +++ b/src/components/charts/ChromatogramChart/plotBuilder.ts @@ -0,0 +1,307 @@ +import Plotly from "plotly.js-dist"; + +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, PeakSelectEvent } from "./types"; +import type { PlotlyThemeColors } from "@/hooks/use-plotly-theme"; + +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, + }, + }), + }; +} + +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; + } + }; +} 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; +} diff --git a/src/components/charts/ChromatogramChart/types.ts b/src/components/charts/ChromatogramChart/types.ts index a7c8dcd9..3e934d00 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) */ @@ -64,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. @@ -71,6 +95,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 */ @@ -154,6 +220,52 @@ export interface ChromatogramChartProps { annotationOverlapThreshold?: number; /** Show export button in modebar (default: true) */ showExportButton?: boolean; + + // ── 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; + + /** + * 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"; + + /** 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; } /** 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 new file mode 100644 index 00000000..9d6b07b1 --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.stories.tsx @@ -0,0 +1,326 @@ +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/ChromatogramChart/Stacked", + 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-T5426" }, + }, +}; + +/** + * 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-T5427" }, + }, +}; + +/** + * 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("Four traces are rendered", async () => { + const traces = canvasElement.querySelectorAll(".scatterlayer .trace"); + expect(traces.length).toBe(4); + }); + + 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-T5428" }, + }, +}; + +/** + * 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: { + zephyr: { testCaseId: "SW-T5429" }, + 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'. + */ +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-T5430" }, + }, +}; diff --git a/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx new file mode 100644 index 00000000..28192cd8 --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/StackedChromatogramChart.tsx @@ -0,0 +1,43 @@ +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, + stackingOrder = "first-on-bottom", + annotations, + ...restProps +}: StackedChromatogramChartProps) { + const { + series: transformedSeries, + annotations: transformedAnnotations, + yRange, + } = useMemo( + () => + applyStackingTransform( + series, + annotations, + stackingMode, + stackOffset, + stackingOrder + ), + [series, annotations, stackingMode, stackOffset, stackingOrder] + ); + + return ( + + ); +} + +export default StackedChromatogramChart; 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..7cb7aea1 --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/__tests__/transforms.test.ts @@ -0,0 +1,121 @@ +import { describe, it, expect } from "vitest"; + +import { applyStackingTransform } from "../transforms"; + +import type { ChromatogramSeries, PeakAnnotation } 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, "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, "overlay", 10); + expect(result.annotations).toHaveLength(2); + }); + + it("returns empty annotations when undefined", () => { + const series = [makeSeries([1, 2])]; + const result = applyStackingTransform(series, undefined, "overlay", 10); + expect(result.annotations).toHaveLength(0); + }); + + it("computes yRange spanning all series values including 0 minimum", () => { + const series = [makeSeries([5, 10]), makeSeries([3, 8])]; + 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, "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, "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, "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, "stack", 10); + expect(result.annotations[0].y).toBe(5); // series 0, no shift + expect(result.annotations[1].y).toBe(15); // series 1, +10 + }); + + it("handles undefined annotations gracefully", () => { + const series = [makeSeries([0, 5])]; + const result = applyStackingTransform(series, undefined, "stack", 10); + expect(result.annotations).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, "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, "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, "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, "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/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..f8416531 --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/transforms.ts @@ -0,0 +1,64 @@ +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, + stackingOrder: "first-on-bottom" | "first-on-top" = "first-on-bottom" +): 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 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 + yShiftForIndex(index)), + }) + ); + + const offsetAnnotations: PeakAnnotation[] = []; + if (inputAnnotations) { + inputAnnotations.forEach((seriesAnnotations, seriesIndex) => { + const yShift = yShiftForIndex(seriesIndex); + 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..ac8e4951 --- /dev/null +++ b/src/components/charts/StackedChromatogramChart/types.ts @@ -0,0 +1,37 @@ +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[][]; + + /** + * 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 follow the chosen direction. + */ + stackingOrder?: "first-on-bottom" | "first-on-top"; +} diff --git a/src/index.ts b/src/index.ts index 67bc8f1d..51871b50 100644 --- a/src/index.ts +++ b/src/index.ts @@ -24,6 +24,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";