From a4a9c66a8db92dec1dcdf2bdb10dd658701ca4b3 Mon Sep 17 00:00:00 2001 From: scriptis Date: Fri, 9 Sep 2022 01:42:01 -0500 Subject: [PATCH] Makes AnimatedNumber overperform (at 60 FPS) (#69731) * Replace `AnimatedNumber` with the 60 FPS counter for techfabs * stylemistake changes: round 1 * Revert some refactoring * Revert some more refactoring * Revert that one last stray hunk * Fix an oversight in NumberInput * Match the style of `DraggableControl` 1:1 * Use `textContent` * Drop dead import --- .../tgui/components/AnimatedNumber.js | 84 -------- .../tgui/components/AnimatedNumber.tsx | 187 ++++++++++++++++++ .../tgui/components/DraggableControl.js | 25 +-- tgui/packages/tgui/components/NumberInput.js | 21 +- tgui/packages/tgui/interfaces/ChemHeater.js | 82 ++++---- .../tgui/interfaces/ChemRecipeDebug.js | 82 ++++---- .../Fabrication/AnimatedQuantityLabel.tsx | 115 ----------- .../Fabrication/MineralAccessBar.tsx | 11 +- 8 files changed, 290 insertions(+), 317 deletions(-) delete mode 100644 tgui/packages/tgui/components/AnimatedNumber.js create mode 100644 tgui/packages/tgui/components/AnimatedNumber.tsx delete mode 100644 tgui/packages/tgui/interfaces/Fabrication/AnimatedQuantityLabel.tsx diff --git a/tgui/packages/tgui/components/AnimatedNumber.js b/tgui/packages/tgui/components/AnimatedNumber.js deleted file mode 100644 index 0d330d5f844..00000000000 --- a/tgui/packages/tgui/components/AnimatedNumber.js +++ /dev/null @@ -1,84 +0,0 @@ -/** - * @file - * @copyright 2020 Aleksej Komarov - * @license MIT - */ - -import { clamp, toFixed } from 'common/math'; -import { Component } from 'inferno'; - -const FPS = 20; -const Q = 0.5; - -const isSafeNumber = (value) => { - // prettier-ignore - return typeof value === 'number' - && Number.isFinite(value) - && !Number.isNaN(value); -}; - -export class AnimatedNumber extends Component { - constructor(props) { - super(props); - this.timer = null; - this.state = { - value: 0, - }; - // Use provided initial state - if (isSafeNumber(props.initial)) { - this.state.value = props.initial; - } - // Set initial state with value provided in props - else if (isSafeNumber(props.value)) { - this.state.value = Number(props.value); - } - } - - tick() { - const { props, state } = this; - const currentValue = Number(state.value); - const targetValue = Number(props.value); - // Avoid poisoning our state with infinities and NaN - if (!isSafeNumber(targetValue)) { - return; - } - // Smooth the value using an exponential moving average - const value = currentValue * Q + targetValue * (1 - Q); - this.setState({ value }); - } - - componentDidMount() { - this.timer = setInterval(() => this.tick(), 1000 / FPS); - } - - componentWillUnmount() { - clearTimeout(this.timer); - } - - render() { - const { props, state } = this; - const { format, children } = props; - const currentValue = state.value; - const targetValue = props.value; - // Directly display values which can't be animated - if (!isSafeNumber(targetValue)) { - return targetValue || null; - } - let formattedValue; - // Use custom formatter - if (format) { - formattedValue = format(currentValue); - } - // Fix our animated precision at target value's precision. - else { - const fraction = String(targetValue).split('.')[1]; - const precision = fraction ? fraction.length : 0; - formattedValue = toFixed(currentValue, clamp(precision, 0, 8)); - } - // Use a custom render function - if (typeof children === 'function') { - return children(formattedValue, currentValue); - } - return formattedValue; - } -} diff --git a/tgui/packages/tgui/components/AnimatedNumber.tsx b/tgui/packages/tgui/components/AnimatedNumber.tsx new file mode 100644 index 00000000000..6dba043cf09 --- /dev/null +++ b/tgui/packages/tgui/components/AnimatedNumber.tsx @@ -0,0 +1,187 @@ +/** + * @file + * @copyright 2020 Aleksej Komarov + * @license MIT + */ + +import { clamp, toFixed } from 'common/math'; +import { Component, createRef } from 'inferno'; + +const isSafeNumber = (value: number) => { + // prettier-ignore + return typeof value === 'number' + && Number.isFinite(value) + && !Number.isNaN(value); +}; + +export type AnimatedNumberProps = { + /** + * The target value to approach. + */ + value: number; + + /** + * If provided, the initial value displayed. By default, the same as `value`. + * If `initial` and `value` are different, the component immediately starts + * animating. + */ + initial?: number; + + /** + * If provided, a function that formats the inner string. By default, + * attempts to match the numeric precision of `value`. + */ + format?: (value: number) => string; +}; + +/** + * Animated numbers are animated at roughly 60 frames per second. + */ +const SIXTY_HZ = 1_000.0 / 60.0; + +/** + * The exponential moving average coefficient. Larger values result in a faster + * convergence. + */ +const Q = 0.8333; + +/** + * A small number. + */ +const EPSILON = 10e-4; + +/** + * An animated number label. Shows a number, formatted with an optionally + * provided function, and animates it towards its target value. + */ +export class AnimatedNumber extends Component { + /** + * The inner `` being updated sixty times per second. + */ + ref = createRef(); + + /** + * The interval being used to update the inner span. + */ + interval?: NodeJS.Timeout; + + /** + * The current value. This values approaches the target value. + */ + currentValue: number = 0; + + constructor(props: AnimatedNumberProps) { + super(props); + + const { initial, value } = props; + + if (initial !== undefined && isSafeNumber(initial)) { + this.currentValue = initial; + } else if (isSafeNumber(value)) { + this.currentValue = value; + } + } + + componentDidMount() { + if (this.currentValue !== this.props.value) { + this.startTicking(); + } + } + + componentWillUnmount() { + // Stop animating when the component is unmounted. + this.stopTicking(); + } + + shouldComponentUpdate(newProps: AnimatedNumberProps) { + if (newProps.value !== this.props.value) { + // The target value has been adjusted; start animating if we aren't + // already. + this.startTicking(); + } + + // We render the inner `span` directly using a ref to bypass inferno diffing + // and reach 60 frames per second--tell inferno not to re-render this tree. + return false; + } + + /** + * Starts animating the inner span. If the inner span is already animating, + * this is a no-op. + */ + startTicking() { + if (this.interval !== undefined) { + // We're already ticking; do nothing. + return; + } + + this.interval = setInterval(() => this.tick(), SIXTY_HZ); + } + + /** + * Stops animating the inner span. + */ + stopTicking() { + if (this.interval === undefined) { + // We're not ticking; do nothing. + return; + } + + clearInterval(this.interval); + + this.interval = undefined; + } + + /** + * Steps forward one frame. + */ + tick() { + const { currentValue } = this; + const { value } = this.props; + + if (isSafeNumber(value)) { + // Converge towards the value. + this.currentValue = currentValue * Q + value * (1 - Q); + } else { + // If the value is unsafe, we're never going to converge, so stop ticking. + this.stopTicking(); + } + + if (Math.abs(value - this.currentValue) < EPSILON) { + // We're about as close as we're going to get--snap to the value and + // stop ticking. + this.currentValue = value; + this.stopTicking(); + } + + if (this.ref.current) { + // Directly update the inner span, without bothering inferno. + this.ref.current.textContent = this.getText(); + } + } + + /** + * Gets the inner text of the span. + */ + getText() { + const { props, currentValue } = this; + const { format, value } = props; + + if (!isSafeNumber(value)) { + return String(value); + } + + if (format) { + return format(this.currentValue); + } + + const fraction = String(value).split('.')[1]; + const precision = fraction ? fraction.length : 0; + + return toFixed(currentValue, clamp(precision, 0, 8)); + } + + render() { + return {this.getText()}; + } +} diff --git a/tgui/packages/tgui/components/DraggableControl.js b/tgui/packages/tgui/components/DraggableControl.js index ddce215a6f3..8ada6f2fa4d 100644 --- a/tgui/packages/tgui/components/DraggableControl.js +++ b/tgui/packages/tgui/components/DraggableControl.js @@ -174,24 +174,19 @@ export class DraggableControl extends Component { if (dragging || suppressingFlicker) { displayValue = intermediateValue; } - // Setup a display element - // Shows a formatted number based on what we are currently doing - // with the draggable surface. - const renderDisplayElement = (value) => value + (unit ? ' ' + unit : ''); // prettier-ignore const displayElement = ( - animated && !dragging && !suppressingFlicker && ( - - {renderDisplayElement} - - ) || ( - renderDisplayElement(format - ? format(displayValue) - : displayValue) - ) + <> + { + (animated && !dragging && !suppressingFlicker) ? + () : + (format ? format(displayValue) : displayValue) + } + + { (unit ? ' ' + unit : '') } + ); + // Setup an input element // Handles direct input via the keyboard const inputElement = ( diff --git a/tgui/packages/tgui/components/NumberInput.js b/tgui/packages/tgui/components/NumberInput.js index e0db9b2bb43..e264d811d37 100644 --- a/tgui/packages/tgui/components/NumberInput.js +++ b/tgui/packages/tgui/components/NumberInput.js @@ -164,19 +164,20 @@ export class NumberInput extends Component { if (dragging || suppressingFlicker) { displayValue = intermediateValue; } - // IE8: Use an "unselectable" prop because "user-select" doesn't work. - const renderContentElement = (value) => ( + + // prettier-ignore + const contentElement = (
- {value + (unit ? ' ' + unit : '')} + { + (animated && !dragging && !suppressingFlicker) ? + () : + (format ? format(displayValue) : displayValue) + } + + {unit ? ' ' + unit : ''}
); - const contentElement = - (animated && !dragging && !suppressingFlicker && ( - - {renderContentElement} - - )) || - renderContentElement(format ? format(displayValue) : displayValue); + return ( { /> - - {(_, value) => ( - null} - ranges={{ - 'red': [-0.22, 1.5], - 'orange': [1.5, 3], - 'yellow': [3, 4.5], - 'olive': [4.5, 5], - 'good': [5, 6], - 'green': [6, 8.5], - 'teal': [8.5, 9.5], - 'blue': [9.5, 11], - 'purple': [11, 12.5], - 'violet': [12.5, 14], - }} - /> - )} - + ''} + ranges={{ + 'red': [-0.22, 1.5], + 'orange': [1.5, 3], + 'yellow': [3, 4.5], + 'olive': [4.5, 5], + 'good': [5, 6], + 'green': [6, 8.5], + 'teal': [8.5, 9.5], + 'blue': [9.5, 11], + 'purple': [11, 12.5], + 'violet': [12.5, 14], + }} + /> }> @@ -250,26 +246,22 @@ export const ChemHeater = (props, context) => { ml={2.5} /> )) || ( - - {(_, value) => ( - null} - ml={5} - ranges={{ - 'red': [0, reaction.minPure], - 'orange': [reaction.minPure, reaction.inverse], - 'yellow': [reaction.inverse, 0.8], - 'green': [0.8, 1], - }} - /> - )} - + ''} + ml={5} + ranges={{ + 'red': [0, reaction.minPure], + 'orange': [reaction.minPure, reaction.inverse], + 'yellow': [reaction.inverse, 0.8], + 'green': [0.8, 1], + }} + /> )} diff --git a/tgui/packages/tgui/interfaces/ChemRecipeDebug.js b/tgui/packages/tgui/interfaces/ChemRecipeDebug.js index b90d9f49ba2..a1b90b2f6d7 100644 --- a/tgui/packages/tgui/interfaces/ChemRecipeDebug.js +++ b/tgui/packages/tgui/interfaces/ChemRecipeDebug.js @@ -215,31 +215,27 @@ export const ChemRecipeDebug = (props, context) => { /> - - {(_, value) => ( - null} - ranges={{ - 'red': [-0.22, 1.5], - 'orange': [1.5, 3], - 'yellow': [3, 4.5], - 'olive': [4.5, 5], - 'good': [5, 6], - 'green': [6, 8.5], - 'teal': [8.5, 9.5], - 'blue': [9.5, 11], - 'purple': [11, 12.5], - 'violet': [12.5, 14], - }} - /> - )} - + ''} + ranges={{ + 'red': [-0.22, 1.5], + 'orange': [1.5, 3], + 'yellow': [3, 4.5], + 'olive': [4.5, 5], + 'good': [5, 6], + 'green': [6, 8.5], + 'teal': [8.5, 9.5], + 'blue': [9.5, 11], + 'purple': [11, 12.5], + 'violet': [12.5, 14], + }} + /> }> @@ -265,26 +261,22 @@ export const ChemRecipeDebug = (props, context) => { {reaction.name} - - {(_, value) => ( - null} - ml={5} - ranges={{ - 'red': [0, reaction.minPure], - 'orange': [reaction.minPure, reaction.inverse], - 'yellow': [reaction.inverse, 0.8], - 'green': [0.8, 1], - }} - /> - )} - + ''} + ml={5} + ranges={{ + 'red': [0, reaction.minPure], + 'orange': [reaction.minPure, reaction.inverse], + 'yellow': [reaction.inverse, 0.8], + 'green': [0.8, 1], + }} + /> { - /** - * The inner `` being updated sixty times per second. - */ - ref = createRef(); - - /** - * The interval being used to update the inner span. - */ - interval?: NodeJS.Timeout; - - /** - * The current value. This values approaches the target value. - */ - currentValue: number = 0; - - constructor(props: AnimatedQuantityLabelProps) { - super(props); - - this.currentValue = props.targetValue; - } - - componentWillUnmount() { - // Stop animating when the component is unmounted. - this.stopTicking(); - } - - shouldComponentUpdate(newProps: AnimatedQuantityLabelProps) { - if (newProps.targetValue !== this.props.targetValue) { - // The target value has been adjusted; start animating if we aren't - // already. - this.startTicking(); - } - - // Never re-render this component; we handle it manually. Inferno is too - // slow to handle 60 frames per second in IE. - return false; - } - - /** - * Starts animating the inner span. If the inner span is already animating, - * this is a no-op. - */ - startTicking() { - if (this.interval !== undefined) { - return; - } - - this.interval = setInterval(() => this.tick(), SIXTY_HZ); - } - - /** - * Stops animating the inner span. - */ - stopTicking() { - if (this.interval !== undefined) { - clearInterval(this.interval); - - this.interval = undefined; - } - } - - /** - * Steps forward one frame. - */ - tick() { - const { currentValue } = this; - const { targetValue } = this.props; - - this.currentValue += (targetValue - currentValue) / SIXTY_HZ; - - if (Math.abs(targetValue - currentValue) < 1) { - this.stopTicking(); - } - - if (this.ref.current) { - this.ref.current.innerText = this.getText(); - } - } - - /** - * Returns the inner text of the span. - */ - getText() { - return formatSiUnit(this.currentValue, 0); - } - - render() { - // Only executes for the first render; afterwards, we directly animate - // the inner contents using the ref. - return {this.getText()}; - } -} diff --git a/tgui/packages/tgui/interfaces/Fabrication/MineralAccessBar.tsx b/tgui/packages/tgui/interfaces/Fabrication/MineralAccessBar.tsx index a81f646b2a5..c21acc1b6f8 100644 --- a/tgui/packages/tgui/interfaces/Fabrication/MineralAccessBar.tsx +++ b/tgui/packages/tgui/interfaces/Fabrication/MineralAccessBar.tsx @@ -1,8 +1,8 @@ import { sortBy } from 'common/collections'; import { useLocalState } from '../../backend'; -import { Flex, Button, Stack } from '../../components'; +import { Flex, Button, Stack, AnimatedNumber } from '../../components'; +import { formatSiUnit } from '../../format'; import { Material, MaterialIcon } from '../common/Materials'; -import { AnimatedQuantityLabel } from './AnimatedQuantityLabel'; import { MaterialName } from './Types'; // by popular demand of discord people (who are always right and never wrong) @@ -33,6 +33,11 @@ export type MineralAccessBarProps = { onEjectRequested?: (material: Material, quantity: number) => void; }; +/** + * The formatting function applied to the quantity labels in the bar. + */ +const LABEL_FORMAT = (value: number) => formatSiUnit(value, 0); + /** * A bottom-docked bar for viewing and ejecting materials from local storage or * the ore silo. Has pop-out docks for each material type for ejecting up to @@ -87,7 +92,7 @@ const MineralCounter = (props: MineralCounterProps, context) => { - + {hovering && (