From 4094fb12a3dec5dd2a3462edc00f5dc54f825204 Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Jan 2026 13:11:24 +0100 Subject: [PATCH 1/3] Fix wheel animation for zero-crossing and countdown Refactor useWheelCharacters to properly handle animations that cross zero (both directions) and countdown scenarios. Extract digit animation logic into createDigitCharFunction for cleaner code. Leading zeros now stay hidden until the wheel rotates past the backside, and zeros remain hidden when counting down since they become leading zeros. REDMINE-21218 --- .../counter/useWheelCharacters-spec.js | 76 ++++++++++++++++++- .../counter/useWheelCharacters.js | 58 +++++++------- 2 files changed, 106 insertions(+), 28 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js b/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js index d147e0594..1eaed0c57 100644 --- a/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js @@ -127,7 +127,7 @@ describe('createWheelCharacterFunctions', () => { }); expect(result).toEqual([ - {value: 1, hideZero: false}, + {value: 1, hideZero: true}, {text: ',', hide: false}, {value: 2, hideZero: false}, {value: 3, hideZero: false}, @@ -161,4 +161,78 @@ describe('createWheelCharacterFunctions', () => { expect(values[2]).toBe(0.5); // tens: 1 → 0 (99 rotations), at halfway = 0.5 expect(values[3]).toBe(5); // ones: 0 → 0 (99 rotations), at halfway = 5 }); + + it('animates digits when counting down from 10 to 9', () => { + const result = getRotationValues({value: 9.5, startValue: 10, targetValue: 9}); + + expect(result.map(r => r.value)).toEqual([0.5, 9.5]); + }); + + it('keeps hideZero true while digit value is below 1 when counting up past threshold', () => { + const result = getRotationValues({value: 10, startValue: 9, targetValue: 15}); + + expect(result[0].hideZero).toBe(true); + expect(result[0].value).toBeCloseTo(1 / 6); + }); + + it('does not hide middle zero at end when digit completes full rotation', () => { + const result = getRotationValues({value: 100, startValue: 0, targetValue: 100}); + + expect(result[1].hideZero).toBe(false); + expect(result[1].value).toBe(0); + }); + + it('does not hide middle zero at end when start was not a leading zero', () => { + const result = getRotationValues({value: 100, startValue: 10, targetValue: 100}); + + expect(result[1].hideZero).toBe(false); + expect(result[1].value).toBe(0); + }); + + it('shows correct final digit when crossing zero from negative to positive', () => { + const result = getRotationValues({value: 9, startValue: -10, targetValue: 9}); + + // tens digit should be 0, not 0.9 + expect(result[1].value).toBe(0); + expect(result[2].value).toBe(9); + }); + + it('shows correct final digit when crossing zero from positive to negative', () => { + const result = getRotationValues({value: -9, startValue: 10, targetValue: -9}); + + expect(result[1].value).toBe(0); + expect(result[2].value).toBe(9); + }); + + it('works with decimal places when counting from 0 to 0.5', () => { + const result = getRotationValues({ + value: 0.5, + startValue: 0, + targetValue: 0.5, + decimalPlaces: 1, + locale: 'en' + }); + + expect(result).toEqual([ + {value: 0, hideZero: false}, + {text: '.'}, + {value: 5, hideZero: false} + ]); + }); + + it('does not hide leading digit at start when counting down from 1900 to 0', () => { + const result = getRotationValues({value: 1900, startValue: 1900, targetValue: 0}); + + // thousands digit should not have hideZero at value 1900 + expect(result[0].hideZero).toBe(false); + expect(result[0].value).toBe(1); + }); + + it('hides thousands digit at value 1000 when counting down from 1900', () => { + const result = getRotationValues({value: 1000, startValue: 1900, targetValue: 0}); + + // at 1000, the "0" coming in on the thousands wheel should be hidden + // since it will become a leading zero + expect(result[0].hideZero).toBe(true); + }); }); diff --git a/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js b/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js index 7bf8da5a6..f1140bbb3 100644 --- a/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js +++ b/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js @@ -9,8 +9,6 @@ export function createWheelCharacterFunctions({startValue, targetValue, decimalP String(Math.round(absTargetValue)).length, String(Math.round(absStartValue)).length ); - const delta = absTargetValue - absStartValue; - const range = targetValue - startValue; const formatted = absTargetValue.toLocaleString(locale, { minimumIntegerDigits: integerDigitCount, @@ -19,52 +17,58 @@ export function createWheelCharacterFunctions({startValue, targetValue, decimalP useGrouping }); - const charFunctions = []; let digitIndex = 0; - for (const char of formatted) { + const charFunctions = [...formatted].map((char) => { if (/\d/.test(char)) { - const position = integerDigitCount - 1 - digitIndex; + const position = integerDigitCount - digitIndex++ - 1; const divisor = Math.pow(10, position); if (crossesZero) { - charFunctions.push((absValue) => ({ - value: (absValue / divisor) % 10, - hideZero: position > 0 && absValue < divisor - })); - } else { - const startDigit = Math.floor(absStartValue / divisor) % 10; - const endDigit = Math.floor(absTargetValue / divisor) % 10; - const fullRotations = Math.floor(absTargetValue / (divisor * 10)) - - Math.floor(absStartValue / (divisor * 10)); - let distance = endDigit - startDigit + fullRotations * 10; - if (delta < 0 && endDigit > startDigit) distance -= 10; + const toZero = createDigitCharFunction(position, divisor, absStartValue, 0); + const fromZero = createDigitCharFunction(position, divisor, 0, absTargetValue); + const inFirstSegment = (value) => startValue < 0 ? value < 0 : value > 0; - charFunctions.push((absValue, progress) => ({ - value: ((startDigit + progress * distance) % 10 + 10) % 10, - hideZero: position > 0 && absValue < divisor - })); + return (value, progress) => + inFirstSegment(value) ? + toZero(value, (value - startValue) / -startValue) : + fromZero(value, value / targetValue); + } else { + return createDigitCharFunction(position, divisor, absStartValue, absTargetValue); } - digitIndex++; } else if (digitIndex < integerDigitCount) { const threshold = Math.pow(10, integerDigitCount - digitIndex); - charFunctions.push((absValue) => ({text: char, hide: absValue < threshold})); + return (value) => ({text: char, hide: Math.abs(value) < threshold}); } else { - charFunctions.push(() => ({text: char})); + return () => ({text: char}); } - } + }); if (hasNegative) { - charFunctions.unshift((absValue, progress, value) => ({text: '-', hide: value > -1})); + charFunctions.unshift((value) => ({text: '-', hide: value > -1})); } + const range = targetValue - startValue; + return (value) => { - const absValue = Math.abs(value); const progress = range === 0 ? 0 : (value - startValue) / range; - return charFunctions.map(fn => fn(absValue, progress, value)); + return charFunctions.map(fn => fn(value, progress)); }; } +function createDigitCharFunction(position, divisor, segmentStart, segmentEnd) { + const startDigit = Math.floor(segmentStart / divisor) % 10; + const endDigit = Math.floor(segmentEnd / divisor) % 10; + const fullRotations = Math.floor(segmentEnd / (divisor * 10)) - + Math.floor(segmentStart / (divisor * 10)); + const distance = endDigit - startDigit + fullRotations * 10; + + return (value, progress) => ({ + value: ((startDigit + progress * distance) % 10 + 10) % 10, + hideZero: position > 0 && Math.abs(value) < divisor * 1.9 + }); +} + export function useWheelCharacters({startValue, targetValue, decimalPlaces = 0, locale = 'en', useGrouping = false}) { return useMemo( () => createWheelCharacterFunctions({startValue, targetValue, decimalPlaces, locale, useGrouping}), From 3d91a9be43fea821555f5c7d023846acc517b68a Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Jan 2026 15:47:21 +0100 Subject: [PATCH 2/3] Show minus sign correctly for decimal places The minus sign now appears when the displayed value is actually negative. For integers, this is at -1; for 1 decimal place, at -0.1. Previously the minus was hidden until -1 regardless of decimal places. REDMINE-21218 --- .../counter/useWheelCharacters-spec.js | 13 ++++++++++++- .../contentElements/counter/WheelNumber.module.css | 1 - .../contentElements/counter/useWheelCharacters.js | 3 ++- 3 files changed, 14 insertions(+), 3 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js b/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js index 1eaed0c57..926507198 100644 --- a/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js @@ -65,12 +65,23 @@ describe('createWheelCharacterFunctions', () => { ]); }); - it('hides minus sign when transitioning to negative', () => { + it('hides minus sign when transitioning to negative integer', () => { const result = getRotationValues({value: -0.4, startValue: 0, targetValue: -1}); expect(result[0]).toEqual({text: '-', hide: true}); }); + it('shows minus sign for small negative values with decimal places', () => { + const result = getRotationValues({ + value: -0.1, + startValue: 0, + targetValue: -0.5, + decimalPlaces: 1 + }); + + expect(result[0]).toEqual({text: '-', hide: false}); + }); + it('includes hidden minus sign at start when counting to negative', () => { const result = getRotationValues({value: 10, startValue: 10, targetValue: -10}); diff --git a/entry_types/scrolled/package/src/contentElements/counter/WheelNumber.module.css b/entry_types/scrolled/package/src/contentElements/counter/WheelNumber.module.css index 8d48e1ee4..5f49fb336 100644 --- a/entry_types/scrolled/package/src/contentElements/counter/WheelNumber.module.css +++ b/entry_types/scrolled/package/src/contentElements/counter/WheelNumber.module.css @@ -29,5 +29,4 @@ .hidden { opacity: 0; - transition: none; } diff --git a/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js b/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js index f1140bbb3..1a693f1da 100644 --- a/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js +++ b/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js @@ -45,7 +45,8 @@ export function createWheelCharacterFunctions({startValue, targetValue, decimalP }); if (hasNegative) { - charFunctions.unshift((value) => ({text: '-', hide: value > -1})); + const minusThreshold = -Math.pow(10, -decimalPlaces); + charFunctions.unshift((value) => ({text: '-', hide: value > minusThreshold})); } const range = targetValue - startValue; From 987917f2ba9968a831e01a5e8efa46429ba2624d Mon Sep 17 00:00:00 2001 From: Tim Fischbach Date: Fri, 30 Jan 2026 15:54:33 +0100 Subject: [PATCH 3/3] Fix floating point precision in wheel digit extraction Decimal values like 0.7 were displayed incorrectly because dividing by 0.1 produces 6.999... instead of 7. Multiply by integer (10, 100) instead of dividing by fraction (0.1, 0.01) since integer multiplication is exact. REDMINE-21218 --- .../counter/useWheelCharacters-spec.js | 16 ++++++++++ .../counter/useWheelCharacters.js | 30 ++++++++++++++----- 2 files changed, 38 insertions(+), 8 deletions(-) diff --git a/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js b/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js index 926507198..264f0bb2b 100644 --- a/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js +++ b/entry_types/scrolled/package/spec/contentElements/counter/useWheelCharacters-spec.js @@ -231,6 +231,22 @@ describe('createWheelCharacterFunctions', () => { ]); }); + it('handles floating point precision when counting to 0.7', () => { + const result = getRotationValues({ + value: 0.7, + startValue: 0, + targetValue: 0.7, + decimalPlaces: 1, + locale: 'en' + }); + + expect(result).toEqual([ + {value: 0, hideZero: false}, + {text: '.'}, + {value: 7, hideZero: false} + ]); + }); + it('does not hide leading digit at start when counting down from 1900 to 0', () => { const result = getRotationValues({value: 1900, startValue: 1900, targetValue: 0}); diff --git a/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js b/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js index 1a693f1da..284718c18 100644 --- a/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js +++ b/entry_types/scrolled/package/src/contentElements/counter/useWheelCharacters.js @@ -1,6 +1,17 @@ import {useMemo} from 'react'; -export function createWheelCharacterFunctions({startValue, targetValue, decimalPlaces = 0, locale = 'en', useGrouping = false}) { +export function useWheelCharacters({ + startValue, targetValue, decimalPlaces = 0, locale = 'en', useGrouping = false +}) { + return useMemo( + () => createWheelCharacterFunctions({startValue, targetValue, decimalPlaces, locale, useGrouping}), + [startValue, targetValue, decimalPlaces, locale, useGrouping] + ); +} + +export function createWheelCharacterFunctions({ + startValue, targetValue, decimalPlaces = 0, locale = 'en', useGrouping = false +}) { const hasNegative = startValue < 0 || targetValue < 0; const crossesZero = (startValue > 0 && targetValue < 0) || (startValue < 0 && targetValue > 0); const absStartValue = Math.abs(startValue); @@ -58,8 +69,8 @@ export function createWheelCharacterFunctions({startValue, targetValue, decimalP } function createDigitCharFunction(position, divisor, segmentStart, segmentEnd) { - const startDigit = Math.floor(segmentStart / divisor) % 10; - const endDigit = Math.floor(segmentEnd / divisor) % 10; + const startDigit = getDigitAtPosition(segmentStart, divisor); + const endDigit = getDigitAtPosition(segmentEnd, divisor); const fullRotations = Math.floor(segmentEnd / (divisor * 10)) - Math.floor(segmentStart / (divisor * 10)); const distance = endDigit - startDigit + fullRotations * 10; @@ -70,9 +81,12 @@ function createDigitCharFunction(position, divisor, segmentStart, segmentEnd) { }); } -export function useWheelCharacters({startValue, targetValue, decimalPlaces = 0, locale = 'en', useGrouping = false}) { - return useMemo( - () => createWheelCharacterFunctions({startValue, targetValue, decimalPlaces, locale, useGrouping}), - [startValue, targetValue, decimalPlaces, locale, useGrouping] - ); +function getDigitAtPosition(value, divisor) { + // Multiply by integer instead of dividing by fraction to avoid floating point errors + // (e.g., 0.7 / 0.1 = 6.999... but 0.7 * 10 = 7) + if (divisor < 1) { + const multiplier = Math.round(1 / divisor); + return Math.floor(value * multiplier) % 10; + } + return Math.floor(value / divisor) % 10; }