Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
8a06f8a
Adds range annotations to ChromatogramChart
sdee-tetra May 4, 2026
e83e826
Fixes vertical overlap between annotation and peak
sdee-tetra May 4, 2026
e9197b4
Adds StackedChromatogram
sdee-tetra May 4, 2026
826d7bd
Makes RangedAnnotations work with StackedChromatogramChart
sdee-tetra May 4, 2026
818f9d7
Fixes annotation behavior with zoom
sdee-tetra May 4, 2026
201cdba
Adds props for selecting and highlighting peaks
sdee-tetra May 5, 2026
f5bdbb6
Puts annotation closer to trace
sdee-tetra May 5, 2026
ee6f756
feat(ChromatogramChart): per-peak color override
sdee-tetra May 12, 2026
a1d1e39
feat(ChromatogramChart): peak region overlay trace
sdee-tetra May 12, 2026
a320da9
feat(StackedChromatogramChart): stackingOrder prop
sdee-tetra May 12, 2026
2747534
Tigthen storybook stories
sdee-tetra May 13, 2026
872af91
Fix tests
sdee-tetra May 13, 2026
d40c928
Increase test coverage of new chromatogram features
sdee-tetra May 13, 2026
d1d840c
Lint fixes
sdee-tetra May 13, 2026
cc050b0
Remove RangeAnnotation
sdee-tetra May 13, 2026
25583df
Adds play function for PeakHoverAndSelection
sdee-tetra May 13, 2026
3a24192
Adds test coverage for both the Pointer Events and Mouse Events APIs…
sdee-tetra May 13, 2026
bd0f245
Increase test coverage of ChromatogramChart and adds story for hover
sdee-tetra May 13, 2026
8c76357
refactor(chromatogram): extract buildTraceData/buildLayout/buildConfi…
sdee-tetra May 13, 2026
9ad82b0
Removes raceHoverThickening story
sdee-tetra May 13, 2026
f34fe23
Makes annotation for peak region overlays inline
sdee-tetra May 13, 2026
71e0a43
Extract hover/unhover handlers into testable factory functions in plo…
sdee-tetra May 13, 2026
4503b15
Lint fixes
sdee-tetra May 13, 2026
98f60ff
Merge branch 'main' of https://github.com/tetrascience/ts-lib-ui-kit …
sdee-tetra May 20, 2026
cc28aae
lint fix
sdee-tetra May 20, 2026
f2122af
Groups ChromatogramChart stories together
sdee-tetra May 22, 2026
02cf1fe
Adds test to improve coverage
sdee-tetra May 22, 2026
36fd03e
Merge main and accept deletion of PlateMapEditor.stories.tsx
sdee-tetra May 28, 2026
1fdacfb
chore: address PR review on chromatogram enhancements
54321jenn-ts Jun 24, 2026
426ae3c
Merge origin/main and resolve ChromatogramChart conflicts
Copilot Jun 24, 2026
38d880f
chore: merge main and resolve conflicts
Copilot Jun 24, 2026
e1982a1
feat(ChromatogramChart): render empty state instead of blank canvas
54321jenn-ts Jun 24, 2026
d13c6a1
chore: add Zephyr test case IDs to stories
54321jenn-ts Jun 24, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
465 changes: 246 additions & 219 deletions src/components/charts/ChromatogramChart/ChromatogramChart.stories.tsx

Large diffs are not rendered by default.

383 changes: 231 additions & 152 deletions src/components/charts/ChromatogramChart/ChromatogramChart.tsx

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -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("<b>Peak A</b>");
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("<b>Peak A</b>");
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 <b> 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);
});
});
Loading
Loading