diff --git a/extensions/cornerstone/package.json b/extensions/cornerstone/package.json index 382d78aa0..6ea501893 100644 --- a/extensions/cornerstone/package.json +++ b/extensions/cornerstone/package.json @@ -31,7 +31,9 @@ "dev:cornerstone": "yarn run dev", "build": "cross-env NODE_ENV=production webpack --progress --config .webpack/webpack.prod.js", "build:package-1": "yarn run build", - "start": "yarn run dev" + "start": "yarn run dev", + "test:unit": "jest --watchAll", + "test:unit:ci": "jest --ci --runInBand --collectCoverage --passWithNoTests" }, "peerDependencies": { "@cornerstonejs/codec-charls": "^1.2.3", diff --git a/extensions/cornerstone/src/commandsModule.ts b/extensions/cornerstone/src/commandsModule.ts index 1deeecbad..e408cc8a1 100644 --- a/extensions/cornerstone/src/commandsModule.ts +++ b/extensions/cornerstone/src/commandsModule.ts @@ -42,6 +42,7 @@ import { getUpdatedViewportsForSegmentation } from './utils/hydrationUtils'; import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; import { isMeasurementWithinViewport } from './utils/isMeasurementWithinViewport'; import { getCenterExtent } from './utils/getCenterExtent'; +import { EasingFunctionEnum } from './utils/transitions'; const { DefaultHistoryMemo } = csUtils.HistoryMemo; const toggleSyncFunctions = { @@ -1518,7 +1519,33 @@ function commandsModule({ segmentationId ); segmentationService.setActiveSegment(segmentationId, segmentIndex); - segmentationService.jumpToSegmentCenter(segmentationId, segmentIndex); + + const { highlightAlpha, highlightSegment, animationLength, animationFunctionType } = + (customizationService.getCustomization( + 'panelSegmentation.jumpToSegmentHighlightAnimationConfig' + ) as Object as { + highlightAlpha?: number; + highlightSegment?: boolean; + animationLength?: number; + animationFunctionType?: EasingFunctionEnum; + }) ?? {}; + + const validAnimationFunctionType = Object.values(EasingFunctionEnum).includes( + animationFunctionType + ) + ? animationFunctionType + : undefined; + + segmentationService.jumpToSegmentCenter( + segmentationId, + segmentIndex, + undefined, + highlightAlpha, + highlightSegment, + animationLength, + undefined, + validAnimationFunctionType + ); }, /** diff --git a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts index ec938475e..0bebef5c1 100644 --- a/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts +++ b/extensions/cornerstone/src/services/SegmentationService/SegmentationService.ts @@ -16,7 +16,7 @@ import { } from '@cornerstonejs/tools'; import { PubSubService, Types as OHIFTypes } from '@ohif/core'; import i18n from '@ohif/i18n'; -import { easeInOutBell, easeInOutBellRelative } from '../../utils/transitions'; +import { EasingFunctionEnum, EasingFunctionMap } from '../../utils/transitions'; import { mapROIContoursToRTStructData } from './RTSTRUCT/mapROIContoursToRTStructData'; import { SegmentationRepresentations } from '@cornerstonejs/tools/enums'; import { addColorLUT } from '@cornerstonejs/tools/segmentation/addColorLUT'; @@ -1178,7 +1178,7 @@ class SegmentationService extends PubSubService { highlightSegment = true, animationLength = 750, highlightHideOthers = false, - highlightFunctionType = 'ease-in-out' // todo: make animation functions configurable from outside + animationFunctionType: EasingFunctionEnum = EasingFunctionEnum.EASE_IN_OUT ): void { const center = this._getSegmentCenter(segmentationId, segmentIndex); if (!center) { @@ -1204,7 +1204,8 @@ class SegmentationService extends PubSubService { viewportId, highlightAlpha, animationLength, - highlightHideOthers + highlightHideOthers, + animationFunctionType ); }); } @@ -1216,7 +1217,7 @@ class SegmentationService extends PubSubService { alpha = 0.9, animationLength = 750, hideOthers = true, - highlightFunctionType = 'ease-in-out' + animationFunctionType: EasingFunctionEnum = EasingFunctionEnum.EASE_IN_OUT ): void { if (this.highlightIntervalId) { clearInterval(this.highlightIntervalId); @@ -1249,7 +1250,8 @@ class SegmentationService extends PubSubService { segments, viewportId, animationLength, - representation + representation, + animationFunctionType ); }); } @@ -1614,7 +1616,8 @@ class SegmentationService extends PubSubService { segments: Segment[], viewportId: string, animationLength: number, - representation: cstTypes.SegmentationRepresentation + representation: cstTypes.SegmentationRepresentation, + animationFunctionType: EasingFunctionEnum ) { const { segmentationId } = representation; const newSegmentSpecificConfig = { @@ -1648,6 +1651,8 @@ class SegmentationService extends PubSubService { const elapsed = timestamp - startTime; const progress = Math.min(elapsed / animationLength, 1); + const easingFunction = EasingFunctionMap.get(animationFunctionType); + cstSegmentation.config.style.setStyle( { segmentationId, @@ -1655,7 +1660,7 @@ class SegmentationService extends PubSubService { type: LABELMAP, }, { - fillAlpha: easeInOutBell(progress, fillAlpha), + fillAlpha: easingFunction(progress, fillAlpha), } ); @@ -1683,7 +1688,8 @@ class SegmentationService extends PubSubService { segments: Segment[], viewportId: string, animationLength: number, - representation: cstTypes.SegmentationRepresentation + representation: cstTypes.SegmentationRepresentation, + animationFunctionType: EasingFunctionEnum ) { const { segmentationId } = representation; const startTime = performance.now(); @@ -1693,8 +1699,6 @@ class SegmentationService extends PubSubService { }) as ContourStyle; const prevOutlineWidth = prevStyle.outlineWidth; - // make this configurable - const baseline = Math.max(prevOutlineWidth * 3.5, 5); const animate = (currentTime: number) => { const progress = (currentTime - startTime) / animationLength; @@ -1703,7 +1707,8 @@ class SegmentationService extends PubSubService { return; } - const reversedProgress = easeInOutBellRelative(progress, baseline, prevOutlineWidth); + const OUTLINE_ANIMATION_SCALE_FACTOR = 5; + const easingFunction = EasingFunctionMap.get(animationFunctionType); cstSegmentation.config.style.setStyle( { @@ -1712,7 +1717,7 @@ class SegmentationService extends PubSubService { type: CONTOUR, }, { - outlineWidth: reversedProgress, + outlineWidth: easingFunction(progress, prevOutlineWidth, OUTLINE_ANIMATION_SCALE_FACTOR), } ); diff --git a/extensions/cornerstone/src/utils/transitions.test.ts b/extensions/cornerstone/src/utils/transitions.test.ts new file mode 100644 index 000000000..c86efcdf3 --- /dev/null +++ b/extensions/cornerstone/src/utils/transitions.test.ts @@ -0,0 +1,367 @@ +import { linear, ease, easeIn, easeOut, easeInOut, cubicBezier } from './transitions'; + +describe('Transitions Module', () => { + const EPSILON = 1e-6; + + /** + * Helper function to check if two numbers are approximately equal + */ + const approxEqual = (actual: number, expected: number, tolerance = EPSILON): boolean => { + return Math.abs(actual - expected) < tolerance; + }; + + describe('Standard CSS Easing (Mode 1: no baseline)', () => { + describe('linear', () => { + it('should return 0 at timeProgress=0', () => { + expect(linear(0)).toBe(0); + }); + + it('should return 1 at timeProgress=1', () => { + expect(linear(1)).toBe(1); + }); + + it('should return timeProgress for linear progression', () => { + expect(linear(0.25)).toBe(0.25); + expect(linear(0.5)).toBe(0.5); + expect(linear(0.75)).toBe(0.75); + }); + }); + + describe('easeInOut', () => { + it('should return 0 at timeProgress=0', () => { + expect(easeInOut(0)).toBe(0); + }); + + it('should return 1 at timeProgress=1', () => { + expect(easeInOut(1)).toBe(1); + }); + + it('should return 0.5 at timeProgress=0.5', () => { + const result = easeInOut(0.5); + expect(approxEqual(result, 0.5)).toBe(true); + }); + + it('should be slower at start than linear', () => { + const easedValue = easeInOut(0.2); + expect(easedValue).toBeLessThan(0.2); + }); + + it('should provide smoothing toward the end', () => { + const baseTime = 0.9; + const controlTimeProgressionDifference = 0.2; + + const result1 = easeInOut(baseTime); + const result2 = easeInOut(baseTime - controlTimeProgressionDifference); + + expect(result1 - result2).toBeLessThan(controlTimeProgressionDifference); + }); + }); + + describe('easeIn', () => { + it('should return 0 at timeProgress=0', () => { + expect(easeIn(0)).toBe(0); + }); + + it('should return 1 at timeProgress=1', () => { + expect(easeIn(1)).toBe(1); + }); + + it('should be slower at start', () => { + const easedValue = easeIn(0.4); + expect(easedValue).toBeLessThan(0.4); + }); + + it('should accelerate toward the end', () => { + const baseTime = 0.9; + const controlTimeProgressionDifference = 0.2; + + const result1 = easeIn(baseTime); + const result2 = easeIn(baseTime - controlTimeProgressionDifference); + + expect(result1 - result2).toBeGreaterThan(controlTimeProgressionDifference); + }); + }); + + describe('easeOut', () => { + it('should return 0 at timeProgress=0', () => { + expect(easeOut(0)).toBe(0); + }); + + it('should return 1 at timeProgress=1', () => { + expect(easeOut(1)).toBe(1); + }); + + it('should be faster at start', () => { + const easedValue = easeOut(0.4); + expect(easedValue).toBeGreaterThan(0.4); + }); + + it('should provide smoothing toward the end', () => { + const baseTime = 0.9; + const controlTimeProgressionDifference = 0.2; + + const result1 = ease(baseTime); + const result2 = ease(baseTime - controlTimeProgressionDifference); + + expect(result1 - result2).toBeLessThan(controlTimeProgressionDifference); + }); + }); + + describe('ease', () => { + it('should return 0 at timeProgress=0', () => { + expect(ease(0)).toBe(0); + }); + + it('should return 1 at timeProgress=1', () => { + expect(ease(1)).toBe(1); + }); + + it('should provide significant growth at first half', () => { + const result = ease(0.5); + expect(result).toBeGreaterThan(0.75); + }); + + it('should provide smoothing toward the end', () => { + const baseTime = 0.9; + const controlTimeProgressionDifference = 0.2; + + const result1 = ease(baseTime); + const result2 = ease(baseTime - controlTimeProgressionDifference); + + expect(result1 - result2).toBeLessThan(controlTimeProgressionDifference); + }); + }); + }); + + describe('Baseline Bell-Curve (Mode 2: baseline only)', () => { + const baseline = 0.2; + + describe('linear with baseline', () => { + it('should start and end at baseline', () => { + expect(linear(0, baseline)).toBe(baseline); + expect(linear(1, baseline)).toBe(baseline); + }); + + it('should peak at 1.0 at timeProgress=0.5', () => { + const result = linear(0.5, baseline); + expect(approxEqual(result, 1.0)).toBe(true); + }); + + it('should create symmetric bell curve', () => { + const quarterValue = linear(0.25, baseline); + const threeQuarterValue = linear(0.75, baseline); + expect(approxEqual(quarterValue, threeQuarterValue)).toBe(true); + }); + + it('should be between baseline and 1.0', () => { + for (let t = 0; t <= 1; t += 0.1) { + const result = linear(t, baseline); + expect(result).toBeGreaterThanOrEqual(baseline); + expect(result).toBeLessThanOrEqual(1.0); + } + }); + }); + + describe('easeInOut with baseline', () => { + it('should start and end at baseline', () => { + expect(easeInOut(0, baseline)).toBe(baseline); + expect(easeInOut(1, baseline)).toBe(baseline); + }); + + it('should peak near 1.0 around timeProgress=0.5', () => { + const result = easeInOut(0.5, baseline); + expect(result).toBeGreaterThan(0.8); + expect(result).toBeLessThanOrEqual(1.0); + }); + + it('should create bell curve with easing', () => { + const quarterValue = easeInOut(0.25, baseline); + const threeQuarterValue = easeInOut(0.75, baseline); + expect(approxEqual(quarterValue, threeQuarterValue, 0.01)).toBe(true); + }); + }); + + describe('all functions with baseline', () => { + const testBaseline = 0.3; + + it('should all start and end at baseline', () => { + const functions = [linear, ease, easeIn, easeOut, easeInOut]; + + functions.forEach(fn => { + expect(fn(0, testBaseline)).toBe(testBaseline); + expect(fn(1, testBaseline)).toBe(testBaseline); + }); + }); + }); + }); + + describe('Scaled Bell-Curve (Mode 3: baseline + scale)', () => { + const baseline = 2.0; + const scale = 3.0; + const expectedPeak = baseline * scale; + + describe('linear with baseline and scale', () => { + it('should start and end at baseline', () => { + expect(linear(0, baseline, scale)).toBe(baseline); + expect(linear(1, baseline, scale)).toBe(baseline); + }); + + it('should peak at baseline*scale at timeProgress=0.5', () => { + const result = linear(0.5, baseline, scale); + expect(approxEqual(result, expectedPeak)).toBe(true); + }); + + it('should create symmetric scaled bell curve', () => { + const quarterValue = linear(0.25, baseline, scale); + const threeQuarterValue = linear(0.75, baseline, scale); + expect(approxEqual(quarterValue, threeQuarterValue)).toBe(true); + }); + }); + + describe('easeInOut with baseline and scale', () => { + it('should start and end at baseline', () => { + expect(easeInOut(0, baseline, scale)).toBe(baseline); + expect(easeInOut(1, baseline, scale)).toBe(baseline); + }); + + it('should peak near baseline*scale around timeProgress=0.5', () => { + const result = easeInOut(0.5, baseline, scale); + expect(result).toBeGreaterThan(baseline + (expectedPeak - baseline) * 0.8); + expect(result).toBeLessThanOrEqual(expectedPeak); + }); + }); + + describe('different scale values', () => { + it('should handle scale < 1 (shrinking)', () => { + const shrinkScale = 0.5; + const result = linear(0.5, 1.0, shrinkScale); + expect(approxEqual(result, 0.5)).toBe(true); + }); + + it('should handle scale > 1 (growing)', () => { + const growScale = 2.5; + const result = linear(0.5, 1.0, growScale); + expect(approxEqual(result, 2.5)).toBe(true); + }); + + it('should handle scale = 1 (baseline to baseline)', () => { + const result = linear(0.5, 0.5, 1); + expect(approxEqual(result, 0.5)).toBe(true); + }); + }); + }); + + describe('Edge Cases', () => { + describe('baseline = 0 behavior', () => { + it('should behave like standard easing when baseline=0', () => { + expect(linear(0.5, 0)).toBe(linear(0.5)); + expect(easeInOut(0.5, 0)).toBe(easeInOut(0.5)); + }); + }); + + describe('extreme values', () => { + it('should handle negative baseline', () => { + const result = linear(0.5, -1.0, 2.0); + expect(approxEqual(result, -2.0)).toBe(true); + }); + + it('should handle large baseline values', () => { + const result = linear(0.5, 100, 1.5); + expect(approxEqual(result, 150)).toBe(true); + }); + }); + + describe('timeProgress edge cases', () => { + it('should handle timeProgress < 0', () => { + expect(linear(-0.1)).toBe(0); + expect(easeInOut(-0.1, 0.2)).toBe(0.2); + }); + + it('should handle timeProgress > 1', () => { + expect(linear(1.1)).toBe(1); + expect(easeInOut(1.1, 0.2)).toBe(0.2); + }); + }); + }); + + describe('Mathematical Properties', () => { + describe('bell curve symmetry for symmetric cubic bezier functions', () => { + it('should be symmetric around timeProgress=0.5', () => { + const baseline = 0.1; + const scale = 2.0; + + [linear, easeInOut].forEach(fn => { + for (let offset = 0.1; offset <= 0.4; offset += 0.1) { + const leftValue = fn(0.5 - offset, baseline, scale); + const rightValue = fn(0.5 + offset, baseline, scale); + expect(approxEqual(leftValue, rightValue, 0.01)).toBe(true); + } + }); + }); + }); + + describe('monotonicity in first half', () => { + it('should be non-decreasing from 0 to 0.5 for functions that grow under linear in first half', () => { + const baseline = 0.2; + + [linear, easeIn, easeInOut].forEach(fn => { + let prevValue = fn(0, baseline); + for (let time = 0.1; time <= 0.5; time += 0.1) { + const currentValue = fn(time, baseline); + expect(currentValue).toBeGreaterThanOrEqual(prevValue - EPSILON); + prevValue = currentValue; + } + }); + }); + }); + }); + + describe('Utility Functions', () => { + describe('cubicBezier', () => { + it('should create functions that return 0 at t=0 and 1 at t=1', () => { + const customEasing = cubicBezier(0.25, 0.1, 0.75, 0.9); + expect(customEasing(0)).toBe(0); + expect(customEasing(1)).toBe(1); + }); + + it('should throw error for invalid x values', () => { + expect(() => cubicBezier(-0.1, 0, 1, 1)).toThrow(); + expect(() => cubicBezier(0, 0, 1.1, 1)).toThrow(); + }); + + it('should fallback to binary search when Newton-Raphson fails due to small derivative', () => { + /** + * Create a cubic-bezier curve that has a very flat section (small derivative) + * This will cause Newton-Raphson to fail and use binary search fallback + * Using control points that create a nearly horizontal tangent + * Obs: Values obtained by imperative testing + */ + const problematicEasing = cubicBezier(0.98888, 0.00001, 0.00001, 0.98888); + + /** Test a value in the problematic region where derivative is very small*/ + const result = problematicEasing(0.5); + + expect(typeof result).toBe('number'); + expect(isNaN(result)).toBe(false); + expect(result).toBeGreaterThanOrEqual(0); + expect(result).toBeLessThanOrEqual(1); + }); + }); + }); + + describe('Performance Validation', () => { + it('should handle many rapid calls without issues', () => { + const startTime = performance.now(); + + for (let i = 0; i < 1000; i++) { + const t = i / 1000; + linear(t); + easeInOut(t, 0.1); + ease(t, 0.2, 1.5); + } + + const endTime = performance.now(); + expect(endTime - startTime).toBeLessThan(10); + }); + }); +}); diff --git a/extensions/cornerstone/src/utils/transitions.ts b/extensions/cornerstone/src/utils/transitions.ts index 7d63f26f7..41991637d 100644 --- a/extensions/cornerstone/src/utils/transitions.ts +++ b/extensions/cornerstone/src/utils/transitions.ts @@ -1,63 +1,300 @@ -/** - * It is a bell curved function that uses ease in out quadratic for css - * transition timing function for each side of the curve. - * - * @param {number} x - The current time, in the range [0, 1]. - * @param {number} baseline - The baseline value to start from and return to. - * @returns the value of the transition at time x. - */ -export function easeInOutBell(x: number, baseline: number): number { - const alpha = 1 - baseline; +/** Cubic Bezier Implementation */ - // prettier-ignore - if (x < 1 / 4) { - return 4 * Math.pow(2 * x, 3) * alpha + baseline; - } else if (x < 1 / 2) { - return (1 - Math.pow(-4 * x + 2, 3) / 2) * alpha + baseline; - } else if (x < 3 / 4) { - return (1 - Math.pow(4 * x - 2, 3) / 2) * alpha + baseline; - } else { - return (- 4 * Math.pow(2 * x - 2, 3)) * alpha + baseline; +/** + * Finds the parameter t for a given x value on a cubic Bézier curve. + * Uses Newton's method first (fast), then falls back to binary search (reliable). + * Based on WebKit/Chromium's UnitBezier implementation. + * + * @param {number} targetX - The x value to find parameter t for + * @param {number} x1 - x-coordinate of first control point + * @param {number} x2 - x-coordinate of second control point + * @returns {number} The parameter t that gives the specified x value + */ +function solveCubicBezierX(targetX: number, x1: number, x2: number): number { + const epsilon = 1e-6; + + // Pre-compute polynomial coefficients for performance + const cx = 3.0 * x1; + const bx = 3.0 * (x2 - x1) - cx; + const ax = 1.0 - cx - bx; + + function sampleCurveX(t: number): number { + // Evaluate: ax*t³ + bx*t² + cx*t using Horner's rule + return ((ax * t + bx) * t + cx) * t; } + + function sampleCurveDerivativeX(t: number): number { + return (3.0 * ax * t + 2.0 * bx) * t + cx; + } + + /** + * Newton's method - fast convergence with good initial guess + * Try this first as it's normally very fast + */ + function newtonRaphsonMethod(targetX: number): number | null { + let t = targetX; + + for (let iteration = 0; iteration < 8; iteration++) { + const currentX = sampleCurveX(t); + const error = currentX - targetX; + + if (Math.abs(error) < epsilon) { + return t; + } + + const derivative = sampleCurveDerivativeX(t); + + // Break if derivative is too small (avoid division by zero) + if (Math.abs(derivative) < epsilon) { + break; + } + + // Newton-Raphson step: t_new = t_old - f(t_old) / f'(t_old) + t = t - error / derivative; + } + + return null; + } + + /** + * Binary search fallback - guaranteed to converge + * Use this when Newton's method fails + */ + function binarySearchFallback(targetX: number): number { + let lowerBound = 0.0; + let upperBound = 1.0; + let t = targetX; + + while (lowerBound < upperBound) { + const currentX = sampleCurveX(t); + + if (Math.abs(currentX - targetX) < epsilon) { + return t; + } + + if (targetX > currentX) { + lowerBound = t; + } else { + upperBound = t; + } + + t = (upperBound - lowerBound) * 0.5 + lowerBound; + } + + return t; + } + + const newtonResult = newtonRaphsonMethod(targetX); + if (newtonResult !== null) { + return newtonResult; + } + + return binarySearchFallback(targetX); } /** - * A reversed bell curved function that starts from 1 and goes to baseline and - * come back to 1 again. It uses ease in out quadratic for css transition - * timing function for each side of the curve. + * Evaluates the Y coordinate of a cubic Bézier curve at parameter t. + * Optimized for CSS timing functions where P0=(0,0) and P3=(1,1). + * Uses pre-computed polynomial coefficients and Horner's rule for performance. * - * @param {number} x - The current time, in the range [0, 1]. - * @param {number} baseline - The baseline value to start from and return to. - * @returns the value of the transition at time x. + * @param {number} t - Parameter value along the curve, typically in [0, 1] + * @param {number} y1 - y-coordinate of first control point + * @param {number} y2 - y-coordinate of second control point + * @returns {number} The Y value of the curve at parameter t */ -export function reverseEaseInOutBell(x: number, baseline: number): number { - const y = easeInOutBell(x, baseline); - return -y + 1 + baseline; +function sampleCurveY(t: number, y1: number, y2: number): number { + // Pre-compute polynomial coefficients for performance + const cy = 3.0 * y1; + const by = 3.0 * (y2 - y1) - cy; + const ay = 1.0 - cy - by; + + // Evaluate: ay*t³ + by*t² + cy*t using Horner's rule + return ((ay * t + by) * t + cy) * t; } -export function easeInOutBellRelative( - x: number, - baseline: number, - prevOutlineWidth: number -): number { - const range = baseline - prevOutlineWidth; - - if (x < 1 / 4) { - return prevOutlineWidth + 4 * Math.pow(2 * x, 3) * range; - } else if (x < 1 / 2) { - return prevOutlineWidth + (1 - Math.pow(-4 * x + 2, 3) / 2) * range; - } else if (x < 3 / 4) { - return prevOutlineWidth + (1 - Math.pow(4 * x - 2, 3) / 2) * range; - } else { - return prevOutlineWidth + -4 * Math.pow(2 * x - 2, 3) * range; +/** + * Cubic Bézier easing function implementation following CSS specifications. + * + * A cubic Bézier curve is defined by four points: P0, P1, P2, and P3. + * In CSS animations, P0 is fixed at (0, 0) and P3 is fixed at (1, 1). + * This function allows you to specify the intermediate control points P1 and P2. + * + * @param {number} x1 - x-coordinate of the first control point P1 (must be in [0, 1]) + * @param {number} y1 - y-coordinate of the first control point P1 + * @param {number} x2 - x-coordinate of the second control point P2 (must be in [0, 1]) + * @param {number} y2 - y-coordinate of the second control point P2 + * @returns {function} A function that takes time t ∈ [0, 1] and returns the eased value + */ +export function cubicBezier(x1: number, y1: number, x2: number, y2: number) { + if (x1 < 0 || x1 > 1 || x2 < 0 || x2 > 1) { + throw new Error('x1 and x2 must be in the range [0, 1]'); } + + return function (timeProgress: number): number { + if (timeProgress <= 0) return 0; + if (timeProgress >= 1) return 1; + + if (x1 === y1 && x2 === y2) { + return timeProgress; + } + + const curveParameter = solveCubicBezierX(timeProgress, x1, x2); + return sampleCurveY(curveParameter, y1, y2); + }; } -export function reverseEaseInOutBellRelative( - x: number, - baseline: number, - prevOutlineWidth: number -): number { - const y = easeInOutBellRelative(x, baseline, prevOutlineWidth); - return y; +/** Core Easing Functions */ + +/** + * Linear easing function - constant speed throughout the animation. + * Equivalent to CSS: cubic-bezier(0.0, 0.0, 1.0, 1.0) + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @returns {number} The linear eased value. + */ +const linearCore = cubicBezier(0.0, 0.0, 1.0, 1.0); + +/** + * Ease-in easing function - starts slow and accelerates. + * Equivalent to CSS: cubic-bezier(0.42, 0, 1.0, 1.0) + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @returns {number} The eased value. + */ +const easeInCore = cubicBezier(0.42, 0, 1.0, 1.0); + +/** + * Ease-out easing function - starts fast and decelerates. + * Equivalent to CSS: cubic-bezier(0, 0, 0.58, 1.0) + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @returns {number} The eased value. + */ +const easeOutCore = cubicBezier(0, 0, 0.58, 1.0); + +/** + * Ease-in-out easing function - starts slow, accelerates, then decelerates. + * Equivalent to CSS: cubic-bezier(0.42, 0, 0.58, 1.0) + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @returns {number} The eased value. + */ +const easeInOutCore = cubicBezier(0.42, 0, 0.58, 1.0); + +/** + * Standard ease easing function (CSS default). + * Equivalent to CSS: cubic-bezier(0.25, 0.1, 0.25, 1.0) + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @returns {number} The eased value. + */ +const easeCore = cubicBezier(0.25, 0.1, 0.25, 1.0); + +/** Flexible Easing Function Factory */ + +/** + * Flexible factory function that creates easing functions with optional baseline and scale support. + * Provides bell-curve behavior: baseline → baseline*scale → baseline + * + * @param {Function} coreEasingFn - The core easing function to wrap + * @returns {Function} A function that accepts (timeProgress, baseline?, scale?) and provides flexible behavior + */ +function flexibleEasingFunctionFactory(coreEasingFn: (timeProgress: number) => number) { + return function (timeProgress: number, baseline: number = 0, scale?: number): number { + if (baseline === 0) { + return coreEasingFn(timeProgress); + } + + const easedProgress = coreEasingFn(timeProgress); + + const targetValue = scale ? baseline * scale : 1; + const range = targetValue - baseline; + + // Create bell-curve: baseline → targetValue → baseline + const bellMultiplier = 1 - Math.abs(2 * easedProgress - 1); + return baseline + bellMultiplier * range; + }; } + +/** + * Linear easing function with optional baseline and scale support. + * - No params: standard linear progression [0, 1] + * - With baseline only: bell-curve baseline → 1 → baseline + * - With baseline+scale: bell-curve baseline → baseline*scale → baseline + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @param {number} baseline - Optional baseline value (default: 0). Creates bell-curve effect. + * @param {number} scale - Optional scale multiplier (default: 1). Peak value = baseline * scale. + * @returns {number} The linear eased value. + */ +export const linear = flexibleEasingFunctionFactory(linearCore); + +/** + * Standard ease easing function with optional baseline and scale support. + * - No params: standard ease progression [0, 1] + * - With baseline only: bell-curve baseline → 1 → baseline + * - With baseline+scale: bell-curve baseline → baseline*scale → baseline + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @param {number} baseline - Optional baseline value (default: 0). Creates bell-curve effect. + * @param {number} scale - Optional scale multiplier (default: 1). Peak value = baseline * scale. + * @returns {number} The eased value. + */ +export const ease = flexibleEasingFunctionFactory(easeCore); + +/** + * Ease-in easing function with optional baseline and scale support. + * - No params: standard ease-in progression [0, 1] + * - With baseline only: bell-curve baseline → 1 → baseline + * - With baseline+scale: bell-curve baseline → baseline*scale → baseline + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @param {number} baseline - Optional baseline value (default: 0). Creates bell-curve effect. + * @param {number} scale - Optional scale multiplier (default: 1). Peak value = baseline * scale. + * @returns {number} The eased value. + */ +export const easeIn = flexibleEasingFunctionFactory(easeInCore); + +/** + * Ease-out easing function with optional baseline and scale support. + * - No params: standard ease-out progression [0, 1] + * - With baseline only: bell-curve baseline → 1 → baseline + * - With baseline+scale: bell-curve baseline → baseline*scale → baseline + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @param {number} baseline - Optional baseline value (default: 0). Creates bell-curve effect. + * @param {number} scale - Optional scale multiplier (default: 1). Peak value = baseline * scale. + * @returns {number} The eased value. + */ +export const easeOut = flexibleEasingFunctionFactory(easeOutCore); + +/** + * Ease-in-out easing function with optional baseline and scale support. + * - No params: standard ease-in-out progression [0, 1] + * - With baseline only: bell-curve baseline → 1 → baseline + * - With baseline+scale: bell-curve baseline → baseline*scale → baseline + * + * @param {number} timeProgress - The animation progress, in the range [0, 1]. + * @param {number} baseline - Optional baseline value (default: 0). Creates bell-curve effect. + * @param {number} scale - Optional scale multiplier (default: 1). Peak value = baseline * scale. + * @returns {number} The eased value. + */ +export const easeInOut = flexibleEasingFunctionFactory(easeInOutCore); + +/** Export interfaces */ + +export enum EasingFunctionEnum { + EASE = 'ease', + EASE_IN = 'ease-in', + EASE_OUT = 'ease-out', + EASE_IN_OUT = 'ease-in-out', + LINEAR = 'linear', +} + +export const EasingFunctionMap = new Map([ + [EasingFunctionEnum.EASE, ease], + [EasingFunctionEnum.EASE_IN, easeIn], + [EasingFunctionEnum.EASE_OUT, easeOut], + [EasingFunctionEnum.EASE_IN_OUT, easeInOut], + [EasingFunctionEnum.LINEAR, linear], +]); diff --git a/platform/docs/docs/migration-guide/3p8-to-3p9/1-segmentation/6-segmentationserice-other.md b/platform/docs/docs/migration-guide/3p8-to-3p9/1-segmentation/6-segmentationserice-other.md index a13cd718f..c4195c598 100644 --- a/platform/docs/docs/migration-guide/3p8-to-3p9/1-segmentation/6-segmentationserice-other.md +++ b/platform/docs/docs/migration-guide/3p8-to-3p9/1-segmentation/6-segmentationserice-other.md @@ -346,7 +346,7 @@ jumpToSegmentCenter( highlightSegment = true, animationLength = 750, highlightHideOthers = false, - highlightFunctionType = 'ease-in-out' + animationFunctionType = 'ease-in-out' ) ``` diff --git a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx index b3b997446..c420bf46c 100644 --- a/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx +++ b/platform/docs/docs/platform/services/customization-service/sampleCustomizations.tsx @@ -1313,7 +1313,7 @@ window.config = { }; `, }, - { + { id: 'panelSegmentation.disableUpdateSegmentationStats', description: 'Disables the automatic update of segmentation statistics in the panel.', default: false, @@ -1328,6 +1328,35 @@ window.config = { }, }, ], +}; + `, + }, + { + id: 'panelSegmentation.jumpToSegmentHighlightAnimationConfig', + description: + 'Customize the highlight animation when clicking on a segment at the segmentation panel and jumping to it.', + default: { + highlightAlpha: 0.9, + highlightSegment: true, + animationLength: 750, + animationFunctionType: 'ease-in-out', + }, + image: [], + configuration: ` +window.config = { + // rest of window config + customizationService: [ + { + 'panelSegmentation.jumpToSegmentHighlightAnimationConfig': { + $set: { + highlightAlpha: 1.0, + highlightSegment: true, + animationLength: 900, + animationFunctionType: 'linear', // one of 'ease-in-out', 'ease-in', 'ease-out', 'ease', 'linear' + }, + }, + }, + ], }; `, }, diff --git a/platform/docs/versioned_docs/version-3.11/migration-guide/3p8-to-3p9/1-segmentation/6-segmentationserice-other.md b/platform/docs/versioned_docs/version-3.11/migration-guide/3p8-to-3p9/1-segmentation/6-segmentationserice-other.md index a13cd718f..c4195c598 100644 --- a/platform/docs/versioned_docs/version-3.11/migration-guide/3p8-to-3p9/1-segmentation/6-segmentationserice-other.md +++ b/platform/docs/versioned_docs/version-3.11/migration-guide/3p8-to-3p9/1-segmentation/6-segmentationserice-other.md @@ -346,7 +346,7 @@ jumpToSegmentCenter( highlightSegment = true, animationLength = 750, highlightHideOthers = false, - highlightFunctionType = 'ease-in-out' + animationFunctionType = 'ease-in-out' ) ```