diff --git a/docs/superpowers/plans/2026-05-28-spike-lines-styling.md b/docs/superpowers/plans/2026-05-28-spike-lines-styling.md new file mode 100644 index 00000000..e2ee3ce9 --- /dev/null +++ b/docs/superpowers/plans/2026-05-28-spike-lines-styling.md @@ -0,0 +1,231 @@ +# Spike Lines Styling Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Make Plotly spike lines visible on hover and show precise x/y values in the existing hover popup, all styled to match the app's dark theme. + +**Architecture:** Fix `plot_bgcolor` so Plotly renders spike lines against a dark surface; add x/y values to `SelectedPointData` and surface them in `PopoverCard`. No new components. + +**Tech Stack:** React, Plotly / react-plotly.js, TypeScript, Redux + +--- + +## File Map + +| File | Change | +| -------------------------------------------- | ----------------------------------------------------------------------- | +| `src/constants/index.ts` | `spikeColor` updated (DONE) | +| `src/components/MainPlot/index.tsx` | Fix `plot_bgcolor`; spike/hoverlabel styling (DONE except plot_bgcolor) | +| `src/state/selection/types.ts` | Add `xValue`, `yValue` to `SelectedPointData` | +| `src/containers/MainPlotContainer/index.tsx` | Extract x/y in hover handler; format and pass to PopoverCard | +| `src/components/PopoverCard/index.tsx` | Add x/y display | + +--- + +### Task 1: Fix spike line color constant ✅ DONE + +--- + +### Task 2: Wire up spike color constant and add hover label styling in MainPlot ✅ DONE + +--- + +### Task 3: Fix `plot_bgcolor` to eliminate white spike background + +**Files:** +- Modify: `src/components/MainPlot/index.tsx` + +Plotly renders a white mask behind spike lines when `plot_bgcolor` is transparent. Fix by setting it to `PALETTE.backgroundColor` (`#000`). The page background behind the plot is already this color, so visually nothing changes — Plotly just gets a dark surface to render against. + +`PALETTE` is already imported in this file (from Task 2). + +- [ ] **Step 1: Change `plot_bgcolor` in the layout `useMemo`** + +Find the layout return object inside the `useMemo` and change: + +- [ ] **Step 2: Run TypeScript check** + +```bash +npx tsc --noEmit +``` + +Expected: same two pre-existing errors only. + +- [ ] **Step 3: Commit** + +```bash +git add src/components/MainPlot/index.tsx +git commit -m "fix: use dark plot background to prevent white spike line halo" +``` + +--- + +### Task 4: Add x/y values to hover popup + +**Files:** +- Modify: `src/state/selection/types.ts` +- Modify: `src/containers/MainPlotContainer/index.tsx` +- Modify: `src/components/PopoverCard/index.tsx` + +Three changes to show the hovered point's x and y values in the existing `PopoverCard`. + +- [ ] **Step 1: Add `xValue` and `yValue` to `SelectedPointData` in `src/state/selection/types.ts`** + +```ts +export interface SelectedPointData { + [CELL_ID_KEY]: string; + index: number; + thumbnailPath: string; + srcPath: string; + groupBy?: string; + xValue?: number | string; + yValue?: number | string; +} +``` + +- [ ] **Step 2: Extract x/y in `onPointHovered` in `src/containers/MainPlotContainer/index.tsx`** + +In the `onPointHovered` method, add `xValue` and `yValue` to the `changeHoveredPoint` call: + +```ts +changeHoveredPoint({ + [CELL_ID_KEY]: point.id, + index: point.customdata.index, + thumbnailPath: point.customdata.thumbnailPath, + srcPath: point.customdata.srcPath, + xValue: point.x, + yValue: point.y, +}); +``` + +- [ ] **Step 3: Format and pass x/y values in `renderPopover` in `src/containers/MainPlotContainer/index.tsx`** + +Add a helper at the top of `renderPopover` to format a value: + +```ts +const formatAxisValue = (value: number | string | undefined): string => { + if (value === undefined) return ""; + if (typeof value === "string") return value; + return Number(value).toPrecision(4); +}; +``` + +Then pass x/y to `PopoverCard`: + +```ts +return ( + hoveredPointData && + galleryCollapsed && ( + + ) +); +``` + +Note: `xDropDownValue` and `yDropDownValue` are already in the component's props (destructured from `this.props` in the `render` method). Destructure them in `renderPopover` as well: + +```ts +public renderPopover() { + const { hoveredPointData, galleryCollapsed, thumbnailRoot, xDropDownValue, yDropDownValue } = this.props; + // ... rest of method +``` + +- [ ] **Step 4: Add x/y props and display to `PopoverCard` in `src/components/PopoverCard/index.tsx`** + +Update the interface: + +```ts +export interface PopoverCardProps { + description: string; + title: string; + src?: string; + xLabel?: string; + xValue?: string; + yLabel?: string; + yValue?: string; +} +``` + +Add the x/y display below `` when values are present: + +```tsx +return ( + + + {(props.xValue || props.yValue) && ( + + {props.xValue && ( + + {props.xLabel} + {props.xValue} + + )} + {props.yValue && ( + + {props.yLabel} + {props.yValue} + + )} + + )} + +); +``` + +- [ ] **Step 5: Add CSS for x/y display in `src/components/PopoverCard/style.css`** + +Inside the `#thumbnail-popover` block, add: + +```css +.axisValues { + padding: 4px 2px 2px; + display: flex; + flex-direction: column; + gap: 2px; +} + +.axisRow { + display: flex; + justify-content: space-between; + gap: 6px; + font-size: 9px; + line-height: 1.3; + color: var(--text-gray); + overflow: hidden; +} + +.axisLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 54px; +} + +.axisValue { + white-space: nowrap; + color: var(--off-white); + flex-shrink: 0; +} +``` + +- [ ] **Step 6: Run TypeScript check** + +```bash +npx tsc --noEmit +``` + +Expected: same two pre-existing errors only. + +- [ ] **Step 7: Commit** + +```bash +git add src/state/selection/types.ts src/containers/MainPlotContainer/index.tsx src/components/PopoverCard/index.tsx src/components/PopoverCard/style.css +git commit -m "feat: show x/y axis values in hover popup" +``` diff --git a/docs/superpowers/specs/2026-05-28-spike-lines-styling-design.md b/docs/superpowers/specs/2026-05-28-spike-lines-styling-design.md new file mode 100644 index 00000000..08f44517 --- /dev/null +++ b/docs/superpowers/specs/2026-05-28-spike-lines-styling-design.md @@ -0,0 +1,106 @@ +# Spike Lines Styling Design + +**Date:** 2026-05-28 (revised after visual verification) +**Branch:** copilot/display-x-and-y-values + +## Problem + +When hovering over a data point on the scatter plot, it is hard to read the precise x and y values. Plotly spike lines were added to address this, but two issues were discovered during visual verification: + +1. **White background on spike lines**: Plotly renders a white mask/halo behind spike lines when `plot_bgcolor` is transparent (`rgba(0,0,0,0)`). This makes the dotted lines look like a solid white band on the dark background. +2. **No axis value labels**: Plotly only shows value labels at the spike/axis intersection in `hovermode: "x"`, `"y"`, `"x unified"`, or `"y unified"`. This app uses `hovermode: "closest"`, so those labels never appear. + +## Scope + +- Only activates when hovering over a data point (not on free cursor movement) +- Affects the main scatter plot axes only (`xaxis`, `yaxis` in `makeAxis()`) +- The histogram sub-axes (`histogramAxis`) are unaffected + +## Design + +### 1. Fix spike line white background (`MainPlot/index.tsx`) + +Change `plot_bgcolor` to black. + +### 2. Show x/y values in the hover popup + +Since Plotly's axis spike labels don't appear in `hovermode: "closest"`, add the x/y values to the existing `PopoverCard` hover popup instead. + +#### `src/state/selection/types.ts` + +Add optional fields to `SelectedPointData`: + +```ts +export interface SelectedPointData { + [CELL_ID_KEY]: string; + index: number; + thumbnailPath: string; + srcPath: string; + groupBy?: string; + xValue?: number; + yValue?: number; +} +``` + +#### `src/containers/MainPlotContainer/index.tsx` + +In `onPointHovered`, extract `point.x` and `point.y` from the Plotly event and include them in the `changeHoveredPoint` dispatch: + +```ts +changeHoveredPoint({ + [CELL_ID_KEY]: point.id, + index: point.customdata.index, + thumbnailPath: point.customdata.thumbnailPath, + srcPath: point.customdata.srcPath, + xValue: point.x as number, + yValue: point.y as number, +}); +``` + +In `renderPopover`, format the values and pass them to `PopoverCard` with axis feature name labels: + +- For numerical axes: format to 4 significant figures using `Number(value).toPrecision(4)` +- For categorical axes: look up the display label from `xTickConversion`/`yTickConversion` +- Labels come from `xDropDownValue` / `yDropDownValue` (already in props) + +#### `src/components/PopoverCard/index.tsx` + +Add optional x/y props and display them below the existing cell info: + +```ts +export interface PopoverCardProps { + description: string; + title: string; + src?: string; + xLabel?: string; + xValue?: string; + yLabel?: string; + yValue?: string; +} +``` + +Display as a small table or label/value pairs beneath the `Meta` section when values are present. + +### 3. Spike line color (already done) + +`GENERAL_PLOT_SETTINGS.spikeColor` was updated to `"rgb(0, 0, 0)"` in Task 1. This remains correct. + +### 4. Hover label styling (already done) + +`hoverlabel` was added to the layout in Task 2 using `PALETTE.darkGray`, `PALETTE.headerGray`, and `GENERAL_PLOT_SETTINGS.textColor`. This styles the Plotly point tooltip and remains correct. + +## Files Changed + +| File | Change | +| -------------------------------------------- | ------------------------------------------------------------- | +| `src/constants/index.ts` | `spikeColor` updated (done) | +| `src/components/MainPlot/index.tsx` | Fix `plot_bgcolor`; spike/hoverlabel styling (partially done) | +| `src/state/selection/types.ts` | Add `xValue`, `yValue` to `SelectedPointData` | +| `src/containers/MainPlotContainer/index.tsx` | Extract x/y in hover handler; format and pass to PopoverCard | +| `src/components/PopoverCard/index.tsx` | Add x/y display | + +## Non-goals + +- Showing spike lines when hovering empty plot space (only on data points) +- Axis value labels drawn on the axis line (not achievable with `hovermode: "closest"`) +- Custom `hovertemplate` text on Plotly's trace tooltip diff --git a/src/components/MainPlot/index.tsx b/src/components/MainPlot/index.tsx index d231158c..915807d6 100644 --- a/src/components/MainPlot/index.tsx +++ b/src/components/MainPlot/index.tsx @@ -107,6 +107,11 @@ const MainPlot: React.FC = (props) => { hoverformat: ".1f", linecolor: GENERAL_PLOT_SETTINGS.textColor, showgrid: false, + showspikes: true, + spikecolor: GENERAL_PLOT_SETTINGS.spikeColor, + spikethickness: 2, + spikedash: "dot", + spikemode: "toaxis+marker" as const, tickcolor: GENERAL_PLOT_SETTINGS.textColor, tickmode: type, ticktext: tickConversion.tickText, diff --git a/src/components/PopoverCard/index.tsx b/src/components/PopoverCard/index.tsx index 546fcf38..ee438b26 100644 --- a/src/components/PopoverCard/index.tsx +++ b/src/components/PopoverCard/index.tsx @@ -10,6 +10,10 @@ export interface PopoverCardProps { description: string; title: string; src?: string; + xLabel?: string; + xValue?: string; + yLabel?: string; + yValue?: string; } const PopoverCard: React.FC = (props) => { @@ -42,6 +46,22 @@ const PopoverCard: React.FC = (props) => { return ( + {(props.xValue || props.yValue) && ( + + {props.xValue && ( + + {props.xLabel} + {props.xValue} + + )} + {props.yValue && ( + + {props.yLabel} + {props.yValue} + + )} + + )} ); }; diff --git a/src/components/PopoverCard/style.css b/src/components/PopoverCard/style.css index b4982f19..e2387340 100644 --- a/src/components/PopoverCard/style.css +++ b/src/components/PopoverCard/style.css @@ -36,4 +36,34 @@ .container :global(.ant-card-body) { padding: 2px; } + + .axisValues { + padding: 4px 2px 2px; + display: flex; + flex-direction: column; + gap: 2px; + } + + .axisRow { + display: flex; + justify-content: space-between; + gap: 6px; + font-size: 9px; + line-height: 1.3; + color: var(--text-gray); + overflow: hidden; + } + + .axisLabel { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 54px; + } + + .axisValue { + white-space: nowrap; + color: var(--off-white); + flex-shrink: 0; + } } diff --git a/src/constants/index.ts b/src/constants/index.ts index 47375f94..70fd7805 100755 --- a/src/constants/index.ts +++ b/src/constants/index.ts @@ -46,16 +46,28 @@ export const DISABLE_COLOR = "#6e6e6e"; export const OFF_COLOR = "#000"; export const HEX_COLOR_REGEX = /^([0-9A-Fa-f]{3}|[0-9A-Fa-f]{6})$/i; +const BASE_PALETTE_COLORS = { + purple: "#8950d9", + darkGray: "#313131", + mediumDarkGray: "#4b4b4b", + mediumGray: "#6e6e6e", + lightGray: "#a0a0a0", + extraLightGray: "#d8d8d8", + brightGreen: "#b2d030", + brightBlue: "#00a0ff", + white: "#ffffff", + translucentWhite: "#ffffffab", +} as const; export const GENERAL_PLOT_SETTINGS = { - backgroundColor: "rgba(0,0,0,0)", + backgroundColor: "#000", cellName: CELL_ID_KEY, chartParent: "ace-scatter-chart", circleRadius: 4, - histogramColor: "rgb(164,162,164)", + histogramColor: BASE_PALETTE_COLORS.lightGray, legend: { font: { - color: "rgb(255,255,255)", + color: BASE_PALETTE_COLORS.white, }, orientation: "h" as const, y: 60, @@ -70,25 +82,13 @@ export const GENERAL_PLOT_SETTINGS = { heightMargin: 56 + 74 + 140, // header height + tab height + margins showLegendCutoffHeight: 635, showLegendCutoffWidth: 692, - textColor: "rgb(255,255,255)", + spikeColor: BASE_PALETTE_COLORS.translucentWhite, + textColor: BASE_PALETTE_COLORS.white, unselectedCircleOpacity: 0.5, }; export const NO_DOWNLOADS_TOOLTIP = "Direct download is not available for this dataset."; -const BASE_PALETTE_COLORS = { - purple: "#8950d9", - darkGray: "#313131", - mediumDarkGray: "#4b4b4b", - mediumGray: "#6e6e6e", - lightGray: "#a0a0a0", - extraLightGray: "#d8d8d8", - brightGreen: "#b2d030", - brightBlue: "#00a0ff", - white: "#ffffff", - translucentWhite: "#ffffffab", -} as const; - export const PALETTE = { ...BASE_PALETTE_COLORS, headerGray: BASE_PALETTE_COLORS.mediumDarkGray, diff --git a/src/containers/MainPlotContainer/index.tsx b/src/containers/MainPlotContainer/index.tsx index c6bed12a..2421888c 100644 --- a/src/containers/MainPlotContainer/index.tsx +++ b/src/containers/MainPlotContainer/index.tsx @@ -35,9 +35,13 @@ import type { State } from "../../state/types"; import { getDataForOverlayCard, + getFormattedHoveredXValue, + getFormattedHoveredYValue, getScatterPlotDataArray, + getXDisplayName, getXDisplayOptions, getXTickConversion, + getYDisplayName, getYDisplayOptions, getYTickConversion, getXAxisRange, @@ -60,9 +64,13 @@ interface PropsFromState { filtersToExclude: string[]; galleryCollapsed: boolean; hoveredPointData: SelectedPointData | null; + hoveredXValue: string; + hoveredYValue: string; mousePosition: MousePosition; plotDataArray: any; thumbnailRoot: string; + xDisplayName: string; + yDisplayName: string; xDropDownValue: string; yDropDownValue: string; yDropDownOptions: MeasuredFeatureDef[]; @@ -193,6 +201,8 @@ class MainPlotContainer extends React.Component ) ); @@ -354,9 +376,13 @@ function mapStateToProps(state: State): PropsFromState { filtersToExclude: selectionStateBranch.selectors.getFiltersToExclude(state), galleryCollapsed: selectionStateBranch.selectors.getGalleryCollapsed(state), hoveredPointData: getDataForOverlayCard(state), + hoveredXValue: getFormattedHoveredXValue(state), + hoveredYValue: getFormattedHoveredYValue(state), mousePosition: selectionStateBranch.selectors.getMousePosition(state), plotDataArray: getScatterPlotDataArray(state), thumbnailRoot: selectionStateBranch.selectors.getThumbnailRoot(state), + xDisplayName: getXDisplayName(state), + yDisplayName: getYDisplayName(state), xDropDownOptions: getXDisplayOptions(state), xDropDownValue: selectionStateBranch.selectors.getPlotByOnX(state), xTickConversion: getXTickConversion(state), diff --git a/src/containers/MainPlotContainer/selectors.ts b/src/containers/MainPlotContainer/selectors.ts index 3ea85f42..f52d56d7 100644 --- a/src/containers/MainPlotContainer/selectors.ts +++ b/src/containers/MainPlotContainer/selectors.ts @@ -506,3 +506,56 @@ export const getYAxisRange = createSelector( return getAxisRange(yValues); } ); + +export const getXDisplayName = createSelector( + [getPlotByOnX, getMeasuredFeaturesDefs], + (plotByOnX, featureDefs): string => { + const feature = findFeature(featureDefs, plotByOnX); + return feature?.displayName ?? plotByOnX; + } +); + +export const getYDisplayName = createSelector( + [getPlotByOnY, getMeasuredFeaturesDefs], + (plotByOnY, featureDefs): string => { + const feature = find(featureDefs, { key: plotByOnY }); + return feature?.displayName ?? plotByOnY; + } +); + +function formatAxisValue( + value: number | string | undefined, + isCategorical: boolean, + tickConversion: TickConversion +): string { + if (value === undefined) return ""; + if (typeof value === "string") return value; + if (!isFinite(value)) return ""; + if (isCategorical) { + const idx = tickConversion.tickValues.indexOf(value); + if (idx >= 0) return tickConversion.tickText[idx]; + } + return Number(value).toPrecision(4); +} + +export const getFormattedHoveredXValue = createSelector( + [getHoveredPointData, getCategoricalFeatureKeys, getPlotByOnX, getXTickConversion], + (hoveredPointData, categoricalFeatures, xKey, xTickConversion): string => { + return formatAxisValue( + hoveredPointData?.xValue, + includes(categoricalFeatures, xKey), + xTickConversion + ); + } +); + +export const getFormattedHoveredYValue = createSelector( + [getHoveredPointData, getCategoricalFeatureKeys, getPlotByOnY, getYTickConversion], + (hoveredPointData, categoricalFeatures, yKey, yTickConversion): string => { + return formatAxisValue( + hoveredPointData?.yValue, + includes(categoricalFeatures, yKey), + yTickConversion + ); + } +); diff --git a/src/containers/MainPlotContainer/test/selectors.test.ts b/src/containers/MainPlotContainer/test/selectors.test.ts index 4800bf14..3dc7899a 100644 --- a/src/containers/MainPlotContainer/test/selectors.test.ts +++ b/src/containers/MainPlotContainer/test/selectors.test.ts @@ -1,10 +1,18 @@ import { describe, it, expect } from "vitest"; import { mockState, selectedCellFileInfo } from "../../../state/test/mocks"; -import type { State, AnnotationData } from "../../../state/types"; -import { getAnnotations, handleNullValues, makeAnnotations } from "../selectors"; -import type { PlotlyAnnotation } from "../../../components/MainPlot"; -import { PALETTE } from "../../../constants"; +import type { State, AnnotationData } from "../../../state/types"; +import { + getAnnotations, + getFormattedHoveredXValue, + getFormattedHoveredYValue, + getXDisplayName, + getYDisplayName, + handleNullValues, + makeAnnotations, +} from "../selectors"; +import type { PlotlyAnnotation } from "../../../components/MainPlot"; +import { CELL_ID_KEY, PALETTE } from "../../../constants"; describe("MainPlotContainer selectors", () => { const newMockState = mockState; @@ -72,6 +80,196 @@ describe("MainPlotContainer selectors", () => { expect(result).to.have.lengthOf(2); }); }); + describe("getXDisplayName", () => { + it("returns the displayName for a continuous feature", () => { + const state: State = { + ...newMockState, + selection: { ...newMockState.selection, plotByOnX: "apical-proximity" }, + }; + expect(getXDisplayName(state)).to.equal("Apical Proximity"); + }); + + it("returns the displayName for a discrete feature", () => { + const state: State = { + ...newMockState, + selection: { ...newMockState.selection, plotByOnX: "cell-line" }, + }; + expect(getXDisplayName(state)).to.equal("Labeled Structure"); + }); + + it("falls back to the raw key when the feature is not found", () => { + const state: State = { + ...newMockState, + selection: { ...newMockState.selection, plotByOnX: "unknown-feature" }, + }; + expect(getXDisplayName(state)).to.equal("unknown-feature"); + }); + }); + + describe("getYDisplayName", () => { + it("returns the displayName for a continuous feature", () => { + const state: State = { + ...newMockState, + selection: { ...newMockState.selection, plotByOnY: "cellular-surface-area" }, + }; + expect(getYDisplayName(state)).to.equal("Cell Surface area"); + }); + + it("returns the displayName for a discrete feature", () => { + const state: State = { + ...newMockState, + selection: { ...newMockState.selection, plotByOnY: "anaphase-segmentation-complete" }, + }; + expect(getYDisplayName(state)).to.equal("Anaphase segmentation complete"); + }); + + it("falls back to the raw key when the feature is not found", () => { + const state: State = { + ...newMockState, + selection: { ...newMockState.selection, plotByOnY: "unknown-feature" }, + }; + expect(getYDisplayName(state)).to.equal("unknown-feature"); + }); + }); + + describe("getFormattedHoveredXValue", () => { + it("returns empty string when no point is hovered", () => { + const state: State = { + ...newMockState, + selection: { ...newMockState.selection, hoveredPointData: null }, + }; + expect(getFormattedHoveredXValue(state)).to.equal(""); + }); + + it("formats a continuous numeric value to 4 significant figures", () => { + const state: State = { + ...newMockState, + selection: { + ...newMockState.selection, + plotByOnX: "apical-proximity", + hoveredPointData: { + [CELL_ID_KEY]: "1", + index: 0, + thumbnailPath: "path1", + srcPath: "", + xValue: -0.25868651080317, + yValue: 1, + }, + }, + }; + expect(getFormattedHoveredXValue(state)).to.equal( + Number(-0.25868651080317).toPrecision(4) + ); + }); + + it("resolves a categorical value to its display label", () => { + const state: State = { + ...newMockState, + selection: { + ...newMockState.selection, + plotByOnX: "cell-line", + hoveredPointData: { + [CELL_ID_KEY]: "1", + index: 0, + thumbnailPath: "path1", + srcPath: "", + xValue: 5, + yValue: 0, + }, + }, + }; + expect(getFormattedHoveredXValue(state)).to.equal("Matrix adhesions"); + }); + + it("returns empty string for a non-finite value", () => { + const state: State = { + ...newMockState, + selection: { + ...newMockState.selection, + plotByOnX: "apical-proximity", + hoveredPointData: { + [CELL_ID_KEY]: "1", + index: 0, + thumbnailPath: "path1", + srcPath: "", + xValue: NaN, + yValue: 0, + }, + }, + }; + expect(getFormattedHoveredXValue(state)).to.equal(""); + }); + }); + + describe("getFormattedHoveredYValue", () => { + it("returns empty string when no point is hovered", () => { + const state: State = { + ...newMockState, + selection: { ...newMockState.selection, hoveredPointData: null }, + }; + expect(getFormattedHoveredYValue(state)).to.equal(""); + }); + + it("formats a continuous numeric value to 4 significant figures", () => { + const state: State = { + ...newMockState, + selection: { + ...newMockState.selection, + plotByOnY: "cellular-surface-area", + hoveredPointData: { + [CELL_ID_KEY]: "1", + index: 0, + thumbnailPath: "path1", + srcPath: "", + xValue: 0, + yValue: 702.3191, + }, + }, + }; + expect(getFormattedHoveredYValue(state)).to.equal( + Number(702.3191).toPrecision(4) + ); + }); + + it("resolves a categorical value to its display label", () => { + const state: State = { + ...newMockState, + selection: { + ...newMockState.selection, + plotByOnY: "anaphase-segmentation-complete", + hoveredPointData: { + [CELL_ID_KEY]: "1", + index: 0, + thumbnailPath: "path1", + srcPath: "", + xValue: 0, + yValue: 1, + }, + }, + }; + expect(getFormattedHoveredYValue(state)).to.equal("Complete"); + }); + + it("returns empty string for a non-finite value", () => { + const state: State = { + ...newMockState, + selection: { + ...newMockState.selection, + plotByOnY: "cellular-surface-area", + hoveredPointData: { + [CELL_ID_KEY]: "1", + index: 0, + thumbnailPath: "path1", + srcPath: "", + xValue: 0, + yValue: Infinity, + }, + }, + }; + expect(getFormattedHoveredYValue(state)).to.equal(""); + }); + }); + describe("makeAnnotations", () => { const baseAnnotation: AnnotationData = { cellID: "cell-42", diff --git a/src/state/selection/types.ts b/src/state/selection/types.ts index 7dcbdc08..73243734 100755 --- a/src/state/selection/types.ts +++ b/src/state/selection/types.ts @@ -115,6 +115,8 @@ export interface SelectedPointData { thumbnailPath: string; srcPath: string; groupBy?: string; + xValue?: number | string; + yValue?: number | string; } export interface ChangeHoveredPointAction {