mirror of
https://github.com/Bubberstation/Bubberstation.git
synced 2026-08-30 00:29:02 +01:00
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:
@@ -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 = (
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
Reference in New Issue
Block a user