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
This commit is contained in:
scriptis
2022-09-09 09:42:01 +03:00
committed by GitHub
parent 5fdaf3f8ba
commit a4a9c66a8d
8 changed files with 290 additions and 317 deletions
@@ -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;
}
}
@@ -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<AnimatedNumberProps> {
/**
* The inner `<span/>` being updated sixty times per second.
*/
ref = createRef<HTMLSpanElement>();
/**
* 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 <span ref={this.ref}>{this.getText()}</span>;
}
}
@@ -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 && (
<AnimatedNumber
value={displayValue}
format={format}>
{renderDisplayElement}
</AnimatedNumber>
) || (
renderDisplayElement(format
? format(displayValue)
: displayValue)
)
<>
{
(animated && !dragging && !suppressingFlicker) ?
(<AnimatedNumber value={displayValue} format={format} />) :
(format ? format(displayValue) : displayValue)
}
{ (unit ? ' ' + unit : '') }
</>
);
// Setup an input element
// Handles direct input via the keyboard
const inputElement = (
+11 -10
View File
@@ -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 = (
<div className="NumberInput__content" unselectable={Byond.IS_LTE_IE8}>
{value + (unit ? ' ' + unit : '')}
{
(animated && !dragging && !suppressingFlicker) ?
(<AnimatedNumber value={displayValue} format={format} />) :
(format ? format(displayValue) : displayValue)
}
{unit ? ' ' + unit : ''}
</div>
);
const contentElement =
(animated && !dragging && !suppressingFlicker && (
<AnimatedNumber value={displayValue} format={format}>
{renderContentElement}
</AnimatedNumber>
)) ||
renderContentElement(format ? format(displayValue) : displayValue);
return (
<Box
className={classes([
+37 -45
View File
@@ -191,31 +191,27 @@ export const ChemHeater = (props, context) => {
/>
</Flex.Item>
<Flex.Item>
<AnimatedNumber value={currentpH}>
{(_, value) => (
<RoundGauge
size={1.6}
value={value}
minValue={0}
maxValue={14}
alertAfter={isFlashing}
content={'test'}
format={(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],
}}
/>
)}
</AnimatedNumber>
<RoundGauge
size={1.6}
value={currentpH}
minValue={0}
maxValue={14}
alertAfter={isFlashing}
content={'test'}
format={() => ''}
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],
}}
/>
</Flex.Item>
</Flex>
}>
@@ -250,26 +246,22 @@ export const ChemHeater = (props, context) => {
ml={2.5}
/>
)) || (
<AnimatedNumber value={reaction.quality}>
{(_, value) => (
<RoundGauge
size={1.3}
value={value}
minValue={0}
maxValue={1}
alertAfter={reaction.purityAlert}
content={'test'}
format={(value) => null}
ml={5}
ranges={{
'red': [0, reaction.minPure],
'orange': [reaction.minPure, reaction.inverse],
'yellow': [reaction.inverse, 0.8],
'green': [0.8, 1],
}}
/>
)}
</AnimatedNumber>
<RoundGauge
size={1.3}
value={reaction.quality}
minValue={0}
maxValue={1}
alertAfter={reaction.purityAlert}
content={'test'}
format={(value) => ''}
ml={5}
ranges={{
'red': [0, reaction.minPure],
'orange': [reaction.minPure, reaction.inverse],
'yellow': [reaction.inverse, 0.8],
'green': [0.8, 1],
}}
/>
)}
</Table.Cell>
<Table.Cell width={'70px'}>
@@ -215,31 +215,27 @@ export const ChemRecipeDebug = (props, context) => {
/>
</Flex.Item>
<Flex.Item>
<AnimatedNumber value={currentpH}>
{(_, value) => (
<RoundGauge
size={1.6}
value={value}
minValue={0}
maxValue={14}
alertAfter={isFlashing}
content={'test'}
format={(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],
}}
/>
)}
</AnimatedNumber>
<RoundGauge
size={1.6}
value={currentpH}
minValue={0}
maxValue={14}
alertAfter={isFlashing}
content={'test'}
format={() => ''}
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],
}}
/>
</Flex.Item>
</Flex>
}>
@@ -265,26 +261,22 @@ export const ChemRecipeDebug = (props, context) => {
{reaction.name}
</Table.Cell>
<Table.Cell width={'100px'} pr={'10px'}>
<AnimatedNumber value={reaction.quality}>
{(_, value) => (
<RoundGauge
size={1.3}
value={value}
minValue={0}
maxValue={1}
alertAfter={reaction.purityAlert}
content={'test'}
format={(value) => null}
ml={5}
ranges={{
'red': [0, reaction.minPure],
'orange': [reaction.minPure, reaction.inverse],
'yellow': [reaction.inverse, 0.8],
'green': [0.8, 1],
}}
/>
)}
</AnimatedNumber>
<RoundGauge
size={1.3}
value={reaction.quality}
minValue={0}
maxValue={1}
alertAfter={reaction.purityAlert}
content={'test'}
format={() => ''}
ml={5}
ranges={{
'red': [0, reaction.minPure],
'orange': [reaction.minPure, reaction.inverse],
'yellow': [reaction.inverse, 0.8],
'green': [0.8, 1],
}}
/>
</Table.Cell>
<Table.Cell width={'70px'}>
<ProgressBar
@@ -1,115 +0,0 @@
import { Component, createRef } from 'inferno';
import { formatSiUnit } from '../../format';
/**
* The properties of an animated quantity label.
*/
export type AnimatedQuantityLabelProps = {
/**
* The target value to approach.
*/
targetValue: number;
};
/**
* Quantity labels are animated at roughly 60 frames per second.
*/
const SIXTY_HZ = 1_000.0 / 60.0;
/**
* An animated quantity label. Shows an SI-encoded number, and animates it
* towards towards the provided target value.
*/
export class AnimatedQuantityLabel extends Component<AnimatedQuantityLabelProps> {
/**
* The inner `<span/>` being updated sixty times per second.
*/
ref = createRef<HTMLSpanElement>();
/**
* 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 <span ref={this.ref}>{this.getText()}</span>;
}
}
@@ -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) => {
<MaterialIcon material={material.name} />
</Flex.Item>
<Flex.Item>
<AnimatedQuantityLabel targetValue={material.amount} />
<AnimatedNumber value={material.amount} format={LABEL_FORMAT} />
</Flex.Item>
</Flex>
{hovering && (