This commit is contained in:
Aleksej Komarov
2020-04-19 19:38:55 +03:00
parent 40b7566c9c
commit f73d00d2c4
11 changed files with 336 additions and 386 deletions
+32 -5
View File
@@ -2,13 +2,15 @@
* Limits a number to the range between 'min' and 'max'.
*/
export const clamp = (value, min, max) => {
return Math.max(min, Math.min(value, max));
return value < min ? min : value > max ? max : value;
};
/**
* Limits a number between 0 and 1.
*/
export const clamp01 = value => clamp(value, 0, 1);
export const clamp01 = value => {
return value < 0 ? 0 : value > 1 ? 1 : value;
};
/**
* Scales a number to fit into the range between min and max.
@@ -18,10 +20,35 @@ export const scale = (value, min, max) => {
};
/**
* Returns a rounded number.
* TODO: Replace this native rounding function with a more robust one.
* Robust number rounding.
*
* Adapted from Locutus, see: http://locutus.io/php/math/round/
*
* @param {number} value
* @param {number} precision
* @return {number}
*/
export const round = value => Math.round(value);
export const round = (value, precision) => {
if (!value || isNaN(value)) {
return value;
}
// helper variables
let m, f, isHalf, sgn;
// making sure precision is integer
precision |= 0;
m = Math.pow(10, precision);
value *= m;
// sign of the number
sgn = (value > 0) | -(value < 0);
// isHalf = value % 1 === 0.5 * sgn;
isHalf = Math.abs(value % 1) >= 0.4999999999854481;
f = Math.floor(value);
if (isHalf) {
// rounds .5 away from zero
value = f + (sgn > 0);
}
return (isHalf ? value : Math.round(value)) / m;
};
/**
* Returns a string representing a number in fixed point notation.
+4 -4
View File
@@ -1,4 +1,4 @@
import { clamp, keyOfMatchingRange, toFixed } from 'common/math';
import { clamp01, scale, keyOfMatchingRange, toFixed } from 'common/math';
import { classes, pureComponentHooks } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
@@ -13,7 +13,7 @@ export const ProgressBar = props => {
children,
...rest
} = props;
const scaledValue = (value - minValue) / (maxValue - minValue);
const scaledValue = scale(value, minValue, maxValue);
const hasContent = children !== undefined;
const effectiveColor = color
|| keyOfMatchingRange(value, ranges)
@@ -28,9 +28,9 @@ export const ProgressBar = props => {
])}
{...computeBoxProps(rest)}>
<div
className="ProgressBar__fill"
className="ProgressBar__fill ProgressBar__fill--animated"
style={{
width: (clamp(scaledValue, 0, 1) * 100) + '%',
width: clamp01(scaledValue) * 100 + '%',
}} />
<div className="ProgressBar__content">
{hasContent
+44 -132
View File
@@ -1,139 +1,51 @@
import { classes, normalizeChildren } from 'common/react';
import { Component } from 'inferno';
import { Box } from './Box';
import { classes } from 'common/react';
import { computeBoxClassName, computeBoxProps } from './Box';
import { Button } from './Button';
// A magic value for enforcing type safety
const TAB_MAGIC_TYPE = 'Tab';
const validateTabs = tabs => {
for (let tab of tabs) {
if (!tab.props || tab.props.__type__ !== TAB_MAGIC_TYPE) {
const json = JSON.stringify(tab, null, 2);
throw new Error('<Tabs> only accepts children of type <Tabs.Tab>.'
+ 'This is what we received: ' + json);
}
}
export const Tabs = props => {
const {
className,
vertical,
children,
...rest
} = props;
return (
<div
className={classes([
'Tabs',
vertical
? 'Tabs--vertical'
: 'Tabs--horizontal',
className,
computeBoxClassName(rest),
])}
{...computeBoxProps(rest)}>
<div className="Tabs__tabBox">
{children}
</div>
</div>
);
};
export class Tabs extends Component {
constructor(props) {
super(props);
this.state = {
activeTabKey: null,
};
}
getActiveTab() {
const { state, props } = this;
const tabs = normalizeChildren(props.children);
validateTabs(tabs);
// Get active tab
let activeTabKey = props.activeTab || state.activeTabKey;
// Verify that active tab exists
let activeTab = tabs
.find(tab => {
const key = tab.key || tab.props.label;
return key === activeTabKey;
});
// Set first tab as the active tab
if (!activeTab) {
activeTab = tabs[0];
activeTabKey = activeTab && (activeTab.key || activeTab.props.label);
}
return {
tabs,
activeTab,
activeTabKey,
};
}
render() {
const { props } = this;
const {
className,
vertical,
altSelection,
children,
...rest
} = props;
const {
tabs,
activeTab,
activeTabKey,
} = this.getActiveTab();
// Retrieve tab content
let content = null;
if (activeTab) {
content = activeTab.props.content || activeTab.props.children;
}
// Get children by calling a wrapper function
if (typeof content === 'function') {
content = content(activeTabKey);
}
return (
<Box
className={classes([
'Tabs',
vertical && 'Tabs--vertical',
className,
])}
{...rest}>
<div className="Tabs__tabBox">
{tabs.map(tab => {
const {
className,
label,
content, // ignored
children, // ignored
onClick,
highlight,
...rest
} = tab.props;
const key = tab.key || tab.props.label;
const active = tab.active || key === activeTabKey;
const altSelectionStyle = 'Button--altSelected'
+ (vertical ? '--right' : '--bottom');
return (
<Button
key={key}
className={classes([
'Tabs__tab',
active && 'Tabs__tab--active',
highlight && !active && 'color-yellow',
altSelection && active && altSelectionStyle,
className,
])}
selected={!altSelection && active}
color="transparent"
onClick={e => {
this.setState({ activeTabKey: key });
if (onClick) {
onClick(e, tab);
}
}}
{...rest}>
{label}
</Button>
);
})}
</div>
<div className="Tabs__content">
{content || null}
</div>
</Box>
);
}
}
/**
* A dummy component, which is used for carrying props for the
* tab container.
*/
export const Tab = props => null;
Tab.defaultProps = {
__type__: TAB_MAGIC_TYPE,
const Tab = props => {
const {
className,
selected,
altSelection,
...rest
} = props;
return (
<Button
className={classes([
'Tabs__tab',
selected && 'Tabs__tab--selected',
altSelection && selected && 'Tabs__tab--altSelection',
className,
])}
selected={!altSelection && selected}
color="transparent"
{...rest} />
);
};
Tabs.Tab = Tab;
+10 -1
View File
@@ -1,4 +1,4 @@
import { clamp, toFixed } from 'common/math';
import { clamp, round, toFixed } from 'common/math';
const SI_SYMBOLS = [
'f', // femto
@@ -54,3 +54,12 @@ const formatSiUnit = (value, minBase1000 = -SI_BASE_INDEX, unit = '') => {
export const formatPower = (value, minBase1000 = 0) => {
return formatSiUnit(value, minBase1000, 'W');
};
export const formatMoney = (value, precision = 0) => {
return round(value, precision)
.toLocaleString('en', {
minimumFractionDigits: precision,
})
// Thin space
.replace(/,/g, '\u2009');
};
+5 -1
View File
@@ -3,7 +3,11 @@ import 'core-js/web/immediate';
import 'core-js/web/queue-microtask';
import 'core-js/web/timers';
import 'regenerator-runtime/runtime';
import './polyfills';
// This one is necessary for Inferno.
if (!window.Int32Array) {
window.Int32Array = Array;
}
import { loadCSS } from 'fg-loadcss';
import { render } from 'inferno';
+111 -94
View File
@@ -1,15 +1,17 @@
import { useBackend } from '../backend';
import { useBackend, useLocalState } from '../backend';
import { Box, Icon, Table, Tabs } from '../components';
import { Window } from '../layouts';
import { Fragment } from 'inferno';
export const Achievements = (props, context) => {
const { data } = useBackend(context);
const {
achievements,
categories,
highscore,
user_ckey,
} = data;
const { categories } = data;
const [
selectedCategory,
setSelectedCategory,
] = useLocalState(context, 'category', categories[0]);
const achievements = data.achievements
.filter(x => x.category === selectedCategory);
return (
<Window resizable>
<Window.Content scrollable>
@@ -17,81 +19,40 @@ export const Achievements = (props, context) => {
{categories.map(category => (
<Tabs.Tab
key={category}
label={category}>
<Table>
{achievements
.filter(x => x.category === category)
.map(achievement => {
if (achievement.score) {
return (
<Score
key={achievement.name}
achievement={achievement} />
);
}
return (
<Achievement
key={achievement.name}
achievement={achievement} />
);
})}
</Table>
selected={selectedCategory === category}
onClick={() => setSelectedCategory(category)}>
{category}
</Tabs.Tab>
))}
<Tabs.Tab
label="High Scores">
<Tabs vertical>
{highscore.map(highscore => (
<Tabs.Tab
key={highscore.name}
label={highscore.name}>
<Table>
<Table.Row className="candystripe">
<Table.Cell color="label" textAlign="center">
#
</Table.Cell>
<Table.Cell color="label" textAlign="center">
Key
</Table.Cell>
<Table.Cell color="label" textAlign="center">
Score
</Table.Cell>
</Table.Row>
{Object.keys(highscore.scores).map((key, index) => (
<Table.Row
key={key}
className="candystripe"
m={2}>
<Table.Cell color="label" textAlign="center">
{index + 1}
</Table.Cell>
<Table.Cell
color={key === user_ckey && 'green'}
textAlign="center">
{index === 0 && (
<Icon name="crown" color="gold" mr={2} />
)}
{key}
{index === 0 && (
<Icon name="crown" color="gold" ml={2} />
)}
</Table.Cell>
<Table.Cell textAlign="center">
{highscore.scores[key]}
</Table.Cell>
</Table.Row>
))}
</Table>
</Tabs.Tab>
))}
</Tabs>
selected={selectedCategory === 'High Scores'}
onClick={() => setSelectedCategory('High Scores')}>
High Scores
</Tabs.Tab>
</Tabs>
{selectedCategory === 'High Scores' && (
<HighScoreTable />
) || (
<AchievementTable achievements={achievements} />
)}
</Window.Content>
</Window>
);
};
const AchievementTable = (props, context) => {
const { achievements } = props;
return (
<Table>
{achievements.map(achievement => (
<Achievement
key={achievement.name}
achievement={achievement} />
))}
</Table>
);
};
const Achievement = props => {
const { achievement } = props;
const {
@@ -99,6 +60,7 @@ const Achievement = props => {
desc,
icon_class,
value,
score,
} = achievement;
return (
<tr key={name}>
@@ -108,34 +70,89 @@ const Achievement = props => {
<td style={{ 'vertical-align': 'top' }}>
<h1>{name}</h1>
{desc}
<Box color={value ? 'good' : 'bad'}>
{value ? 'Unlocked' : 'Locked'}
</Box>
{score && (
<Box color={value > 0 ? 'good' : 'bad'}>
{value > 0 ? `Earned ${value} times` : 'Locked'}
</Box>
) || (
<Box color={value ? 'good' : 'bad'}>
{value ? 'Unlocked' : 'Locked'}
</Box>
)}
</td>
</tr>
);
};
const Score = props => {
const { achievement } = props;
const HighScoreTable = (props, context) => {
const { data } = useBackend(context);
const {
name,
desc,
icon_class,
value,
} = achievement;
highscore: highscores,
user_ckey,
} = data;
const [
highScoreIndex,
setHighScoreIndex,
] = useLocalState(context, 'highscore', 0);
const highscore = highscores[highScoreIndex];
if (!highscore) {
return null;
}
const scores = Object
.keys(highscore.scores)
.map(key => ({
ckey: key,
value: highscore.scores[key],
}));
return (
<tr key={name}>
<td style={{ 'padding': '6px' }}>
<Box className={icon_class} />
</td>
<td style={{ 'vertical-align': 'top' }}>
<h1>{name}</h1>
{desc}
<Box color={value > 0 ? 'good' : 'bad'}>
{value > 0 ? `Earned ${value} times` : 'Locked'}
</Box>
</td>
</tr>
<Fragment>
<Tabs vertical>
{highscores.map((highscore, i) => (
<Tabs.Tab
key={highscore.name}
selected={highScoreIndex === i}
onClick={() => setHighScoreIndex(i)}>
{highscore.name}
</Tabs.Tab>
))}
</Tabs>
<Table>
<Table.Row className="candystripe">
<Table.Cell color="label" textAlign="center">
#
</Table.Cell>
<Table.Cell color="label" textAlign="center">
Key
</Table.Cell>
<Table.Cell color="label" textAlign="center">
Score
</Table.Cell>
</Table.Row>
{scores.map((score, i) => (
<Table.Row
key={score.ckey}
className="candystripe"
m={2}>
<Table.Cell color="label" textAlign="center">
{i + 1}
</Table.Cell>
<Table.Cell
color={score.ckey === user_ckey && 'green'}
textAlign="center">
{i === 0 && (
<Icon name="crown" color="gold" mr={2} />
)}
{score.ckey}
{i === 0 && (
<Icon name="crown" color="gold" ml={2} />
)}
</Table.Cell>
<Table.Cell textAlign="center">
{score.value}
</Table.Cell>
</Table.Row>
))}
</Table>
</Fragment>
);
};
+29 -34
View File
@@ -1,7 +1,8 @@
import { toArray } from 'common/collections';
import { Fragment } from 'inferno';
import { useBackend, useSharedState } from '../backend';
import { AnimatedNumber, Box, Button, Divider, Flex, LabeledList, Section, Table } from '../components';
import { AnimatedNumber, Box, Button, Flex, LabeledList, Section, Table, Tabs } from '../components';
import { formatMoney } from '../format';
import { Window } from '../layouts';
export const Cargo = (props, context) => {
@@ -16,40 +17,34 @@ export const Cargo = (props, context) => {
<Window resizable>
<Window.Content scrollable>
<CargoStatus />
<Box mb={0.5}>
<Button
<Tabs>
<Tabs.Tab
icon="list"
color="transparent"
lineHeight="23px"
selected={tab === 'catalog'}
onClick={() => setTab('catalog')}>
Catalog
</Button>
<Button
</Tabs.Tab>
<Tabs.Tab
icon="envelope"
color="transparent"
textColor={tab !== 'requests'
&& requests.length > 0
&& 'yellow'}
lineHeight="23px"
selected={tab === 'requests'}
onClick={() => setTab('requests')}>
Requests ({requests.length})
</Button>
</Tabs.Tab>
{!requestonly && (
<Button
<Tabs.Tab
icon="shopping-cart"
color="transparent"
textColor={tab !== 'cart'
&& cart.length > 0
&& 'yellow'}
lineHeight="23px"
selected={tab === 'cart'}
onClick={() => setTab('cart')}>
Checkout ({cart.length})
</Button>
</Tabs.Tab>
)}
</Box>
</Tabs>
{tab === 'catalog' && (
<CargoCatalog />
)}
@@ -81,7 +76,10 @@ const CargoStatus = (props, context) => {
title="Cargo"
buttons={(
<Box inline bold>
<AnimatedNumber value={Math.round(points)} /> credits
<AnimatedNumber
value={points}
format={value => formatMoney(value)} />
{' credits'}
</Box>
)}>
<LabeledList>
@@ -143,19 +141,16 @@ export const CargoCatalog = (props, context) => {
)}>
<Flex>
<Flex.Item>
{supplies.map(supply => (
<Button
fluid
key={supply.name}
color="transparent"
selected={supply.name === activeSupplyName}
onClick={() => setActiveSupplyName(supply.name)}>
{supply.name} ({supply.packs.length})
</Button>
))}
</Flex.Item>
<Flex.Item mr={1}>
<Divider vertical />
<Tabs vertical>
{supplies.map(supply => (
<Tabs.Tab
key={supply.name}
selected={supply.name === activeSupplyName}
onClick={() => setActiveSupplyName(supply.name)}>
{supply.name} ({supply.packs.length})
</Tabs.Tab>
))}
</Tabs>
</Flex.Item>
<Flex.Item grow={1} basis={0}>
<Table>
@@ -190,9 +185,9 @@ export const CargoCatalog = (props, context) => {
onClick={() => act('add', {
id: pack.id,
})}>
{self_paid
{formatMoney(self_paid
? Math.round(pack.cost * 1.1)
: pack.cost}
: pack.cost)}
{' cr'}
</Button>
</Table.Cell>
@@ -247,7 +242,7 @@ const CargoRequests = (props, context) => {
<i>{request.reason}</i>
</Table.Cell>
<Table.Cell collapsing textAlign="right">
{request.cost} cr.
{formatMoney(request.cost)} cr
</Table.Cell>
{!requestonly && (
<Table.Cell collapsing>
@@ -290,7 +285,7 @@ const CargoCartButtons = (props, context) => {
{cart.length === 1 && '1 item'}
{cart.length >= 2 && cart.length + ' items'}
{' '}
{total > 0 && `(${total} cr)`}
{total > 0 && `(${formatMoney(total)} cr)`}
</Box>
<Button
icon="times"
@@ -339,7 +334,7 @@ const CargoCart = (props, context) => {
)}
</Table.Cell>
<Table.Cell collapsing textAlign="right">
{entry.cost} cr.
{formatMoney(entry.cost)} cr
</Table.Cell>
<Table.Cell collapsing>
<Button
+54 -56
View File
@@ -1,6 +1,6 @@
import { Component } from 'inferno';
import { useBackend, useLocalState } from '../backend';
import { BlockQuote, Box, Button, ByondUi, Collapsible, Icon, Input, Knob, LabeledList, NumberInput, ProgressBar, Section, Slider, Tabs, Tooltip } from '../components';
import { BlockQuote, Box, Button, ByondUi, Collapsible, Icon, Input, Knob, LabeledList, NumberInput, ProgressBar, Section, Slider, Tabs, Tooltip, Flex } from '../components';
import { DraggableControl } from '../components/DraggableControl';
import { Window } from '../layouts';
@@ -72,26 +72,31 @@ const PAGES = [
export const KitchenSink = (props, context) => {
const [theme] = useLocalState(context, 'kitchenSinkTheme');
const [pageIndex, setPageIndex] = useLocalState(context, 'pageIndex', 0);
const PageComponent = PAGES[pageIndex].component();
return (
<Window
theme={theme}
resizable>
<Window.Content scrollable>
<Section>
<Tabs vertical>
{PAGES.map(page => (
<Tabs.Tab
key={page.title}
label={page.title}>
{() => {
const Component = page.component();
return (
<Component {...props} />
);
}}
</Tabs.Tab>
))}
</Tabs>
<Flex>
<Flex.Item>
<Tabs vertical>
{PAGES.map((page, i) => (
<Tabs.Tab
key={i}
selected={i === pageIndex}
onClick={() => setPageIndex(i)}>
{page.title}
</Tabs.Tab>
))}
</Tabs>
</Flex.Item>
<Flex.Item grow={1} basis={0}>
<PageComponent />
</Flex.Item>
</Flex>
</Section>
</Window.Content>
</Window>
@@ -216,46 +221,39 @@ class KitchenSinkProgressBar extends Component {
}
}
class KitchenSinkTabs extends Component {
constructor() {
super();
this.state = {
vertical: true,
};
}
render() {
const { vertical } = this.state;
const TAB_KEYS = [1, 2, 3, 4, 5].map(x => 'tab_' + x);
return (
<Box>
{'Vertical: '}
<Button
const KitchenSinkTabs = (props, context) => {
const [tabIndex, setTabIndex] = useLocalState(context, 'tabIndex', 0);
const [vertical, setVertical] = useLocalState(context, 'tabVert');
const [altSelection, setAltSelection] = useLocalState(context, 'tabAlt');
const TAB_RANGE = [1, 2, 3, 4, 5];
return (
<Box>
<Box mb={2}>
<Button.Checkbox
inline
content={String(vertical)}
onClick={() => this.setState(prevState => ({
vertical: !prevState.vertical,
}))} />
<Box mb={2} />
<Tabs vertical={vertical}>
{TAB_KEYS.map(key => (
<Tabs.Tab
key={key}
label={'Label ' + key}>
{() => (
<Box>
{'Active tab: '}
<Box inline color="green">{key}</Box>
<BoxOfSampleText mt={2} />
</Box>
)}
</Tabs.Tab>
))}
</Tabs>
content="vertical"
checked={vertical}
onClick={() => setVertical(!vertical)} />
<Button.Checkbox
inline
content="altSelection"
checked={altSelection}
onClick={() => setAltSelection(!altSelection)} />
</Box>
);
}
}
<Tabs vertical={vertical}>
{TAB_RANGE.map((number, i) => (
<Tabs.Tab
key={i}
altSelection={altSelection}
selected={i === tabIndex}
onClick={() => setTabIndex(i)}>
Tab #{number}
</Tabs.Tab>
))}
</Tabs>
</Box>
);
};
const KitchenSinkTooltip = props => {
const positions = [
@@ -421,13 +419,13 @@ const KitchenSinkCollapsible = props => {
<Button icon="cog" />
)}>
<Section>
<BoxOfSampleText />
<BoxWithSampleText />
</Section>
</Collapsible>
);
};
const BoxOfSampleText = props => {
const BoxWithSampleText = props => {
return (
<Box {...props}>
<Box italic>
@@ -445,7 +443,7 @@ const BoxOfSampleText = props => {
const KitchenSinkBlockQuote = props => {
return (
<BlockQuote>
<BoxOfSampleText />
<BoxWithSampleText />
</BlockQuote>
);
};
-5
View File
@@ -1,5 +0,0 @@
// This one is necessary for Inferno to do complex DOM patching on IE8.
// Not the fastest one or most spec compliant, but hey, it works!
if (!window.Int32Array) {
window.Int32Array = Array;
}
@@ -113,27 +113,3 @@ $bg-map: colors.$bg-map !default;
.Button--selected {
@include button-color($color-selected);
}
.Button--altSelected--bottom::after {
content: "";
position: absolute;
bottom: 0;
right: 0;
left: 0;
height: 2px;
width: 100%;
background-color: colors.$white;
border-radius: $border-radius;
}
.Button--altSelected--right::after {
content: "";
position: absolute;
top: 0;
bottom: 0;
right: 0;
height: 100%;
width: 3px;
background-color: colors.$white;
border-radius: $border-radius;
}
+47 -30
View File
@@ -1,39 +1,56 @@
.Tabs__content {
padding-top: 6px;
border-top: 2px solid rgba(255, 255, 255, 0.1);
@use '../base.scss';
$border-radius: base.$border-radius !default;
.Tabs--horizontal {
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
margin-bottom: 6px;
.Tabs__tab--altSelection::after {
content: '';
position: absolute;
bottom: 0;
right: 0;
left: 0;
height: 2px;
width: 100%;
background-color: #fff;
border-radius: $border-radius;
}
}
.Tabs--vertical {
display: table-row;
}
margin-right: 9px;
// NOTE: These selectors have to be specific in order to stop cascading
// from influencing nested tabs.
.Tabs--vertical > .Tabs__content {
display: table-cell;
width: 100%;
padding-top: 0;
padding-left: 9px;
border-top: 0;
}
.Tabs__tabBox {
// padding-right: 2px;
border-right: 2px solid rgba(255, 255, 255, 0.1);
// Disable baseline alignment when doing vertical tabs
vertical-align: top;
}
.Tabs--vertical > .Tabs__tabBox {
display: table-cell;
// padding-right: 2px;
border-right: 2px solid rgba(255, 255, 255, 0.1);
// Disable baseline alignment when doing vertical tabs
vertical-align: top;
}
.Tabs__tab {
// Force display block because Button theme overrides it via cascading.
display: block !important;
margin-right: 0;
margin-bottom: 0;
padding: 1px 9px 0px 6px;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
.Tabs--vertical > .Tabs__tabBox > .Tabs__tab {
// Force display block because Button theme overrides it via cascading.
display: block !important;
margin-right: 0;
margin-bottom: 0;
padding: 1px 9px 0px 6px;
border-bottom: 2px solid rgba(255, 255, 255, 0.1);
&:last-child {
border-bottom: 0;
}
}
&:last-child {
border-bottom: 0;
.Tabs__tab--altSelection::after {
content: '';
position: absolute;
top: 0;
bottom: 0;
right: 0;
height: 100%;
width: 3px;
background-color: #fff;
border-radius: $border-radius;
}
}