From d3389d260d9e47b3197c5b9ff366c3026be338f2 Mon Sep 17 00:00:00 2001 From: Shihab Dider Date: Mon, 11 May 2026 11:01:23 -0400 Subject: [PATCH 1/2] feat: purity ploidy slider --- .gitignore | 6 +- src/components/binPlot/index.js | 148 +-- src/components/binPlot/index.style.js | 2 + src/components/binPlotPanel/index.js | 100 +- .../purityPloidySlider/copyStateFit.js | 328 +++++++ .../purityPloidySlider/copyStateFit.test.js | 905 ++++++++++++++++++ .../copyStateFitControls.js | 77 ++ .../copyStateFitControls.style.js | 98 ++ src/components/purityPloidySlider/index.js | 283 ++++++ .../purityPloidySlider/index.style.js | 29 + 10 files changed, 1901 insertions(+), 75 deletions(-) create mode 100644 src/components/purityPloidySlider/copyStateFit.js create mode 100644 src/components/purityPloidySlider/copyStateFit.test.js create mode 100644 src/components/purityPloidySlider/copyStateFitControls.js create mode 100644 src/components/purityPloidySlider/copyStateFitControls.style.js create mode 100644 src/components/purityPloidySlider/index.js create mode 100644 src/components/purityPloidySlider/index.style.js diff --git a/.gitignore b/.gitignore index 09c833d6..fe383044 100644 --- a/.gitignore +++ b/.gitignore @@ -70,9 +70,13 @@ packages/react-devtools-scheduling-profiler/dist# See https://help.github.com/ar .env.test.local .env.production.local .prettierrc -AGENT.md +AGENT*.md *SPEC.md thoughts/ +docs/ +.memory/ +.handoffs/ +.gitignore *.rb *.arrow diff --git a/src/components/binPlot/index.js b/src/components/binPlot/index.js index a10ebd47..5d776be0 100644 --- a/src/components/binPlot/index.js +++ b/src/components/binPlot/index.js @@ -5,6 +5,12 @@ import { connect } from "react-redux"; import { withTranslation } from "react-i18next"; import { measureText, segmentAttributes } from "../../helpers/utility"; import { maxSeparatorsCount } from "../../helpers/segmentWidth"; +import { + generateCopyStateSeparators, + metadataToCopyStateFit, + selectActiveCopyStateFit, +} from "../purityPloidySlider/copyStateFit"; +import PurityPloidySlider from "../purityPloidySlider"; import Wrapper from "./index.style"; const margins = { @@ -60,6 +66,22 @@ class BinPlot extends Component { this.renderZoom(); } + getActiveCopyStateFit(metadataFit = metadataToCopyStateFit(this.props.separatorsConfig)) { + const { activeCopyStateFit } = this.props; + const fitUiState = { + metadataFit: + activeCopyStateFit?.source === "metadata" ? activeCopyStateFit : metadataFit, + previewFit: + activeCopyStateFit?.source === "preview" ? activeCopyStateFit : null, + appliedOverrideFit: + activeCopyStateFit?.source === "appliedOverride" + ? activeCopyStateFit + : null, + }; + + return selectActiveCopyStateFit(fitUiState); + } + getPlotConfiguration() { const { width, @@ -80,27 +102,30 @@ class BinPlot extends Component { let panelWidth = stageWidth; let panelHeight = stageHeight; - const { beta, purity } = separatorsConfig; - let a = (2 * (1 - purity)) / purity; - let b = 1 / beta; - let ppfit_intercept = a / b; - let ppfit_slope = beta; - - let finalMaxMean = ppfit_intercept + maxSeparatorsCount * ppfit_slope; + const metadataFit = metadataToCopyStateFit(separatorsConfig); + const activeFit = this.getActiveCopyStateFit(metadataFit); + const separators = generateCopyStateSeparators( + activeFit, + maxSeparatorsCount + ); + const metadataSeparators = generateCopyStateSeparators( + metadataFit, + maxSeparatorsCount + ); + let finalMaxMean = metadataSeparators[maxSeparatorsCount].segmentMean; let filteredData = data .filter((d) => d.metadata.mean) - .map((d) => { - d.metadata.mean = d3.min([d.metadata.mean, finalMaxMean]); - return d; - }); + .map((d) => ({ + ...d, + metadata: { + ...d.metadata, + mean: d3.min([d.metadata.mean, finalMaxMean]), + }, + })); let extent = [0, finalMaxMean]; - let separators = d3 - .range(0, maxSeparatorsCount + 1) - .map((i) => ppfit_slope * i + ppfit_intercept); - let num = Math.ceil(panelWidth / minBarWidth); let step = (extent[1] - extent[0]) / num; @@ -201,6 +226,7 @@ class BinPlot extends Component { chromoBins, selectSegment, separators, + activeCopyStateFit: activeFit, }; } @@ -306,6 +332,7 @@ class BinPlot extends Component { chromoBins, selectSegment, separators, + activeCopyStateFit, } = this.getPlotConfiguration(); const { tooltip, segmentId, currentTransform } = this.state; @@ -359,61 +386,34 @@ class BinPlot extends Component { }} /> - - {separators.map((d, i) => ( - - + + {series.map((d, i) => ( + this.handleMouseMove(e, d, i)} + onMouseOut={(e) => this.handleMouseOut(e, d)} + onClick={(e) => selectSegment(d)} + stroke="#FFF" + strokeWidth={0.5} + rx={1} + opacity={!segmentId || d.iid === segmentId ? 1 : 0.13} /> - - {i} - - - {d3.format(".3f")(d)} - - - ))} - - - {series.map((d, i) => ( - this.handleMouseMove(e, d, i)} - onMouseOut={(e) => this.handleMouseOut(e, d)} - onClick={(e) => selectSegment(d)} - stroke="#FFF" - strokeWidth={0.5} - rx={1} - opacity={!segmentId || d.iid === segmentId ? 1 : 0.13} - /> - ))} - + ))} + + { @@ -72,6 +85,73 @@ class BinPlotPanel extends Component { this.setState({ open: false }); }; + handleCopyStateFitPreview = (drag) => { + if (drag == null) { + return; + } + + this.setState((state, props) => { + const metadata = props.metadata || {}; + const uiState = + state.copyStateFitUiState || + createCopyStateFitUiState( + metadataToCopyStateFit({ + beta: metadata.beta, + purity: metadata.purity, + meanSegmentValue: metadata.meanSegmentValue, + }) + ); + + return { + copyStateFitUiState: previewCopyStateFitDrag(uiState, drag), + }; + }); + }; + + transformCopyStateFitUiState = (transformUiState) => { + if (this.state.copyStateFitUiState == null) { + return; + } + + this.setState((state) => ({ + copyStateFitUiState: transformUiState(state.copyStateFitUiState), + })); + }; + + handleApplyCopyStateFit = () => { + this.transformCopyStateFitUiState(applyPreviewCopyStateFit); + }; + + handleResetCopyStateFit = () => { + this.transformCopyStateFitUiState(resetCopyStateFitUiState); + }; + + renderCopyStateFitControls = () => { + const { copyStateFitUiState } = this.state; + const { metadata = {} } = this.props; + const activeCopyStateFit = + copyStateFitUiState == null + ? metadataToCopyStateFit({ + beta: metadata.beta, + purity: metadata.purity, + meanSegmentValue: metadata.meanSegmentValue, + }) + : selectActiveCopyStateFit(copyStateFitUiState); + const hasPreview = copyStateFitUiState?.previewFit != null; + const hasFitSession = copyStateFitUiState != null; + + return ( + this.handleCopyStateFitPreview()} + onReset={this.handleResetCopyStateFit} + /> + ); + }; + render() { const { t, @@ -91,8 +171,10 @@ class BinPlotPanel extends Component { } = this.props; const { beta, gamma, purity } = metadata; + const hasPpfitData = ppfit.data.intervals.length > 0; + const shouldDisplayPanel = visible && (loading || hasPpfitData || inViewport !== false); - if (!metadata.pair || ppfit.data.intervals.length < 1) { + if (!metadata.pair || (!loading && !hasPpfitData)) { return null; } const { segment, open } = this.state; @@ -122,7 +204,7 @@ class BinPlotPanel extends Component { /> ) : ( } size="small" onClick={() => this.onDownloadButtonClicked()} @@ -150,7 +232,7 @@ class BinPlotPanel extends Component { } > - {visible && ( + {shouldDisplayPanel && hasPpfitData && (
(this.container = elem)} @@ -225,9 +307,10 @@ class BinPlotPanel extends Component { {({ width, height }) => { return ( - (inViewport) && ( + shouldDisplayPanel && ( + {this.renderCopyStateFitControls()} this.handleSelectSegment(e), separatorsConfig: { beta, purity }, + activeCopyStateFit: + this.state.copyStateFitUiState?.previewFit || + this.state.copyStateFitUiState + ?.appliedOverrideFit || + this.state.copyStateFitUiState?.metadataFit, + onCopyStateFitPreview: (drag) => + this.handleCopyStateFitPreview(drag), }} /> diff --git a/src/components/purityPloidySlider/copyStateFit.js b/src/components/purityPloidySlider/copyStateFit.js new file mode 100644 index 00000000..e848c61e --- /dev/null +++ b/src/components/purityPloidySlider/copyStateFit.js @@ -0,0 +1,328 @@ +import { maxSeparatorsCount } from "../../helpers/segmentWidth"; + +export const copyStateFitSources = Object.freeze({ + metadata: "metadata", + preview: "preview", + appliedOverride: "appliedOverride", +}); + +function createValidatedCopyStateFit({ + slope, + intercept, + spacing, + zeroCopyOffset, + source, + meanSegmentValue = 1, + isValid = true, + errorMessage, +}) { + if ( + !isValid || + !Number.isFinite(spacing) || + spacing <= 0 || + !Number.isFinite(slope) || + slope <= 0 || + !Number.isFinite(zeroCopyOffset) || + !Number.isFinite(intercept) + ) { + throw new Error(errorMessage); + } + + const copyStateFit = { + slope, + intercept, + spacing, + zeroCopyOffset, + purity: derivePurityFromIntercept(intercept), + ploidy: 0, + source, + }; + + copyStateFit.ploidy = derivePloidyFromFit(copyStateFit, meanSegmentValue); + + if (!Number.isFinite(copyStateFit.ploidy)) { + throw new Error(errorMessage); + } + + return copyStateFit; +} + +/** + * @typedef {"metadata" | "preview" | "appliedOverride"} CopyStateFitSource + * + * @typedef {Object} CopyStateFit + * @property {number} slope - Copy-number slope where copy_number = slope * segment_mean + intercept; invariant: slope > 0. + * @property {number} intercept - Copy-number intercept where copy_number = slope * segment_mean + intercept. + * @property {number} spacing - Plot-native distance between adjacent copy-state separators; invariant: spacing > 0 and slope = 1 / spacing. + * @property {number} zeroCopyOffset - Segment-mean position for copy state 0; invariant: intercept = -zeroCopyOffset / spacing. + * @property {number} purity - Derived purity, normally 2 / (2 - intercept). + * @property {number} ploidy - Derived displayed ploidy. This frontend fit assumes normalized mean_segment_value = 1 unless a caller supplies another value. + * @property {CopyStateFitSource} source - Origin of this fit in the frontend session. + * + * @typedef {Object} CopyStateSeparator + * @property {number} copyState - Integer copy-state label represented by this separator. + * @property {number} segmentMean - Segment-mean x-position for this separator. + * + * @typedef {Object} CopyStateFitUiState + * @property {CopyStateFit} metadataFit - Metadata-derived base fit; Reset always restores this fit. + * @property {?CopyStateFit} previewFit - Transient frontend fit produced by drag preview before Apply. + * @property {?CopyStateFit} appliedOverrideFit - Session-only applied frontend override; never persisted to backend metadata. + * + * @typedef {Object} CopyStateFitDrag + * @property {"shift" | "spacing"} mode - Normal drag shifts the family; modifier-drag edits spacing. + * @property {number} copyState - Dragged separator copy state. Spacing mode must use a nonzero copy state. + * @property {number} segmentMean - Dragged separator x-position in segment-mean units. + * @property {number} [deltaSegmentMean] - Normal-drag horizontal delta in segment-mean units. + * @property {CopyStateFit} [startFit] - Stable active fit captured at drag start; used so repeated preview updates do not compound. + */ + +/** + * Convert metadata beta/purity into the explicit CopyStateFit representation. + * Gamma remains unused for copy-state separator math in issue-0001. + * + * @param {{ beta: number, purity: number, meanSegmentValue?: number }} metadata + * @returns {CopyStateFit} + */ +export function metadataToCopyStateFit(metadata) { + const spacing = metadata.beta; + const slope = 1 / spacing; + const intercept = -(2 / metadata.purity - 2); + const zeroCopyOffset = -intercept * spacing; + + return createValidatedCopyStateFit({ + slope, + intercept, + spacing, + zeroCopyOffset, + source: copyStateFitSources.metadata, + meanSegmentValue: metadata.meanSegmentValue, + errorMessage: "metadataToCopyStateFit produced an invalid fit", + }); +} + +/** + * Generate copy-state separators 0..maxCopyState from the active CopyStateFit. + * + * @param {CopyStateFit} copyStateFit + * @param {number} [maxCopyState] + * @returns {CopyStateSeparator[]} + */ +export function generateCopyStateSeparators( + copyStateFit, + maxCopyState = maxSeparatorsCount +) { + const separators = []; + const lastCopyState = Math.floor(maxCopyState); + + for (let copyState = 0; copyState <= lastCopyState; copyState += 1) { + separators.push({ + copyState, + segmentMean: + copyStateFit.zeroCopyOffset + copyStateFit.spacing * copyState, + }); + } + + return separators; +} + +/** + * Produce a preview fit by shifting the whole separator family while preserving spacing. + * + * @param {CopyStateFit} copyStateFit + * @param {number} deltaSegmentMean + * @returns {CopyStateFit} + */ +export function shiftCopyStateFit(copyStateFit, deltaSegmentMean) { + const spacing = copyStateFit.spacing; + const slope = 1 / spacing; + const zeroCopyOffset = copyStateFit.zeroCopyOffset + deltaSegmentMean; + const intercept = -zeroCopyOffset / spacing; + + return createValidatedCopyStateFit({ + slope, + intercept, + spacing, + zeroCopyOffset, + source: copyStateFitSources.preview, + errorMessage: "shiftCopyStateFit produced an invalid fit", + }); +} + +/** + * Produce a preview fit from a modifier-drag of a nonzero separator, anchored at copy state 0. + * + * @param {CopyStateFit} copyStateFit + * @param {number} copyState + * @param {number} draggedSegmentMean + * @returns {CopyStateFit} + */ +export function resizeCopyStateFitFromAnchoredSeparator( + copyStateFit, + copyState, + draggedSegmentMean +) { + const zeroCopyOffset = copyStateFit.zeroCopyOffset; + const spacing = (draggedSegmentMean - zeroCopyOffset) / copyState; + const slope = 1 / spacing; + const intercept = -zeroCopyOffset / spacing; + + return createValidatedCopyStateFit({ + slope, + intercept, + spacing, + zeroCopyOffset, + source: copyStateFitSources.preview, + isValid: + Number.isFinite(copyState) && + copyState !== 0 && + Number.isFinite(draggedSegmentMean), + errorMessage: "resizeCopyStateFitFromAnchoredSeparator produced an invalid fit", + }); +} + +/** + * Derive purity from copy-number intercept. + * + * @param {number} intercept + * @returns {number} + */ +export function derivePurityFromIntercept(intercept) { + if (!Number.isFinite(intercept)) { + throw new Error( + "derivePurityFromIntercept produced a non-finite purity" + ); + } + + const purity = 2 / (2 - intercept); + + if (!Number.isFinite(purity)) { + throw new Error( + "derivePurityFromIntercept produced a non-finite purity" + ); + } + + return purity; +} + +/** + * Derive displayed ploidy from a fit. Uses normalized mean_segment_value = 1 by default. + * + * @param {CopyStateFit} copyStateFit + * @param {number} [meanSegmentValue] + * @returns {number} + */ +export function derivePloidyFromFit(copyStateFit, meanSegmentValue = 1) { + const ploidy = copyStateFit.slope * meanSegmentValue + copyStateFit.intercept; + + return ploidy; +} + +/** + * Create UI fit state from the metadata-derived base fit. + * + * @param {CopyStateFit} metadataFit + * @returns {CopyStateFitUiState} + */ +export function createCopyStateFitUiState(metadataFit) { + return { + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }; +} + +/** + * Select the currently active fit, preferring preview over applied override over metadata. + * + * @param {CopyStateFitUiState} uiState + * @returns {CopyStateFit} + */ +export function selectActiveCopyStateFit(uiState) { + if (uiState.previewFit != null) { + return uiState.previewFit; + } + + if (uiState.appliedOverrideFit != null) { + return uiState.appliedOverrideFit; + } + + return uiState.metadataFit; +} + +/** + * Convert a separator drag event into preview UI state without mutating backend metadata. + * + * @param {CopyStateFitUiState} uiState + * @param {CopyStateFitDrag} drag + * @returns {CopyStateFitUiState} + */ +export function previewCopyStateFitDrag(uiState, drag) { + const activeFit = selectActiveCopyStateFit(uiState); + const dragBaseFit = drag.startFit || activeFit; + let previewFit; + + if (drag.mode === "shift") { + const currentDraggedSegmentMean = + dragBaseFit.zeroCopyOffset + dragBaseFit.spacing * drag.copyState; + const deltaSegmentMean = + drag.deltaSegmentMean === undefined + ? drag.segmentMean - currentDraggedSegmentMean + : drag.deltaSegmentMean; + + previewFit = shiftCopyStateFit(dragBaseFit, deltaSegmentMean); + } else if (drag.mode === "spacing") { + previewFit = resizeCopyStateFitFromAnchoredSeparator( + dragBaseFit, + drag.copyState, + drag.segmentMean + ); + } else { + throw new Error("previewCopyStateFitDrag received an unsupported drag mode"); + } + + return { + metadataFit: uiState.metadataFit, + previewFit, + appliedOverrideFit: uiState.appliedOverrideFit, + }; +} + +/** + * Promote preview fit to a session-only applied frontend override and clear preview. + * + * @param {CopyStateFitUiState} uiState + * @returns {CopyStateFitUiState} + */ +export function applyPreviewCopyStateFit(uiState) { + if (uiState.previewFit == null) { + return { + ...uiState, + previewFit: null, + }; + } + + const activeFit = selectActiveCopyStateFit(uiState); + + return { + metadataFit: uiState.metadataFit, + previewFit: null, + appliedOverrideFit: { + ...activeFit, + source: copyStateFitSources.appliedOverride, + }, + }; +} + +/** + * Clear preview/applied frontend edits and restore the metadata-derived fit. + * + * @param {CopyStateFitUiState} uiState + * @returns {CopyStateFitUiState} + */ +export function resetCopyStateFitUiState(uiState) { + return { + metadataFit: uiState.metadataFit, + previewFit: null, + appliedOverrideFit: null, + }; +} diff --git a/src/components/purityPloidySlider/copyStateFit.test.js b/src/components/purityPloidySlider/copyStateFit.test.js new file mode 100644 index 00000000..24abcef2 --- /dev/null +++ b/src/components/purityPloidySlider/copyStateFit.test.js @@ -0,0 +1,905 @@ +import { + applyPreviewCopyStateFit, + createCopyStateFitUiState, + derivePloidyFromFit, + derivePurityFromIntercept, + generateCopyStateSeparators, + metadataToCopyStateFit, + previewCopyStateFitDrag, + resetCopyStateFitUiState, + resizeCopyStateFitFromAnchoredSeparator, + selectActiveCopyStateFit, + shiftCopyStateFit, +} from "./copyStateFit"; + +void metadataToCopyStateFit; +void generateCopyStateSeparators; +void shiftCopyStateFit; +void resizeCopyStateFitFromAnchoredSeparator; +void createCopyStateFitUiState; +void derivePurityFromIntercept; +void derivePloidyFromFit; +void previewCopyStateFitDrag; +void applyPreviewCopyStateFit; +void resetCopyStateFitUiState; +void selectActiveCopyStateFit; + +describe("copy-state fit math", () => { + describe("derivePurityFromIntercept", () => { + test("computes fit-derived purity from a typical negative intercept", () => { + expect(derivePurityFromIntercept(-2)).toBeCloseTo(0.5); + }); + + test("returns unit purity for a zero intercept", () => { + expect(derivePurityFromIntercept(0)).toBeCloseTo(1); + }); + + test("supports positive intercepts when the derived purity is finite", () => { + expect(derivePurityFromIntercept(1)).toBeCloseTo(2); + }); + + test("allows finite negative purity above the intercept singularity", () => { + expect(derivePurityFromIntercept(3)).toBeCloseTo(-2); + }); + + test("throws when intercept 2 would produce an infinite purity", () => { + expect(() => derivePurityFromIntercept(2)).toThrow( + "derivePurityFromIntercept produced a non-finite purity" + ); + }); + + test("throws when the derived purity is NaN", () => { + expect(() => derivePurityFromIntercept(Number.NaN)).toThrow( + "derivePurityFromIntercept produced a non-finite purity" + ); + }); + + test("throws when the input intercept is non-finite", () => { + expect(() => derivePurityFromIntercept(Number.POSITIVE_INFINITY)).toThrow( + "derivePurityFromIntercept produced a non-finite purity" + ); + }); + }); + + describe("derivePloidyFromFit", () => { + const baseFit = { + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }; + + test("defaults to the normalized segment mean ploidy assumption", () => { + expect(derivePloidyFromFit(baseFit)).toBeCloseTo(2); + }); + + test("uses a caller-supplied mean segment value when available", () => { + expect( + derivePloidyFromFit( + { + ...baseFit, + slope: 2.5, + intercept: -1, + }, + 3 + ) + ).toBeCloseTo(6.5); + }); + + test("supports negative mean segment values by applying the direct fit formula", () => { + expect( + derivePloidyFromFit( + { + ...baseFit, + slope: 1.5, + intercept: -0.5, + }, + -2 + ) + ).toBeCloseTo(-3.5); + }); + + test("returns the intercept when mean segment value is zero", () => { + expect( + derivePloidyFromFit( + { + ...baseFit, + slope: 2.5, + intercept: -1.25, + }, + 0 + ) + ).toBeCloseTo(-1.25); + }); + + test("supports a zero intercept", () => { + expect( + derivePloidyFromFit({ + ...baseFit, + slope: 0.75, + intercept: 0, + }) + ).toBeCloseTo(0.75); + }); + }); + + describe("metadataToCopyStateFit", () => { + test("converts beta and purity to explicit metadata-derived fit values", () => { + expect(metadataToCopyStateFit({ beta: 0.25, purity: 0.5 })).toEqual({ + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }); + }); + + test("uses meanSegmentValue when deriving displayed ploidy", () => { + expect( + metadataToCopyStateFit({ + beta: 0.5, + purity: 0.8, + meanSegmentValue: 3, + }) + ).toEqual({ + slope: 2, + intercept: -0.5, + spacing: 0.5, + zeroCopyOffset: 0.25, + purity: 0.8, + ploidy: 5.5, + source: "metadata", + }); + }); + + test("supports purity one with a zero-copy separator at segment mean zero", () => { + expect(metadataToCopyStateFit({ beta: 2, purity: 1 })).toEqual({ + slope: 0.5, + intercept: -0, + spacing: 2, + zeroCopyOffset: 0, + purity: 1, + ploidy: 0.5, + source: "metadata", + }); + }); + + test("keeps gamma unused when present on metadata", () => { + const fit = metadataToCopyStateFit({ beta: 1, purity: 0.5, gamma: 42 }); + + expect(fit).not.toHaveProperty("gamma"); + expect(fit).toMatchObject({ + slope: 1, + intercept: -2, + spacing: 1, + zeroCopyOffset: 2, + purity: 0.5, + ploidy: -1, + source: "metadata", + }); + }); + + test("throws when beta cannot produce a finite positive slope", () => { + expect(() => metadataToCopyStateFit({ beta: 0, purity: 0.5 })).toThrow( + "metadataToCopyStateFit produced an invalid fit" + ); + }); + }); + + describe("createCopyStateFitUiState", () => { + test("initializes UI state with the metadata base fit and no frontend edits", () => { + const metadataFit = { + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }; + + expect(createCopyStateFitUiState(metadataFit)).toEqual({ + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }); + }); + + test("preserves the metadata fit object reference", () => { + const metadataFit = { + slope: 2, + intercept: 0, + spacing: 0.5, + zeroCopyOffset: 0, + purity: 1, + ploidy: 2, + source: "metadata", + }; + + const uiState = createCopyStateFitUiState(metadataFit); + + expect(uiState.metadataFit).toBe(metadataFit); + }); + + test("supports zero-valued coordinates in the metadata-derived fit", () => { + const metadataFit = { + slope: 1, + intercept: 0, + spacing: 1, + zeroCopyOffset: 0, + purity: 1, + ploidy: 1, + source: "metadata", + }; + + expect(createCopyStateFitUiState(metadataFit)).toEqual({ + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }); + }); + }); + + describe("selectActiveCopyStateFit", () => { + const metadataFit = { + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }; + + const appliedOverrideFit = { + slope: 5, + intercept: -3, + spacing: 0.2, + zeroCopyOffset: 0.6, + purity: 0.4, + ploidy: 2, + source: "appliedOverride", + }; + + const previewFit = { + slope: 2, + intercept: 0, + spacing: 0.5, + zeroCopyOffset: 0, + purity: 1, + ploidy: 2, + source: "preview", + }; + + test("returns the metadata-derived fit when there are no frontend edits", () => { + expect( + selectActiveCopyStateFit({ + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }) + ).toBe(metadataFit); + }); + + test("returns the applied frontend override when there is no preview fit", () => { + expect( + selectActiveCopyStateFit({ + metadataFit, + previewFit: null, + appliedOverrideFit, + }) + ).toBe(appliedOverrideFit); + }); + + test("prefers the preview fit over an applied frontend override", () => { + expect( + selectActiveCopyStateFit({ + metadataFit, + previewFit, + appliedOverrideFit, + }) + ).toBe(previewFit); + }); + + test("supports zero-valued coordinates in the active preview fit", () => { + expect( + selectActiveCopyStateFit({ + metadataFit, + previewFit, + appliedOverrideFit: null, + }) + ).toEqual({ + slope: 2, + intercept: 0, + spacing: 0.5, + zeroCopyOffset: 0, + purity: 1, + ploidy: 2, + source: "preview", + }); + }); + }); + + describe("generateCopyStateSeparators", () => { + const baseFit = { + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }; + + test("generates the default copy states 0..10 with fit-derived positions", () => { + expect(generateCopyStateSeparators(baseFit)).toEqual([ + { copyState: 0, segmentMean: 0.5 }, + { copyState: 1, segmentMean: 0.75 }, + { copyState: 2, segmentMean: 1 }, + { copyState: 3, segmentMean: 1.25 }, + { copyState: 4, segmentMean: 1.5 }, + { copyState: 5, segmentMean: 1.75 }, + { copyState: 6, segmentMean: 2 }, + { copyState: 7, segmentMean: 2.25 }, + { copyState: 8, segmentMean: 2.5 }, + { copyState: 9, segmentMean: 2.75 }, + { copyState: 10, segmentMean: 3 }, + ]); + }); + + test("uses a custom max copy state inclusively", () => { + expect(generateCopyStateSeparators(baseFit, 3)).toEqual([ + { copyState: 0, segmentMean: 0.5 }, + { copyState: 1, segmentMean: 0.75 }, + { copyState: 2, segmentMean: 1 }, + { copyState: 3, segmentMean: 1.25 }, + ]); + }); + + test("returns only the zero-copy separator when max copy state is zero", () => { + expect(generateCopyStateSeparators(baseFit, 0)).toEqual([ + { copyState: 0, segmentMean: 0.5 }, + ]); + }); + + test("returns no separators when max copy state is negative", () => { + expect(generateCopyStateSeparators(baseFit, -1)).toEqual([]); + }); + + test("generates only integer copy states up to a fractional max copy state", () => { + expect(generateCopyStateSeparators(baseFit, 2.5)).toEqual([ + { copyState: 0, segmentMean: 0.5 }, + { copyState: 1, segmentMean: 0.75 }, + { copyState: 2, segmentMean: 1 }, + ]); + }); + + test("supports negative zero-copy offsets", () => { + expect( + generateCopyStateSeparators( + { + ...baseFit, + spacing: 1.5, + zeroCopyOffset: -0.5, + }, + 2 + ) + ).toEqual([ + { copyState: 0, segmentMean: -0.5 }, + { copyState: 1, segmentMean: 1 }, + { copyState: 2, segmentMean: 2.5 }, + ]); + }); + }); + describe("shiftCopyStateFit", () => { + const baseFit = { + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }; + + test("shifts all separator positions by a positive delta while preserving spacing", () => { + const shiftedFit = shiftCopyStateFit(baseFit, 0.125); + + expect(shiftedFit).toMatchObject({ + slope: 4, + intercept: -2.5, + spacing: 0.25, + zeroCopyOffset: 0.625, + ploidy: 1.5, + source: "preview", + }); + expect(shiftedFit.purity).toBeCloseTo(2 / 4.5); + expect(generateCopyStateSeparators(shiftedFit, 2)).toEqual([ + { copyState: 0, segmentMean: 0.625 }, + { copyState: 1, segmentMean: 0.875 }, + { copyState: 2, segmentMean: 1.125 }, + ]); + }); + + test("shifts all separator positions by a negative delta", () => { + const shiftedFit = shiftCopyStateFit(baseFit, -0.25); + + expect(shiftedFit).toMatchObject({ + slope: 4, + intercept: -1, + spacing: 0.25, + zeroCopyOffset: 0.25, + ploidy: 3, + source: "preview", + }); + expect(shiftedFit.purity).toBeCloseTo(2 / 3); + expect(generateCopyStateSeparators(shiftedFit, 2)).toEqual([ + { copyState: 0, segmentMean: 0.25 }, + { copyState: 1, segmentMean: 0.5 }, + { copyState: 2, segmentMean: 0.75 }, + ]); + }); + + test("returns a preview copy without mutating the input fit when delta is zero", () => { + const originalFit = { ...baseFit }; + const shiftedFit = shiftCopyStateFit(originalFit, 0); + + expect(shiftedFit).not.toBe(originalFit); + expect(shiftedFit).toEqual({ + ...baseFit, + source: "preview", + }); + expect(originalFit).toEqual(baseFit); + }); + + test("throws when a shift would make derived purity non-finite", () => { + expect(() => shiftCopyStateFit(baseFit, -1)).toThrow( + "derivePurityFromIntercept produced a non-finite purity" + ); + }); + }); + describe("resizeCopyStateFitFromAnchoredSeparator", () => { + const baseFit = { + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }; + + test("changes spacing from a modifier-drag while anchoring copy state 0", () => { + const resizedFit = resizeCopyStateFitFromAnchoredSeparator(baseFit, 2, 1.5); + + expect(resizedFit).toMatchObject({ + slope: 2, + intercept: -1, + spacing: 0.5, + zeroCopyOffset: 0.5, + ploidy: 1, + source: "preview", + }); + expect(resizedFit.purity).toBeCloseTo(2 / 3); + expect(generateCopyStateSeparators(resizedFit, 2)).toEqual([ + { copyState: 0, segmentMean: 0.5 }, + { copyState: 1, segmentMean: 1 }, + { copyState: 2, segmentMean: 1.5 }, + ]); + }); + + test("returns a preview copy without mutating the input fit when spacing is unchanged", () => { + const originalFit = { ...baseFit }; + const resizedFit = resizeCopyStateFitFromAnchoredSeparator( + originalFit, + 1, + 0.75 + ); + + expect(resizedFit).not.toBe(originalFit); + expect(resizedFit).toEqual({ + ...baseFit, + source: "preview", + }); + expect(originalFit).toEqual(baseFit); + }); + + test("throws when the dragged separator is copy state 0", () => { + expect(() => + resizeCopyStateFitFromAnchoredSeparator(baseFit, 0, 0.75) + ).toThrow("resizeCopyStateFitFromAnchoredSeparator produced an invalid fit"); + }); + + test("throws when the dragged position would make spacing zero", () => { + expect(() => + resizeCopyStateFitFromAnchoredSeparator(baseFit, 2, 0.5) + ).toThrow("resizeCopyStateFitFromAnchoredSeparator produced an invalid fit"); + }); + + test("throws when the dragged position would make spacing negative", () => { + expect(() => + resizeCopyStateFitFromAnchoredSeparator(baseFit, 2, 0) + ).toThrow("resizeCopyStateFitFromAnchoredSeparator produced an invalid fit"); + }); + }); + + describe("previewCopyStateFitDrag", () => { + const metadataFit = { + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }; + + const appliedOverrideFit = { + slope: 5, + intercept: -3, + spacing: 0.2, + zeroCopyOffset: 0.6, + purity: 0.4, + ploidy: 2, + source: "appliedOverride", + }; + + const previewFit = { + slope: 2, + intercept: -1, + spacing: 0.5, + zeroCopyOffset: 0.5, + purity: 2 / 3, + ploidy: 1, + source: "preview", + }; + + test("routes a normal drag with explicit delta to a transient shift preview", () => { + const uiState = { + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }; + + const nextState = previewCopyStateFitDrag(uiState, { + mode: "shift", + copyState: 2, + segmentMean: 1.125, + deltaSegmentMean: 0.125, + }); + + expect(nextState).toEqual({ + metadataFit, + previewFit: { + slope: 4, + intercept: -2.5, + spacing: 0.25, + zeroCopyOffset: 0.625, + purity: 2 / 4.5, + ploidy: 1.5, + source: "preview", + }, + appliedOverrideFit: null, + }); + expect(nextState).not.toBe(uiState); + expect(nextState.metadataFit).toBe(metadataFit); + expect(uiState).toEqual({ + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }); + }); + + test("uses the drag-start fit for bidirectional normal-drag previews instead of compounding on the current preview", () => { + const uiState = { + metadataFit, + previewFit, + appliedOverrideFit: null, + }; + + const nextState = previewCopyStateFitDrag(uiState, { + mode: "shift", + copyState: 2, + segmentMean: 0.875, + deltaSegmentMean: -0.125, + startFit: metadataFit, + }); + + expect(nextState.previewFit).toMatchObject({ + slope: 4, + intercept: -1.5, + spacing: 0.25, + zeroCopyOffset: 0.375, + ploidy: 2.5, + source: "preview", + }); + expect(nextState.previewFit.purity).toBeCloseTo(2 / 3.5); + expect(nextState.metadataFit).toBe(metadataFit); + expect(nextState.appliedOverrideFit).toBeNull(); + }); + + test("derives a normal-drag delta from the dragged separator position when no delta is supplied", () => { + const nextState = previewCopyStateFitDrag( + { + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }, + { + mode: "shift", + copyState: 2, + segmentMean: 1.125, + } + ); + + expect(nextState.previewFit).toMatchObject({ + slope: 4, + intercept: -2.5, + spacing: 0.25, + zeroCopyOffset: 0.625, + ploidy: 1.5, + source: "preview", + }); + expect(nextState.previewFit.purity).toBeCloseTo(2 / 4.5); + }); + + test("routes a spacing-edit drag to an anchored spacing preview", () => { + const nextState = previewCopyStateFitDrag( + { + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }, + { + mode: "spacing", + copyState: 2, + segmentMean: 1.5, + } + ); + + expect(nextState.previewFit).toMatchObject({ + slope: 2, + intercept: -1, + spacing: 0.5, + zeroCopyOffset: 0.5, + ploidy: 1, + source: "preview", + }); + expect(nextState.previewFit.purity).toBeCloseTo(2 / 3); + expect(nextState.metadataFit).toBe(metadataFit); + expect(nextState.appliedOverrideFit).toBeNull(); + }); + + test("uses an applied frontend override as the drag base when no preview exists", () => { + const nextState = previewCopyStateFitDrag( + { + metadataFit, + previewFit: null, + appliedOverrideFit, + }, + { + mode: "shift", + copyState: 1, + segmentMean: 0.85, + deltaSegmentMean: 0.05, + } + ); + + expect(nextState).toEqual({ + metadataFit, + previewFit: { + slope: 5, + intercept: -3.25, + spacing: 0.2, + zeroCopyOffset: 0.65, + purity: 2 / 5.25, + ploidy: 1.75, + source: "preview", + }, + appliedOverrideFit, + }); + expect(nextState.appliedOverrideFit).toBe(appliedOverrideFit); + }); + + test("uses an existing preview fit as the drag base for continued preview updates", () => { + const nextState = previewCopyStateFitDrag( + { + metadataFit, + previewFit, + appliedOverrideFit, + }, + { + mode: "spacing", + copyState: 2, + segmentMean: 2, + } + ); + + expect(nextState.previewFit).toMatchObject({ + slope: 4 / 3, + intercept: -2 / 3, + spacing: 0.75, + zeroCopyOffset: 0.5, + ploidy: 2 / 3, + source: "preview", + }); + expect(nextState.previewFit.purity).toBeCloseTo(0.75); + expect(nextState.metadataFit).toBe(metadataFit); + expect(nextState.appliedOverrideFit).toBe(appliedOverrideFit); + }); + }); + + describe("applyPreviewCopyStateFit", () => { + const metadataFit = { + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }; + + const previewFit = { + slope: 2, + intercept: -1, + spacing: 0.5, + zeroCopyOffset: 0.5, + purity: 2 / 3, + ploidy: 1, + source: "preview", + }; + + const appliedOverrideFit = { + slope: 5, + intercept: -3, + spacing: 0.2, + zeroCopyOffset: 0.6, + purity: 0.4, + ploidy: 2, + source: "appliedOverride", + }; + + test("promotes an existing preview fit to a session-only applied override", () => { + const uiState = { + metadataFit, + previewFit, + appliedOverrideFit: null, + }; + + const appliedState = applyPreviewCopyStateFit(uiState); + + expect(appliedState).toEqual({ + metadataFit, + previewFit: null, + appliedOverrideFit: { + ...previewFit, + source: "appliedOverride", + }, + }); + expect(appliedState.appliedOverrideFit).not.toBe(previewFit); + expect(selectActiveCopyStateFit(appliedState)).toBe( + appliedState.appliedOverrideFit + ); + expect(uiState).toEqual({ + metadataFit, + previewFit, + appliedOverrideFit: null, + }); + expect(previewFit.source).toBe("preview"); + }); + + test("replaces an older applied override when a newer preview is applied", () => { + const appliedState = applyPreviewCopyStateFit({ + metadataFit, + previewFit, + appliedOverrideFit, + }); + + expect(appliedState).toEqual({ + metadataFit, + previewFit: null, + appliedOverrideFit: { + ...previewFit, + source: "appliedOverride", + }, + }); + expect(appliedState.appliedOverrideFit).not.toBe(appliedOverrideFit); + }); + + test("leaves state unchanged when there is no preview fit to apply", () => { + const uiState = { + metadataFit, + previewFit: null, + appliedOverrideFit, + }; + + expect(applyPreviewCopyStateFit(uiState)).toEqual(uiState); + }); + }); + + describe("resetCopyStateFitUiState", () => { + const metadataFit = { + slope: 4, + intercept: -2, + spacing: 0.25, + zeroCopyOffset: 0.5, + purity: 0.5, + ploidy: 2, + source: "metadata", + }; + + const previewFit = { + slope: 2, + intercept: -1, + spacing: 0.5, + zeroCopyOffset: 0.5, + purity: 2 / 3, + ploidy: 1, + source: "preview", + }; + + const appliedOverrideFit = { + slope: 5, + intercept: -3, + spacing: 0.2, + zeroCopyOffset: 0.6, + purity: 0.4, + ploidy: 2, + source: "appliedOverride", + }; + + test("clears preview and applied override so active rendering returns to metadata", () => { + const uiState = { + metadataFit, + previewFit, + appliedOverrideFit, + }; + + const resetState = resetCopyStateFitUiState(uiState); + + expect(resetState).toEqual({ + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }); + expect(resetState.metadataFit).toBe(metadataFit); + expect(selectActiveCopyStateFit(resetState)).toBe(metadataFit); + expect(uiState).toEqual({ + metadataFit, + previewFit, + appliedOverrideFit, + }); + }); + + test("clears an applied override even when there is no preview", () => { + const resetState = resetCopyStateFitUiState({ + metadataFit, + previewFit: null, + appliedOverrideFit, + }); + + expect(resetState).toEqual({ + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }); + expect(selectActiveCopyStateFit(resetState)).toBe(metadataFit); + }); + + test("returns initialized reset state when there are no frontend edits", () => { + const uiState = { + metadataFit, + previewFit: null, + appliedOverrideFit: null, + }; + + expect(resetCopyStateFitUiState(uiState)).toEqual(uiState); + }); + }); +}); diff --git a/src/components/purityPloidySlider/copyStateFitControls.js b/src/components/purityPloidySlider/copyStateFitControls.js new file mode 100644 index 00000000..d3254b20 --- /dev/null +++ b/src/components/purityPloidySlider/copyStateFitControls.js @@ -0,0 +1,77 @@ +import React, { Component } from "react"; +import { PropTypes } from "prop-types"; +import { Button, Space } from "antd"; +import Wrapper from "./copyStateFitControls.style"; + +const fitFields = ["Slope", "Intercept", "Purity", "Ploidy"]; + +const formatFitValue = (value) => + Number.isFinite(value) ? value.toFixed(3) : "n/a"; + +class CopyStateFitControls extends Component { + getFitValues() { + const { activeCopyStateFit } = this.props; + + return fitFields.map((label) => [ + label, + activeCopyStateFit?.[label.charAt(0).toLowerCase() + label.slice(1)], + ]); + } + + render() { + const { hasFitSession, hasPreview, onApply, onPreview, onReset } = + this.props; + + return ( + +
+
+
Fit adjustment
+ + + + + +
+
+ {this.getFitValues().map(([label, value]) => ( + + {label} + + {formatFitValue(value)} + + + ))} +
+
+
+ ); + } +} + +CopyStateFitControls.propTypes = { + activeCopyStateFit: PropTypes.shape({ + slope: PropTypes.number.isRequired, + intercept: PropTypes.number.isRequired, + purity: PropTypes.number.isRequired, + ploidy: PropTypes.number.isRequired, + }).isRequired, + hasFitSession: PropTypes.bool.isRequired, + hasPreview: PropTypes.bool.isRequired, + onApply: PropTypes.func.isRequired, + onPreview: PropTypes.func.isRequired, + onReset: PropTypes.func.isRequired, +}; + +export default CopyStateFitControls; diff --git a/src/components/purityPloidySlider/copyStateFitControls.style.js b/src/components/purityPloidySlider/copyStateFitControls.style.js new file mode 100644 index 00000000..b149eb5b --- /dev/null +++ b/src/components/purityPloidySlider/copyStateFitControls.style.js @@ -0,0 +1,98 @@ +import styled from "styled-components"; + +const Wrapper = styled.div` + position: relative; + z-index: 1; + display: flex; + justify-content: flex-start; + margin-bottom: 24px; + font-size: 14px; + line-height: 20px; + + .copy-state-fit-panel { + box-sizing: border-box; + width: fit-content; + max-width: min(720px, 100%); + padding: 10px 12px 12px; + border: 1px solid rgba(0, 0, 0, 0.08); + border-radius: 8px; + background: linear-gradient(180deg, #ffffff 0%, #fafafa 100%); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); + } + + .copy-state-fit-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 10px; + } + + .copy-state-fit-title { + color: rgba(0, 0, 0, 0.78); + font-size: 13px; + font-weight: 600; + letter-spacing: 0.01em; + white-space: nowrap; + } + + .copy-state-fit-toolbar { + display: flex; + justify-content: flex-end; + } + + .copy-state-fit-readout { + display: grid; + grid-template-columns: repeat(4, minmax(112px, 1fr)); + gap: 8px; + } + + .copy-state-fit-metric { + display: flex; + flex-direction: column; + min-width: 0; + padding: 8px 10px; + border: 1px solid rgba(0, 0, 0, 0.07); + border-radius: 6px; + background: #fff; + } + + .copy-state-fit-metric-label { + color: rgba(0, 0, 0, 0.48); + font-size: 11px; + line-height: 16px; + } + + .copy-state-fit-metric-value { + color: rgba(0, 0, 0, 0.86); + font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, + "Liberation Mono", "Courier New", monospace; + font-size: 17px; + font-variant-numeric: tabular-nums; + font-weight: 600; + line-height: 22px; + } + + @media (max-width: 640px) { + .copy-state-fit-panel { + width: 100%; + max-width: 100%; + } + + .copy-state-fit-header { + align-items: flex-start; + flex-direction: column; + gap: 8px; + } + + .copy-state-fit-toolbar { + justify-content: flex-start; + } + + .copy-state-fit-readout { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } + } +`; + +export default Wrapper; diff --git a/src/components/purityPloidySlider/index.js b/src/components/purityPloidySlider/index.js new file mode 100644 index 00000000..907b8866 --- /dev/null +++ b/src/components/purityPloidySlider/index.js @@ -0,0 +1,283 @@ +import React, { Component } from "react"; +import { PropTypes } from "prop-types"; +import * as d3 from "d3"; +import Wrapper from "./index.style"; + +class PurityPloidySlider extends Component { + state = { + copyStateSeparatorDrag: null, + }; + + componentDidUpdate(prevProps) { + const previousSource = prevProps.activeCopyStateFit?.source; + const currentSource = this.props.activeCopyStateFit?.source; + + if ( + this.state.copyStateSeparatorDrag != null && + previousSource != null && + previousSource !== "metadata" && + currentSource === "metadata" + ) { + this.clearCopyStateSeparatorDrag(); + } + } + + componentWillUnmount() { + this.clearCopyStateSeparatorDrag(); + } + + getSeparatorSegmentMean = (separator) => { + if (separator == null) return undefined; + return typeof separator === "number" ? separator : separator.segmentMean; + }; + + clearCopyStateSeparatorDrag = () => { + const drag = this.state.copyStateSeparatorDrag; + + if (drag == null) return; + + if (typeof window !== "undefined") { + if (typeof drag.mouseMoveListener === "function") { + window.removeEventListener("mousemove", drag.mouseMoveListener); + } + if (typeof drag.mouseUpListener === "function") { + window.removeEventListener("mouseup", drag.mouseUpListener); + } + } + + this.setState({ copyStateSeparatorDrag: null }); + }; + + handleCopyStateSeparatorDragStart = (event, separator) => { + event.preventDefault?.(); + event.stopPropagation?.(); + this.clearCopyStateSeparatorDrag(); + + const copyState = separator.copyState; + const startSegmentMean = this.getSeparatorSegmentMean(separator); + const startFit = this.props.activeCopyStateFit; + // Shift, Alt, or Meta modifier drags on nonzero separators edit spacing + // anchored at copy state 0; copy-state 0 itself can only shift the family. + const mode = + copyState !== 0 && (event.shiftKey || event.altKey || event.metaKey) + ? "spacing" + : "shift"; + const mouseMoveListener = (moveEvent) => + this.handleCopyStateSeparatorDragMove(moveEvent, separator); + const mouseUpListener = (mouseUpEvent) => + this.handleCopyStateSeparatorDragEnd(mouseUpEvent, separator); + + this.setState({ + copyStateSeparatorDrag: { + startClientX: event.clientX, + startSegmentMean, + copyState, + mode, + startFit, + mouseMoveListener, + mouseUpListener, + }, + }); + + if (typeof window !== "undefined") { + window.addEventListener("mousemove", mouseMoveListener); + window.addEventListener("mouseup", mouseUpListener); + } + }; + + handleCopyStateSeparatorDragMove = (event, separator) => { + const drag = this.state.copyStateSeparatorDrag; + + if (drag == null) return; + + event.preventDefault?.(); + event.stopPropagation?.(); + + const { onCopyStateFitPreview, xScale } = this.props; + if (typeof onCopyStateFitPreview !== "function") return; + + const startClientX = drag.startClientX; + const startSegmentMean = Number.isFinite(drag.startSegmentMean) + ? drag.startSegmentMean + : this.getSeparatorSegmentMean(separator); + + if (!Number.isFinite(event.clientX) || !Number.isFinite(startClientX)) { + return; + } + + const deltaPixels = event.clientX - startClientX; + let deltaSegmentMean; + + if (typeof xScale === "function" && typeof xScale.invert === "function") { + const startPixel = xScale(startSegmentMean); + const endSegmentMean = xScale.invert(startPixel + deltaPixels); + const startSegmentMeanFromScale = xScale.invert(startPixel); + deltaSegmentMean = endSegmentMean - startSegmentMeanFromScale; + } else if ( + xScale != null && + typeof xScale.domain === "function" && + typeof xScale.range === "function" + ) { + const domain = xScale.domain(); + const range = xScale.range(); + + if (Array.isArray(domain) && Array.isArray(range)) { + const domainSpan = domain[domain.length - 1] - domain[0]; + const rangeSpan = range[range.length - 1] - range[0]; + deltaSegmentMean = (deltaPixels * domainSpan) / rangeSpan; + } + } + + const segmentMean = startSegmentMean + deltaSegmentMean; + const copyState = drag.copyState ?? separator?.copyState; + + if ( + !Number.isFinite(deltaSegmentMean) || + !Number.isFinite(segmentMean) || + !Number.isFinite(copyState) + ) { + return; + } + + if (drag.mode === "spacing") { + onCopyStateFitPreview({ + mode: "spacing", + copyState, + segmentMean, + ...(drag.startFit == null ? {} : { startFit: drag.startFit }), + }); + return; + } + + onCopyStateFitPreview({ + mode: "shift", + copyState, + segmentMean, + deltaSegmentMean, + ...(drag.startFit == null ? {} : { startFit: drag.startFit }), + }); + }; + + handleCopyStateSeparatorDragEnd = (event, separator) => { + const drag = this.state.copyStateSeparatorDrag; + + if (drag == null) return; + + try { + this.handleCopyStateSeparatorDragMove(event, separator); + } finally { + this.clearCopyStateSeparatorDrag(); + } + }; + + renderVisibleLayer() { + const { clipPath, panelHeight, separators, xScale } = this.props; + + return ( + + {separators.map((d, i) => { + const segmentMean = this.getSeparatorSegmentMean(d); + const previousSegmentMean = this.getSeparatorSegmentMean( + separators[i - 1] + ); + const copyState = d.copyState ?? i; + const labelOpacity = + xScale(segmentMean) - xScale(previousSegmentMean) < 30 ? i % 2 : 1; + + return ( + + + this.handleCopyStateSeparatorDragStart(event, d) + } + /> + + {copyState} + + + {d3.format(".3f")(segmentMean)} + + + ); + })} + + ); + } + + renderHitTargetLayer() { + const { clipPath, panelHeight, separators, xScale } = this.props; + + return ( + + {separators.map((d, i) => { + const segmentMean = this.getSeparatorSegmentMean(d); + const copyState = d.copyState ?? i; + + return ( + + this.handleCopyStateSeparatorDragStart(event, d) + } + /> + ); + })} + + ); + } + + render() { + const { children } = this.props; + + return ( + + {this.renderVisibleLayer()} + {children} + {this.renderHitTargetLayer()} + + ); + } +} + +const copyStateFitPropType = PropTypes.shape({ + slope: PropTypes.number.isRequired, + intercept: PropTypes.number.isRequired, + spacing: PropTypes.number.isRequired, + zeroCopyOffset: PropTypes.number.isRequired, + purity: PropTypes.number.isRequired, + ploidy: PropTypes.number.isRequired, + source: PropTypes.oneOf(["metadata", "preview", "appliedOverride"]).isRequired, +}); + +PurityPloidySlider.propTypes = { + activeCopyStateFit: copyStateFitPropType, + children: PropTypes.node, + clipPath: PropTypes.string.isRequired, + onCopyStateFitPreview: PropTypes.func, + panelHeight: PropTypes.number.isRequired, + separators: PropTypes.arrayOf( + PropTypes.shape({ + copyState: PropTypes.number.isRequired, + segmentMean: PropTypes.number.isRequired, + }) + ).isRequired, + xScale: PropTypes.func.isRequired, +}; + +export default PurityPloidySlider; diff --git a/src/components/purityPloidySlider/index.style.js b/src/components/purityPloidySlider/index.style.js new file mode 100644 index 00000000..cf45ed31 --- /dev/null +++ b/src/components/purityPloidySlider/index.style.js @@ -0,0 +1,29 @@ +import styled from "styled-components"; + +const Wrapper = styled.g` + .copy-state-separator-line, + .copy-state-separator-hit-target { + cursor: ew-resize; + pointer-events: stroke; + } + + .copy-state-separator-line { + stroke: #ffd6d6; + stroke-dasharray: 4 1; + } + + .copy-state-separator-label, + .copy-state-separator-segment-mean { + fill: rgb(179, 150, 150); + font-size: 10px; + text-anchor: middle; + } + + .copy-state-separator-hit-target { + fill: none; + stroke: transparent; + stroke-width: 12; + } +`; + +export default Wrapper; From b857633a4ac37f5480d52365eca123ef9da56b30 Mon Sep 17 00:00:00 2001 From: Shihab Dider Date: Mon, 18 May 2026 12:33:48 -0400 Subject: [PATCH 2/2] refactor: remove redundant preview button --- src/components/binPlotPanel/index.js | 1 - src/components/purityPloidySlider/copyStateFitControls.js | 7 +------ 2 files changed, 1 insertion(+), 7 deletions(-) diff --git a/src/components/binPlotPanel/index.js b/src/components/binPlotPanel/index.js index a1f9cc34..982503a4 100644 --- a/src/components/binPlotPanel/index.js +++ b/src/components/binPlotPanel/index.js @@ -146,7 +146,6 @@ class BinPlotPanel extends Component { hasFitSession={hasFitSession} hasPreview={hasPreview} onApply={this.handleApplyCopyStateFit} - onPreview={() => this.handleCopyStateFitPreview()} onReset={this.handleResetCopyStateFit} /> ); diff --git a/src/components/purityPloidySlider/copyStateFitControls.js b/src/components/purityPloidySlider/copyStateFitControls.js index d3254b20..defa8185 100644 --- a/src/components/purityPloidySlider/copyStateFitControls.js +++ b/src/components/purityPloidySlider/copyStateFitControls.js @@ -19,8 +19,7 @@ class CopyStateFitControls extends Component { } render() { - const { hasFitSession, hasPreview, onApply, onPreview, onReset } = - this.props; + const { hasFitSession, hasPreview, onApply, onReset } = this.props; return ( @@ -28,9 +27,6 @@ class CopyStateFitControls extends Component {
Fit adjustment
-