diff --git a/src/oceans/components/common/Button.jsx b/src/oceans/components/common/Button.jsx new file mode 100644 index 00000000..407b6f75 --- /dev/null +++ b/src/oceans/components/common/Button.jsx @@ -0,0 +1,42 @@ +import React from 'react'; +import Radium from "radium"; +import PropTypes from "prop-types"; + +import guide from "@ml/oceans/models/guide"; +import soundLibrary from "@ml/oceans/models/soundLibrary"; +import styles from "@ml/oceans/styles"; + +const UnwrappedButton = class Button extends React.Component { + static propTypes = { + className: PropTypes.string, + style: PropTypes.object, + children: PropTypes.node, + onClick: PropTypes.func, + sound: PropTypes.string + }; + + onClick = event => { + guide.dismissCurrentGuide(); + const clickReturnValue = this.props.onClick(event); + + if (clickReturnValue !== false) { + const sound = this.props.sound || 'other'; + soundLibrary.playSound(sound); + } + }; + + render() { + return ( + + ); + } +}; + +export default Radium(UnwrappedButton); diff --git a/src/oceans/components/common/ConfirmationDialog.jsx b/src/oceans/components/common/ConfirmationDialog.jsx new file mode 100644 index 00000000..489b3897 --- /dev/null +++ b/src/oceans/components/common/ConfirmationDialog.jsx @@ -0,0 +1,59 @@ +import React from 'react'; +import PropTypes from 'prop-types'; +import Radium from "radium"; + +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faEraser} from "@fortawesome/free-solid-svg-icons"; + +import snail from "@public/images/snail-large.png"; + +import styles from "@ml/oceans/styles"; +import I18n from "@ml/oceans/i18n"; +import Button from "@ml/oceans/components/common/Button"; + +let UnwrappedConfirmationDialog = class ConfirmationDialog extends React.Component { + static propTypes = { + onYesClick: PropTypes.func.isRequired, + onNoClick: PropTypes.func.isRequired + }; + + render() { + return ( +
+
+
+ +
+
+ {I18n.t('areYouSure')} +
+
+ {I18n.t('eraseWarning')} +
+
+
+
+ + +
+
+
+ ); + } +}; +export default Radium(UnwrappedConfirmationDialog); diff --git a/src/oceans/components/common/Guide.jsx b/src/oceans/components/common/Guide.jsx new file mode 100644 index 00000000..cb306e5f --- /dev/null +++ b/src/oceans/components/common/Guide.jsx @@ -0,0 +1,123 @@ +import React from 'react' +import Radium from "radium"; +import Typist from "react-typist"; + +import {getState, setState} from "@ml/oceans/state"; +import guide from "@ml/oceans/models/guide"; +import soundLibrary from "@ml/oceans/models/soundLibrary"; +import styles from "@ml/oceans/styles"; +import colors from "@ml/oceans/styles/colors"; +import I18n from "@ml/oceans/i18n"; +import {Button} from "@ml/oceans/components/common"; +import arrowDownImage from "@public/images/arrow-down.png"; + +let UnwrappedGuide = class Guide extends React.Component { + onShowing() { + clearInterval(getState().guideTypingTimer); + setState({guideShowing: true, guideTypingTimer: null}); + } + + dismissGuideClick() { + const dismissed = guide.dismissCurrentGuide(); + if (dismissed) { + soundLibrary.playSound('other'); + } + } + + render() { + const state = getState(); + const currentGuide = guide.getCurrentGuide(); + + let guideBgStyle = [styles.guideBackground]; + if (currentGuide) { + if (currentGuide.noDimBackground) { + guideBgStyle = [styles.guideBackgroundHidden]; + } + + // Info guides should have a darker background color. + if (currentGuide.style === 'Info') { + guideBgStyle.push({backgroundColor: colors.transparentBlack}); + } + } + + // Start playing the typing sounds. + if (!state.guideShowing && !state.guideTypingTimer && currentGuide) { + const guideTypingTimer = setInterval(() => { + soundLibrary.playSound('no', 0.5); + }, 1000 / 10); + setState({guideTypingTimer}); + } + + return ( +
+ {currentGuide && currentGuide.image && ( + + )} + {!!currentGuide && ( +
+
+
+
+ {currentGuide.style === 'Info' && ( +
+ {I18n.t('didYouKnow')} +
+ )} +
+ + {currentGuide.textFn(getState())} + +
+
+
+ {currentGuide.textFn(getState())} +
+
+ {currentGuide.style === 'Info' && ( + + )} +
+
+
+ {currentGuide.arrow && ( + + )} +
+ )} +
+ ); + } +}; +export default Radium(UnwrappedGuide); diff --git a/src/oceans/components/common/index.jsx b/src/oceans/components/common/index.jsx new file mode 100644 index 00000000..e26fc2dc --- /dev/null +++ b/src/oceans/components/common/index.jsx @@ -0,0 +1,34 @@ +import React from 'react' +import PropTypes from 'prop-types' + +import styles from "@ml/oceans/styles"; + +import Guide from "@ml/oceans/components/common/Guide"; +import Button from "@ml/oceans/components/common/Button"; +import ConfirmationDialog from "@ml/oceans/components/common/ConfirmationDialog"; +import loadingGif from "@public/images/loading.gif"; + +const Body = ({onClick, children}) => ( +
+ {children} + +
+) +Body.propTypes = { + children: PropTypes.node, + onClick: PropTypes.func +} + +const Content = ({children}) => (
{children}
) +Content.propTypes = { + children: PropTypes.node +}; + +const Loading = () => ( + + + +) + + +export {Body, Content, Loading, Guide, Button, ConfirmationDialog} diff --git a/src/oceans/components/scenes/pond/PondPanel.jsx b/src/oceans/components/scenes/pond/PondPanel.jsx new file mode 100644 index 00000000..dc607773 --- /dev/null +++ b/src/oceans/components/scenes/pond/PondPanel.jsx @@ -0,0 +1,134 @@ +import React from "react"; +import {getState, setState} from "@ml/oceans/state"; +import styles from "@ml/oceans/styles"; +import I18n from "@ml/oceans/i18n"; +import Markdown from "@ml/utils/Markdown"; + +class PondPanel extends React.Component { + onPondPanelClick = e => { + setState({pondPanelShowing: false}); + e.stopPropagation(); + }; + + render() { + const state = getState(); + + const maxExplainValue = state.showRecallFish + ? state.pondRecallFishMaxExplainValue + : state.pondFishMaxExplainValue; + + return ( +
+ {!state.pondClickedFish && ( +
+ {state.pondExplainGeneralSummary && ( +
+
+ {I18n.t('mostImportantParts')} +
+ {state.pondExplainGeneralSummary.slice(0, 5).map((f, i) => ( +
+ {f.importance > 0 && ( +
+   +
+   +
+
+ {I18n.t(f.partType)} +
+
+ )} +
+ ))} +
+ {I18n.t('clickIndividualFish')} +
+
+ )} +
+ )} + {state.pondClickedFish && ( +
this.onPondPanelClick(e)} + > + {state.pondExplainFishSummary && ( +
+
+ +
+ {state.pondExplainFishSummary.slice(0, 4).map((f, i) => ( +
+ {f.impact < 0 && ( +
+   +
+   +
+
+ {I18n.t(f.partType)} +
+
+ )} + {f.impact > 0 && ( +
+   +
+   +
+
+ {I18n.t(f.partType)} +
+
+ )} +
+ ))} +
+ )} +
+ )} +
+ ); + } +} +export default PondPanel; diff --git a/src/oceans/components/scenes/pond/index.jsx b/src/oceans/components/scenes/pond/index.jsx new file mode 100644 index 00000000..79439f2f --- /dev/null +++ b/src/oceans/components/scenes/pond/index.jsx @@ -0,0 +1,269 @@ +import React from 'react' +import _ from "lodash"; +import Radium from "radium"; + +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faBan, faCheck, faInfo} from "@fortawesome/free-solid-svg-icons"; + +import {getState, setState} from "@ml/oceans/state"; +import styles from "@ml/oceans/styles"; +import I18n from "@ml/oceans/i18n"; +import soundLibrary from "@ml/oceans/models/soundLibrary"; +import {arrangeFish} from "@ml/oceans/models/pond"; +import helpers, {$time} from "@ml/oceans/helpers"; +import guide from "@ml/oceans/models/guide"; +import constants, {AppMode, Modes} from "@ml/oceans/constants"; +import {Body, Button} from "@ml/oceans/components/common"; +import aiBotClosed from "@public/images/ai-bot/ai-bot-closed.png"; +import modeHelpers from "@ml/oceans/modeHelpers"; +import PondPanel from "@ml/oceans/components/scenes/pond/PondPanel"; + +function Collide(x1, y1, w1, h1, x2, y2, w2, h2) { + // Detect a non-collision. + if ( + x1 + w1 - 1 < x2 || + x1 > x2 + w2 - 1 || + y1 + h1 - 1 < y2 || + y1 > y2 + h2 - 1 + ) { + return false; + } + + // Otherwise we have a collision. + return true; +} + + +let UnwrappedPond = class Pond extends React.Component { + constructor(props) { + super(props); + } + + toggleRecall = e => { + const state = getState(); + + // No-op if transition is already in progress. + if (state.pondFishTransitionStartTime) { + return; + } + + let currentFishSet, nextFishSet; + if (state.showRecallFish) { + currentFishSet = state.recallFish; + nextFishSet = state.pondFish; + soundLibrary.playSound('yes'); + } else { + currentFishSet = state.pondFish; + nextFishSet = state.recallFish; + soundLibrary.playSound('no'); + } + + // Don't call arrangeFish if fish have already been arranged. + if (nextFishSet.length > 0 && !nextFishSet[0].getXY()) { + arrangeFish(nextFishSet); + } + + if (currentFishSet.length === 0) { + // Immediately transition to nextFishSet rather than waiting for empty animation. + setState({showRecallFish: !state.showRecallFish, pondClickedFish: null}); + } else { + setState({pondFishTransitionStartTime: $time(), pondClickedFish: null}); + } + + if (e) { + e.stopPropagation(); + } + }; + + onPondClick = e => { + // Don't allow pond clicks if a Guide is currently showing. + if (guide.getCurrentGuide()) { + return; + } + + const state = getState(); + const clickX = e.nativeEvent.offsetX; + const clickY = e.nativeEvent.offsetY; + + const boundingRect = e.target.getBoundingClientRect(); + const pondWidth = boundingRect.width; + const pondHeight = boundingRect.height; + + // Scale the click to the pond canvas dimensions. + const normalizedClickX = (clickX / pondWidth) * constants.canvasWidth; + const normalizedClickY = (clickY / pondHeight) * constants.canvasHeight; + + const fishCollection = state.showRecallFish + ? state.recallFish + : state.pondFish; + + if (state.pondFishBounds) { + let fishClicked = false; + // Look through the array in reverse so that we click on a fish that + // is rendered topmost. + _.reverse(state.pondFishBounds).forEach(fishBound => { + // If we haven't already clicked on a fish in this current iteration, + // and we're not clicking on a fish that is already actively clicked, + // and we have a collision, then we have clicked on a new fish! + if ( + !fishClicked && + !( + state.pondClickedFish && + fishBound.fishId === state.pondClickedFish.id + ) && + Collide( + fishBound.x, + fishBound.y, + fishBound.w, + fishBound.h, + normalizedClickX, + normalizedClickY, + 1, + 1 + ) + ) { + setState({ + pondClickedFish: { + id: fishBound.fishId, + x: fishBound.x, + y: fishBound.y + } + }); + fishClicked = true; + soundLibrary.playSound('yes'); + + if ( + state.appMode === AppMode.FishShort || + state.appMode === AppMode.FishLong + ) { + const clickedFish = fishCollection.find( + f => f.id === fishBound.fishId + ); + setState({ + pondExplainFishSummary: state.trainer.explainFish(clickedFish) + }); + if (normalizedClickX < constants.canvasWidth / 2) { + setState({pondPanelSide: 'right'}); + } else { + setState({pondPanelSide: 'left'}); + } + } + } + }); + + if (!fishClicked) { + setState({pondClickedFish: null}); + soundLibrary.playSound('no'); + } + } + }; + + onPondPanelButtonClick = e => { + const state = getState(); + + if ([AppMode.FishShort, AppMode.FishLong].includes(state.appMode)) { + setState({ + pondPanelShowing: !state.pondPanelShowing + }); + + if (state.pondPanelShowing) { + soundLibrary.playSound('sortno'); + } else { + soundLibrary.playSound('sortyes'); + } + } + + if (e) { + e.stopPropagation(); + } + }; + + render() { + const state = getState(); + + const showInfoButton = + [AppMode.FishShort, AppMode.FishLong].includes(state.appMode) && + state.pondFish.length > 0 && + state.recallFish.length > 0; + const recallIconsStyle = showInfoButton + ? styles.recallIcons + : {...styles.recallIcons, right: '1.2%'}; + + return ( + +
+
+ + +
+ {showInfoButton && ( +
+ +
+ )} + + {state.canSkipPond && ( +
+ {state.appMode === AppMode.FishLong ? ( +
+ + +
+ ) : ( + + )} +
+ +
+
+ )} + {state.pondPanelShowing && } + + ); + } +}; +export default Radium(UnwrappedPond); diff --git a/src/oceans/components/scenes/predict/index.jsx b/src/oceans/components/scenes/predict/index.jsx new file mode 100644 index 00000000..d9c4590c --- /dev/null +++ b/src/oceans/components/scenes/predict/index.jsx @@ -0,0 +1,166 @@ +import React from 'react' +import Radium from "radium"; + +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faBackward, faForward, faPause, faPlay} from "@fortawesome/free-solid-svg-icons"; + +import {getState, setState} from "@ml/oceans/state"; +import {$time, currentRunTime, finishMovement} from "@ml/oceans/helpers"; +import constants, {AppMode, Modes} from "@ml/oceans/constants"; +import modeHelpers from "@ml/oceans/modeHelpers"; +import {Body, Button} from "@ml/oceans/components/common"; +import styles from "@ml/oceans/styles"; +import I18n from "@ml/oceans/i18n"; + + +const defaultTimeScale = 1; +const timeScales = [1, 2]; +const MediaControl = Object.freeze({ + Rewind: 'rewind', + Play: 'play', + FastForward: 'fast-forward' +}); + +let UnwrappedPredict = class Predict extends React.Component { + state = { + displayControls: false, + timeScale: defaultTimeScale + }; + + onRun = () => { + const state = setState({isRunning: true, runStartTime: $time()}); + if (state.appMode !== AppMode.CreaturesVTrashDemo) { + this.setState({displayControls: true}); + } + }; + + onContinue = () => { + const state = getState(); + if (state.appMode === AppMode.CreaturesVTrashDemo && state.onContinue) { + state.onContinue(); + } else { + setState({showRecallFish: false}); + modeHelpers.toMode(Modes.Pond); + } + }; + + finishMovement = () => { + const state = getState(); + + const t = currentRunTime(state); + if (state.rewind) { + finishMovement(state.lastPauseTime - t); + } else { + finishMovement(state.lastPauseTime + t); + } + }; + + onPressPlay = () => { + const state = getState(); + this.finishMovement(); + setState({ + isRunning: !state.isRunning, + isPaused: !state.isPaused, + rewind: false, + moveTime: constants.defaultMoveTime / defaultTimeScale + }); + this.setState({timeScale: defaultTimeScale}); + }; + + onScaleTime = rewind => { + this.finishMovement(); + const nextIdx = timeScales.indexOf(this.state.timeScale) + 1; + const timeScale = + nextIdx > timeScales.length - 1 ? timeScales[0] : timeScales[nextIdx]; + + setState({ + rewind, + isRunning: true, + isPaused: false, + moveTime: constants.defaultMoveTime / timeScale + }); + this.setState({timeScale}); + }; + + render() { + const state = getState(); + let selectedControl; + if (state.isRunning && state.rewind) { + selectedControl = MediaControl.Rewind; + } else if ( + state.isRunning && + !state.rewind && + this.state.timeScale !== defaultTimeScale + ) { + selectedControl = MediaControl.FastForward; + } + + return ( + + {this.state.displayControls && ( +
+ this.onScaleTime(true)} + style={[ + styles.mediaControl, + selectedControl === MediaControl.Rewind && + styles.selectedControl + ]} + key={MediaControl.Rewind} + > + + {selectedControl === MediaControl.Rewind && + this.state.timeScale !== defaultTimeScale && + `x${this.state.timeScale}`} + + + + + + + this.onScaleTime(false)} + style={[ + styles.mediaControl, + selectedControl === MediaControl.FastForward && + styles.selectedControl + ]} + key={MediaControl.FastForward} + > + + + {selectedControl === MediaControl.FastForward && + this.state.timeScale !== defaultTimeScale && + `x${this.state.timeScale}`} + + +
+ )} + {!state.isRunning && !state.isPaused && ( + + )} + {(state.isRunning || state.isPaused) && state.canSkipPredict && ( + + )} + + ); + } +}; +export default Radium(UnwrappedPredict); diff --git a/src/oceans/components/scenes/train/index.jsx b/src/oceans/components/scenes/train/index.jsx new file mode 100644 index 00000000..872996a0 --- /dev/null +++ b/src/oceans/components/scenes/train/index.jsx @@ -0,0 +1,104 @@ +import React from 'react' +import Radium from "radium"; + +import {getState, setState} from "@ml/oceans/state"; +import {AppMode, Modes} from "@ml/oceans/constants"; +import I18n from "@ml/oceans/i18n"; +import helpers from "@ml/oceans/helpers"; +import {Body, Button} from "@ml/oceans/components/common"; +import styles from "@ml/oceans/styles"; +import aiBotHead from "@public/images/ai-bot/ai-bot-head.png"; +import aiBotBody from "@public/images/ai-bot/ai-bot-body.png"; +import counterIcon from "@public/images/polaroid-icon.png"; +import {FontAwesomeIcon} from "@fortawesome/react-fontawesome"; +import {faBan, faCheck, faTrash} from "@fortawesome/free-solid-svg-icons"; +import train from "@ml/oceans/models/train"; +import modeHelpers from "@ml/oceans/modeHelpers"; + +let UnwrappedTrain = class Train extends React.Component { + state = { + headOpen: false + }; + + render() { + const state = getState(); + const yesButtonText = + state.appMode === AppMode.CreaturesVTrash ? I18n.t('yes') : state.word; + const noButtonText = + state.appMode === AppMode.CreaturesVTrash + ? I18n.t('no') + : I18n.t('notWord', {word: state.word}); + const resetTrainingFunction = () => { + helpers.resetTraining(state); + setState({showConfirmationDialog: false}); + }; + + return ( + +
{state.trainingQuestion}
+
+ + +
+
+ + + {Math.min(999, state.yesCount + state.noCount)} + +
+
+ { + setState({ + showConfirmationDialog: true, + confirmationDialogOnYes: resetTrainingFunction + }); + }} + /> +
+
+ + +
+ + + ); + } +}; + +export default Radium(UnwrappedTrain); diff --git a/src/oceans/components/scenes/words/index.jsx b/src/oceans/components/scenes/words/index.jsx new file mode 100644 index 00000000..a05cc9ad --- /dev/null +++ b/src/oceans/components/scenes/words/index.jsx @@ -0,0 +1,134 @@ +import React from 'react' +import _ from "lodash"; +import Radium from "radium"; + +import {getState, setState} from "@ml/oceans/state"; +import I18n from "@ml/oceans/i18n"; +import modeHelpers from "@ml/oceans/modeHelpers"; +import {AppMode, Modes} from "@ml/oceans/constants"; +import {Body, Button, Content} from "@ml/oceans/components/common"; +import styles from "@ml/oceans/styles"; + + +/* + * The choices for each word set are i18n keys. If adding or changing a word + * choice, be sure to add the word the way it should appear in i18n/oceans.json. + * The keys here will also appear in google analytics, so it's worth making + * them readable in English. + * + * */ +export const wordSet = { + short: { + textKey: 'wordQuestionShort', + choices: [ + ['blue', 'green', 'red'], + ['circular', 'rectangular', 'triangular'] + ], + style: styles.button2col + }, + long: { + textKey: 'wordQuestionLong', + choices: [ + [ + 'angry', + 'awesome', + 'delicious', + 'endangered', + 'fast', + 'fierce', + 'fun', + 'glitchy', + 'happy', + 'hungry', + 'playful', + 'scary', + 'silly', + 'spooky', + 'wild' + ] + ], + style: styles.button3col + } +}; + +let UnwrappedWords = class Words extends React.Component { + constructor(props) { + super(props); + + // Randomize word choices in each set, merge the sets, and set as state. + const appMode = getState().appMode; + + if (!wordSet[appMode]) { + throw `Could not find a set of choices in wordSet for appMode '${appMode}'`; + } + + const appModeWordSet = wordSet[appMode].choices; + let choices = []; + let maxSize = 0; + // Each subset represents a different column, so merge the subsets + // Start by shuffling the subsets and finding the max length + for (var i = 0; i < appModeWordSet.length; ++i) { + appModeWordSet[i] = _.shuffle(appModeWordSet[i]); + if (appModeWordSet[i].length > maxSize) { + maxSize = appModeWordSet[i].length; + } + } + // Iterate through each subset and add those elements to choices + for (i = 0; i < maxSize; ++i) { + appModeWordSet.forEach(col => { + if (col[i]) { + choices.push(col[i]); + } + }); + } + + this.state = {choices}; + } + + onChangeWord(itemIndex) { + const wordKey = this.state.choices[itemIndex]; + const word = I18n.t(wordKey); + setState({ + word, + trainingQuestion: I18n.t('isThisFish', {word: word.toLowerCase()}) + }); + modeHelpers.toMode(Modes.Training); + + // Report an analytics event for the word chosen. + if (window.trackEvent) { + const appModeToString = { + [AppMode.FishShort]: 'words-short', + [AppMode.FishLong]: 'words-long' + }; + + window.trackEvent('oceans', appModeToString[getState().appMode], wordKey); + } + } + + render() { + const state = getState(); + + return ( + + + {wordSet[state.appMode].textKey && ( +
+ {I18n.t(wordSet[state.appMode].textKey)}{' '} +
+ )} + {this.state.choices.map((item, itemIndex) => ( + + ))} +
+ + ); + } +}; +export default Radium(UnwrappedWords); diff --git a/src/oceans/renderer.js b/src/oceans/renderer.js index bacf4c5b..765107e9 100644 --- a/src/oceans/renderer.js +++ b/src/oceans/renderer.js @@ -11,7 +11,7 @@ import { $time } from './helpers'; import {fishData} from '../utils/fishData'; -import colors from './colors'; +import colors from './styles/colors'; import {predictFish} from './models/predict'; import { loadAllFishPartImages, diff --git a/src/oceans/colors.js b/src/oceans/styles/colors.js similarity index 100% rename from src/oceans/colors.js rename to src/oceans/styles/colors.js diff --git a/src/oceans/styles/index.js b/src/oceans/styles/index.js new file mode 100644 index 00000000..b78135fb --- /dev/null +++ b/src/oceans/styles/index.js @@ -0,0 +1,592 @@ +import colors from "@ml/oceans/styles/colors"; + +const styles = { + body: { + position: 'relative', + width: '100%', + paddingTop: '56.25%' // for 16:9 + }, + content: { + position: 'absolute', + top: 0, + left: 0, + width: '100%' + }, + // Note that button fontSize and padding are currently set by surrounding HTML for + // responsiveness. + button: { + cursor: 'pointer', + backgroundColor: colors.white, + color: colors.grey, + fontSize: '100%', + borderRadius: 8, + minWidth: '15%', + outline: 'none', + border: 'none', + whiteSpace: 'nowrap', + lineHeight: 1.3 + }, + continueButton: { + position: 'absolute', + bottom: '2%', + right: '1.2%', + backgroundColor: colors.orange, + color: colors.white + }, + finishButton: { + backgroundColor: colors.orange, + color: colors.white, + position: 'absolute', + bottom: '2%', + right: '1.2%' + }, + playAgainButton: { + backgroundColor: colors.yellowGreen, + color: colors.white, + position: 'absolute', + bottom: '13.5%', + right: '1.2%' + }, + backButton: { + position: 'absolute', + bottom: '2%', + left: '1.2%' + }, + button2col: { + width: '20%', + marginLeft: '14%', + marginRight: '14%', + marginTop: '2%' + }, + button3col: { + width: '20%', + marginLeft: '6%', + marginRight: '6%', + marginTop: '2%' + }, + confirmationDialogBackground: { + backgroundColor: colors.transparentBlack, + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: 10, + zPosition: 1 + }, + confirmationDialog: { + position: 'absolute', + backgroundColor: colors.white, + color: colors.darkGrey, + transform: 'translate(-50%, -50%)', + top: '50%', + bottom: 'initial', + left: '50%', + padding: '2%', + borderRadius: 8 + }, + confirmationDialogContent: { + display: 'flex', + justifyContent: 'space-between' + }, + confirmationDialogImg: { + position: 'absolute', + bottom: '-46%', + left: '-41%', + height: '100%' + }, + confirmationHeader: { + fontSize: '220%', + color: colors.darkGrey, + paddingBottom: '5%', + textAlign: 'center' + }, + confirmationText: { + textAlign: 'center', + backgroundColor: colors.lightGrey, + padding: '5%', + borderRadius: 5 + }, + confirmationButtons: { + paddingTop: '5%', + clear: 'both' + }, + confirmationYesButton: { + backgroundColor: colors.red, + color: colors.white, + left: '5%', + padding: '3.5% 8%', + width: '35%' + }, + confirmationNoButton: { + backgroundColor: colors.orange, + color: colors.white, + float: 'right', + right: '5%', + padding: '3.5% 8%', + width: '35%' + }, + loading: { + position: 'absolute', + transform: 'translate(-50%, -50%)', + top: '50%', + left: '50%', + maxWidth: '30%' + }, + activityIntroText: { + position: 'absolute', + fontSize: '120%', + top: '20%', + left: '50%', + width: '80%', + transform: 'translateX(-50%)', + textAlign: 'center' + }, + trainingIntroBot: { + position: 'absolute', + transform: 'translateX(-50%)', + top: '30%', + left: '50%' + }, + activityIntroBot: { + position: 'absolute', + transform: 'translateX(-50%)', + top: '50%', + left: '50%' + }, + wordsText: { + textAlign: 'center', + marginTop: 20, + fontSize: '120%', + color: colors.white + }, + wordButton: { + ':hover': { + backgroundColor: colors.orange, + color: colors.white + }, + ':focus': { + backgroundColor: colors.orange, + color: colors.white + } + }, + trainQuestionText: { + position: 'absolute', + top: '15%', + left: '50%', + transform: 'translateX(-50%)', + fontSize: '180%', + color: colors.white, + whiteSpace: 'nowrap' + }, + trainButtons: { + position: 'absolute', + top: '83%', + width: '100%', + display: 'flex', + justifyContent: 'center' + }, + trainButtonYes: { + marginLeft: 10, + ':hover': { + backgroundColor: colors.green, + color: colors.white + } + }, + trainButtonNo: { + ':hover': { + backgroundColor: colors.red, + color: colors.white + } + }, + trainBot: { + position: 'absolute', + top: '30%', + right: '-2%', + width: '30%', + direction: 'ltr' + }, + trainBotHead: { + transition: 'transform 500ms', + left: '3%', + width: '43%', + top: '0%', + position: 'absolute', + direction: 'ltr' + }, + trainBotOpen: { + transform: 'rotate(90deg)', + transformOrigin: 'bottom right', + direction: 'ltr' + }, + trainBotBody: { + width: '49%', + marginTop: '30%', + direction: 'ltr' + }, + counter: { + position: 'absolute', + top: '2%', + right: '7%', + backgroundColor: colors.transparentBlack, + color: colors.neonBlue, + borderRadius: 33, + textAlign: 'right', + minWidth: '7%', + height: '5%', + padding: '1% 2.5%' + }, + counterImg: { + float: 'left', + height: '100%' + }, + counterNum: { + fontSize: '90%' + }, + eraseButtonContainer: { + position: 'absolute', + top: '2%', + right: '1.2%', + cursor: 'pointer', + borderRadius: 50, + padding: '0.75% 1.2%', + fontSize: '120%', + backgroundColor: colors.white, + color: colors.grey, + height: '6%', + width: '2.4%', + ':hover': { + backgroundColor: colors.red, + color: colors.white + }, + ':focus': { + backgroundColor: colors.red, + color: colors.white + } + }, + eraseButton: { + display: 'block', + margin: 'auto', + height: '100%' + }, + mediaControls: { + position: 'absolute', + width: '100%', + bottom: '3.5%', + display: 'flex', + justifyContent: 'center', + direction: 'ltr' + }, + mediaControl: { + cursor: 'pointer', + margin: '0 20px', + fontSize: '180%', + color: colors.white, + display: 'flex', + alignItems: 'center', + ':hover': { + color: colors.orange + }, + ':active': { + color: colors.orange + } + }, + selectedControl: { + color: colors.orange + }, + timeScale: { + width: 40, + fontSize: '80%', + textAlign: 'center' + }, + predictSpeech: { + top: '88%', + left: '12%', + width: '65%', + height: 38 + }, + pondSurface: { + position: 'absolute', + width: '100%', + height: '100%', + top: 0, + left: 0 + }, + pondFishDetails: { + position: 'absolute', + backgroundColor: colors.transparentWhite, + padding: '2%', + borderRadius: 5, + color: colors.black + }, + pondBot: { + position: 'absolute', + height: '27%', + top: '59%', + left: '50%', + bottom: 0, + transform: 'translateX(-45%)', + pointerEvents: 'none' + }, + pondPanelButton: { + position: 'absolute', + top: 24, + left: 22, + cursor: 'pointer' + }, + pondPanelLeft: { + position: 'absolute', + width: '30%', + backgroundColor: colors.transparentBlack, + color: colors.white, + borderRadius: 10, + left: '3%', + top: '16%', + padding: '2%', + pointerEvents: 'none' + }, + pondPanelRight: { + position: 'absolute', + width: '30%', + backgroundColor: colors.transparentBlack, + color: colors.white, + borderRadius: 10, + right: '3%', + top: '16%', + padding: '2%', + pointerEvents: 'none' + }, + pondPanelPreText: { + marginBottom: '5%' + }, + pondPanelRow: { + position: 'relative', + marginBottom: '7%' + }, + pondPanelGeneralBar: { + position: 'absolute', + top: 0, + left: '0%', + height: '150%', + backgroundColor: colors.teal + }, + pondPanelGeneralBarText: { + position: 'absolute', + top: '30%', + left: '3%', + textAlign: 'right' + }, + pondPanelGreenBar: { + position: 'absolute', + top: 0, + left: '50%', + height: '150%', + backgroundColor: colors.green + }, + pondPanelGreenBarText: { + position: 'absolute', + top: '30%', + left: '53%' + }, + pondPanelRedBar: { + position: 'absolute', + top: 0, + right: '50%', + height: '150%', + backgroundColor: colors.red + }, + pondPanelRedBarText: { + position: 'absolute', + top: '30%', + width: '47%', + textAlign: 'right' + }, + pondPanelPostText: { + marginTop: '3%' + }, + recallIcons: { + position: 'absolute', + top: '2%', + right: '7%', + backgroundColor: colors.white, + color: colors.grey, + height: '8.5%', + width: '9.5%', + borderRadius: 8, + display: 'flex', + alignItems: 'center', + direction: 'ltr' + }, + recallIcon: { + cursor: 'pointer', + padding: '0 15%', + height: '100%' + }, + infoIconContainer: { + position: 'absolute', + top: '2%', + right: '1.2%', + cursor: 'pointer', + borderRadius: 50, + padding: '0.75% 1.2%', + fontSize: '120%', + backgroundColor: colors.white, + color: colors.grey, + height: '6%', + width: '2.5%', + ':hover': { + backgroundColor: colors.teal, + color: colors.white + }, + ':focus': { + backgroundColor: colors.teal, + color: colors.white + } + }, + infoIcon: { + display: 'block', + margin: 'auto', + height: '100%' + }, + bgTeal: { + backgroundColor: colors.teal, + color: colors.white + }, + bgRed: { + backgroundColor: colors.red, + color: colors.white + }, + bgGreen: { + backgroundColor: colors.green, + color: colors.white + }, + count: { + position: 'absolute', + top: '3%' + }, + noCount: { + right: '9%' + }, + yesCount: { + right: 0 + }, + guide: { + position: 'absolute', + backgroundColor: colors.transparentBlack, + color: colors.white, + borderRadius: 5, + maxWidth: '80%', + bottom: '2%', + left: '50%', + transform: 'translateX(-50%)' + }, + guideImage: { + position: 'absolute', + bottom: '1%', + left: '15%', + zIndex: 2, + maxHeight: '45%', + maxWidth: '35%' + }, + guideHeading: { + fontSize: '220%', + color: colors.darkGrey, + paddingBottom: '5%', + textAlign: 'center' + }, + guideTypingText: { + position: 'absolute', + padding: 20 + }, + guideFinalTextContainer: {}, + guideFinalTextInfoContainer: { + backgroundColor: colors.lightGrey, + borderRadius: 10 + }, + guideFinalText: { + padding: 20, + opacity: 0 + }, + guideBackground: { + backgroundColor: 'rgba(0,0,0,0.3)', + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + borderRadius: 10 + }, + guideBackgroundHidden: { + position: 'absolute', + top: 0, + left: 0, + width: '100%', + height: '100%', + pointerEvents: 'none' + }, + guideArrow: { + position: 'absolute', + width: '8%' + }, + guideInfo: { + backgroundColor: colors.white, + color: colors.darkGrey, + transform: 'translate(-50%, -50%)', + top: '50%', + bottom: 'initial', + left: '50%', + padding: '2%' + }, + guideCenter: { + top: '50%', + left: '50%', + bottom: 'initial', + maxWidth: '47%', + transform: 'translate(-50%, -50%)' + }, + infoGuideButton: { + backgroundColor: colors.orange, + color: colors.white, + transform: 'translate(-50%)', + marginLeft: '50%', + marginTop: '2%', + padding: '3% 7%' + }, + arrowBotRight: { + top: '15%', + right: '12.5%', + transform: 'translateX(-50%)' + }, + arrowLowerLeft: { + bottom: '17%', + left: '8.5%', + transform: 'translateX(-50%)' + }, + arrowLowerRight: { + bottom: '17%', + right: '0.75%', + transform: 'translateX(-50%)' + }, + arrowLowishRight: { + bottom: '28%', + right: '0.75%', + transform: 'translateX(-50%)' + }, + arrowLowerCenter: { + bottom: '22%', + left: '50.5%', + transform: 'translateX(-50%)' + }, + arrowUpperRight: { + top: '13%', + right: '-2%', + transform: 'translateX(-50%) rotate(180deg)' + }, + arrowUpperFarRight: { + top: '15%', + right: '-4.6%', + transform: 'translateX(-50%) rotate(180deg)' + } +}; + +export default styles diff --git a/src/oceans/ui.jsx b/src/oceans/ui.jsx index 17693aa2..1baa2649 100644 --- a/src/oceans/ui.jsx +++ b/src/oceans/ui.jsx @@ -1,1594 +1,14 @@ import React from 'react'; -import PropTypes from 'prop-types'; -import Radium from 'radium'; -import _ from 'lodash'; -import {getState, setState} from './state'; -import constants, {AppMode, Modes} from './constants'; -import modeHelpers from './modeHelpers'; -import helpers, {$time, currentRunTime, finishMovement} from './helpers'; -import train from './models/train'; -import {arrangeFish} from './models/pond'; -import colors from './colors'; -import aiBotHead from '@public/images/ai-bot/ai-bot-head.png'; -import aiBotBody from '@public/images/ai-bot/ai-bot-body.png'; -import aiBotClosed from '@public/images/ai-bot/ai-bot-closed.png'; -import counterIcon from '@public/images/polaroid-icon.png'; -import arrowDownImage from '@public/images/arrow-down.png'; -import snail from '@public/images/snail-large.png'; -import loadingGif from '@public/images/loading.gif'; -import Typist from 'react-typist'; -import guide from './models/guide'; -import soundLibrary from './models/soundLibrary'; -import {FontAwesomeIcon} from '@fortawesome/react-fontawesome'; -import { - faPlay, - faPause, - faBackward, - faForward, - faEraser, - faCheck, - faBan, - faInfo, - faTrash -} from '@fortawesome/free-solid-svg-icons'; -import I18n from './i18n'; -import Markdown from '@ml/utils/Markdown'; - -const styles = { - body: { - position: 'relative', - width: '100%', - paddingTop: '56.25%' // for 16:9 - }, - content: { - position: 'absolute', - top: 0, - left: 0, - width: '100%' - }, - // Note that button fontSize and padding are currently set by surrounding HTML for - // responsiveness. - button: { - cursor: 'pointer', - backgroundColor: colors.white, - color: colors.grey, - fontSize: '100%', - borderRadius: 8, - minWidth: '15%', - outline: 'none', - border: 'none', - whiteSpace: 'nowrap', - lineHeight: 1.3 - }, - continueButton: { - position: 'absolute', - bottom: '2%', - right: '1.2%', - backgroundColor: colors.orange, - color: colors.white - }, - finishButton: { - backgroundColor: colors.orange, - color: colors.white, - position: 'absolute', - bottom: '2%', - right: '1.2%' - }, - playAgainButton: { - backgroundColor: colors.yellowGreen, - color: colors.white, - position: 'absolute', - bottom: '13.5%', - right: '1.2%' - }, - backButton: { - position: 'absolute', - bottom: '2%', - left: '1.2%' - }, - button2col: { - width: '20%', - marginLeft: '14%', - marginRight: '14%', - marginTop: '2%' - }, - button3col: { - width: '20%', - marginLeft: '6%', - marginRight: '6%', - marginTop: '2%' - }, - confirmationDialogBackground: { - backgroundColor: colors.transparentBlack, - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - borderRadius: 10, - zPosition: 1 - }, - confirmationDialog: { - position: 'absolute', - backgroundColor: colors.white, - color: colors.darkGrey, - transform: 'translate(-50%, -50%)', - top: '50%', - bottom: 'initial', - left: '50%', - padding: '2%', - borderRadius: 8 - }, - confirmationDialogContent: { - display: 'flex', - justifyContent: 'space-between' - }, - confirmationDialogImg: { - position: 'absolute', - bottom: '-46%', - left: '-41%', - height: '100%' - }, - confirmationHeader: { - fontSize: '220%', - color: colors.darkGrey, - paddingBottom: '5%', - textAlign: 'center' - }, - confirmationText: { - textAlign: 'center', - backgroundColor: colors.lightGrey, - padding: '5%', - borderRadius: 5 - }, - confirmationButtons: { - paddingTop: '5%', - clear: 'both' - }, - confirmationYesButton: { - backgroundColor: colors.red, - color: colors.white, - left: '5%', - padding: '3.5% 8%', - width: '35%' - }, - confirmationNoButton: { - backgroundColor: colors.orange, - color: colors.white, - float: 'right', - right: '5%', - padding: '3.5% 8%', - width: '35%' - }, - loading: { - position: 'absolute', - transform: 'translate(-50%, -50%)', - top: '50%', - left: '50%', - maxWidth: '30%' - }, - activityIntroText: { - position: 'absolute', - fontSize: '120%', - top: '20%', - left: '50%', - width: '80%', - transform: 'translateX(-50%)', - textAlign: 'center' - }, - trainingIntroBot: { - position: 'absolute', - transform: 'translateX(-50%)', - top: '30%', - left: '50%' - }, - activityIntroBot: { - position: 'absolute', - transform: 'translateX(-50%)', - top: '50%', - left: '50%' - }, - wordsText: { - textAlign: 'center', - marginTop: 20, - fontSize: '120%', - color: colors.white - }, - wordButton: { - ':hover': { - backgroundColor: colors.orange, - color: colors.white - }, - ':focus': { - backgroundColor: colors.orange, - color: colors.white - } - }, - trainQuestionText: { - position: 'absolute', - top: '15%', - left: '50%', - transform: 'translateX(-50%)', - fontSize: '180%', - color: colors.white, - whiteSpace: 'nowrap' - }, - trainButtons: { - position: 'absolute', - top: '83%', - width: '100%', - display: 'flex', - justifyContent: 'center' - }, - trainButtonYes: { - marginLeft: 10, - ':hover': { - backgroundColor: colors.green, - color: colors.white - } - }, - trainButtonNo: { - ':hover': { - backgroundColor: colors.red, - color: colors.white - } - }, - trainBot: { - position: 'absolute', - top: '30%', - right: '-2%', - width: '30%', - direction: 'ltr' - }, - trainBotHead: { - transition: 'transform 500ms', - left: '3%', - width: '43%', - top: '0%', - position: 'absolute', - direction: 'ltr' - }, - trainBotOpen: { - transform: 'rotate(90deg)', - transformOrigin: 'bottom right', - direction: 'ltr' - }, - trainBotBody: { - width: '49%', - marginTop: '30%', - direction: 'ltr' - }, - counter: { - position: 'absolute', - top: '2%', - right: '7%', - backgroundColor: colors.transparentBlack, - color: colors.neonBlue, - borderRadius: 33, - textAlign: 'right', - minWidth: '7%', - height: '5%', - padding: '1% 2.5%' - }, - counterImg: { - float: 'left', - height: '100%' - }, - counterNum: { - fontSize: '90%' - }, - eraseButtonContainer: { - position: 'absolute', - top: '2%', - right: '1.2%', - cursor: 'pointer', - borderRadius: 50, - padding: '0.75% 1.2%', - fontSize: '120%', - backgroundColor: colors.white, - color: colors.grey, - height: '6%', - width: '2.4%', - ':hover': { - backgroundColor: colors.red, - color: colors.white - }, - ':focus': { - backgroundColor: colors.red, - color: colors.white - } - }, - eraseButton: { - display: 'block', - margin: 'auto', - height: '100%' - }, - mediaControls: { - position: 'absolute', - width: '100%', - bottom: '3.5%', - display: 'flex', - justifyContent: 'center', - direction: 'ltr' - }, - mediaControl: { - cursor: 'pointer', - margin: '0 20px', - fontSize: '180%', - color: colors.white, - display: 'flex', - alignItems: 'center', - ':hover': { - color: colors.orange - }, - ':active': { - color: colors.orange - } - }, - selectedControl: { - color: colors.orange - }, - timeScale: { - width: 40, - fontSize: '80%', - textAlign: 'center' - }, - predictSpeech: { - top: '88%', - left: '12%', - width: '65%', - height: 38 - }, - pondSurface: { - position: 'absolute', - width: '100%', - height: '100%', - top: 0, - left: 0 - }, - pondFishDetails: { - position: 'absolute', - backgroundColor: colors.transparentWhite, - padding: '2%', - borderRadius: 5, - color: colors.black - }, - pondBot: { - position: 'absolute', - height: '27%', - top: '59%', - left: '50%', - bottom: 0, - transform: 'translateX(-45%)', - pointerEvents: 'none' - }, - pondPanelButton: { - position: 'absolute', - top: 24, - left: 22, - cursor: 'pointer' - }, - pondPanelLeft: { - position: 'absolute', - width: '30%', - backgroundColor: colors.transparentBlack, - color: colors.white, - borderRadius: 10, - left: '3%', - top: '16%', - padding: '2%', - pointerEvents: 'none' - }, - pondPanelRight: { - position: 'absolute', - width: '30%', - backgroundColor: colors.transparentBlack, - color: colors.white, - borderRadius: 10, - right: '3%', - top: '16%', - padding: '2%', - pointerEvents: 'none' - }, - pondPanelPreText: { - marginBottom: '5%' - }, - pondPanelRow: { - position: 'relative', - marginBottom: '7%' - }, - pondPanelGeneralBar: { - position: 'absolute', - top: 0, - left: '0%', - height: '150%', - backgroundColor: colors.teal - }, - pondPanelGeneralBarText: { - position: 'absolute', - top: '30%', - left: '3%', - textAlign: 'right' - }, - pondPanelGreenBar: { - position: 'absolute', - top: 0, - left: '50%', - height: '150%', - backgroundColor: colors.green - }, - pondPanelGreenBarText: { - position: 'absolute', - top: '30%', - left: '53%' - }, - pondPanelRedBar: { - position: 'absolute', - top: 0, - right: '50%', - height: '150%', - backgroundColor: colors.red - }, - pondPanelRedBarText: { - position: 'absolute', - top: '30%', - width: '47%', - textAlign: 'right' - }, - pondPanelPostText: { - marginTop: '3%' - }, - recallIcons: { - position: 'absolute', - top: '2%', - right: '7%', - backgroundColor: colors.white, - color: colors.grey, - height: '8.5%', - width: '9.5%', - borderRadius: 8, - display: 'flex', - alignItems: 'center', - direction: 'ltr' - }, - recallIcon: { - cursor: 'pointer', - padding: '0 15%', - height: '100%' - }, - infoIconContainer: { - position: 'absolute', - top: '2%', - right: '1.2%', - cursor: 'pointer', - borderRadius: 50, - padding: '0.75% 1.2%', - fontSize: '120%', - backgroundColor: colors.white, - color: colors.grey, - height: '6%', - width: '2.5%', - ':hover': { - backgroundColor: colors.teal, - color: colors.white - }, - ':focus': { - backgroundColor: colors.teal, - color: colors.white - } - }, - infoIcon: { - display: 'block', - margin: 'auto', - height: '100%' - }, - bgTeal: { - backgroundColor: colors.teal, - color: colors.white - }, - bgRed: { - backgroundColor: colors.red, - color: colors.white - }, - bgGreen: { - backgroundColor: colors.green, - color: colors.white - }, - count: { - position: 'absolute', - top: '3%' - }, - noCount: { - right: '9%' - }, - yesCount: { - right: 0 - }, - guide: { - position: 'absolute', - backgroundColor: colors.transparentBlack, - color: colors.white, - borderRadius: 5, - maxWidth: '80%', - bottom: '2%', - left: '50%', - transform: 'translateX(-50%)' - }, - guideImage: { - position: 'absolute', - bottom: '1%', - left: '15%', - zIndex: 2, - maxHeight: '45%', - maxWidth: '35%' - }, - guideHeading: { - fontSize: '220%', - color: colors.darkGrey, - paddingBottom: '5%', - textAlign: 'center' - }, - guideTypingText: { - position: 'absolute', - padding: 20 - }, - guideFinalTextContainer: {}, - guideFinalTextInfoContainer: { - backgroundColor: colors.lightGrey, - borderRadius: 10 - }, - guideFinalText: { - padding: 20, - opacity: 0 - }, - guideBackground: { - backgroundColor: 'rgba(0,0,0,0.3)', - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - borderRadius: 10 - }, - guideBackgroundHidden: { - position: 'absolute', - top: 0, - left: 0, - width: '100%', - height: '100%', - pointerEvents: 'none' - }, - guideArrow: { - position: 'absolute', - width: '8%' - }, - guideInfo: { - backgroundColor: colors.white, - color: colors.darkGrey, - transform: 'translate(-50%, -50%)', - top: '50%', - bottom: 'initial', - left: '50%', - padding: '2%' - }, - guideCenter: { - top: '50%', - left: '50%', - bottom: 'initial', - maxWidth: '47%', - transform: 'translate(-50%, -50%)' - }, - infoGuideButton: { - backgroundColor: colors.orange, - color: colors.white, - transform: 'translate(-50%)', - marginLeft: '50%', - marginTop: '2%', - padding: '3% 7%' - }, - arrowBotRight: { - top: '15%', - right: '12.5%', - transform: 'translateX(-50%)' - }, - arrowLowerLeft: { - bottom: '17%', - left: '8.5%', - transform: 'translateX(-50%)' - }, - arrowLowerRight: { - bottom: '17%', - right: '0.75%', - transform: 'translateX(-50%)' - }, - arrowLowishRight: { - bottom: '28%', - right: '0.75%', - transform: 'translateX(-50%)' - }, - arrowLowerCenter: { - bottom: '22%', - left: '50.5%', - transform: 'translateX(-50%)' - }, - arrowUpperRight: { - top: '13%', - right: '-2%', - transform: 'translateX(-50%) rotate(180deg)' - }, - arrowUpperFarRight: { - top: '15%', - right: '-4.6%', - transform: 'translateX(-50%) rotate(180deg)' - } -}; - -function Collide(x1, y1, w1, h1, x2, y2, w2, h2) { - // Detect a non-collision. - if ( - x1 + w1 - 1 < x2 || - x1 > x2 + w2 - 1 || - y1 + h1 - 1 < y2 || - y1 > y2 + h2 - 1 - ) { - return false; - } - - // Otherwise we have a collision. - return true; -} - -class Body extends React.Component { - static propTypes = { - children: PropTypes.node, - onClick: PropTypes.func - }; - - render() { - return ( -
- {this.props.children} - -
- ); - } -} - -class Content extends React.Component { - static propTypes = { - children: PropTypes.node - }; - - render() { - return
{this.props.children}
; - } -} - -let UnwrappedButton = class Button extends React.Component { - static propTypes = { - className: PropTypes.string, - style: PropTypes.object, - children: PropTypes.node, - onClick: PropTypes.func, - sound: PropTypes.string - }; - - onClick = event => { - guide.dismissCurrentGuide(); - const clickReturnValue = this.props.onClick(event); - - if (clickReturnValue !== false) { - const sound = this.props.sound || 'other'; - soundLibrary.playSound(sound); - } - }; - - render() { - return ( - - ); - } -}; -export const Button = Radium(UnwrappedButton); // Exported for unit tests. - -let UnwrappedConfirmationDialog = class ConfirmationDialog extends React.Component { - static propTypes = { - onYesClick: PropTypes.func.isRequired, - onNoClick: PropTypes.func.isRequired - }; - - render() { - return ( -
-
-
- -
-
- {I18n.t('areYouSure')} -
-
- {I18n.t('eraseWarning')} -
-
-
-
- - -
-
-
- ); - } -}; -export const ConfirmationDialog = Radium(UnwrappedConfirmationDialog); // Exported for unit tests. - -class Loading extends React.Component { - render() { - return ( - - - - ); - } -} - -/* - * The choices for each word set are i18n keys. If adding or changing a word - * choice, be sure to add the word the way it should appear in i18n/oceans.json. - * The keys here will also appear in google analytics, so it's worth making - * them readable in English. - * - * */ -export const wordSet = { - short: { - textKey: 'wordQuestionShort', - choices: [ - ['blue', 'green', 'red'], - ['circular', 'rectangular', 'triangular'] - ], - style: styles.button2col - }, - long: { - textKey: 'wordQuestionLong', - choices: [ - [ - 'angry', - 'awesome', - 'delicious', - 'endangered', - 'fast', - 'fierce', - 'fun', - 'glitchy', - 'happy', - 'hungry', - 'playful', - 'scary', - 'silly', - 'spooky', - 'wild' - ] - ], - style: styles.button3col - } -}; - -let UnwrappedWords = class Words extends React.Component { - constructor(props) { - super(props); - - // Randomize word choices in each set, merge the sets, and set as state. - const appMode = getState().appMode; - - if (!wordSet[appMode]) { - throw `Could not find a set of choices in wordSet for appMode '${appMode}'`; - } - - const appModeWordSet = wordSet[appMode].choices; - let choices = []; - let maxSize = 0; - // Each subset represents a different column, so merge the subsets - // Start by shuffling the subsets and finding the max length - for (var i = 0; i < appModeWordSet.length; ++i) { - appModeWordSet[i] = _.shuffle(appModeWordSet[i]); - if (appModeWordSet[i].length > maxSize) { - maxSize = appModeWordSet[i].length; - } - } - // Iterate through each subset and add those elements to choices - for (i = 0; i < maxSize; ++i) { - appModeWordSet.forEach(col => { - if (col[i]) { - choices.push(col[i]); - } - }); - } - - this.state = {choices}; - } - - onChangeWord(itemIndex) { - const wordKey = this.state.choices[itemIndex]; - const word = I18n.t(wordKey); - setState({ - word, - trainingQuestion: I18n.t('isThisFish', {word: word.toLowerCase()}) - }); - modeHelpers.toMode(Modes.Training); - - // Report an analytics event for the word chosen. - if (window.trackEvent) { - const appModeToString = { - [AppMode.FishShort]: 'words-short', - [AppMode.FishLong]: 'words-long' - }; - - window.trackEvent('oceans', appModeToString[getState().appMode], wordKey); - } - } - - render() { - const state = getState(); - - return ( - - - {wordSet[state.appMode].textKey && ( -
- {I18n.t(wordSet[state.appMode].textKey)}{' '} -
- )} - {this.state.choices.map((item, itemIndex) => ( - - ))} -
- - ); - } -}; -export const Words = Radium(UnwrappedWords); // Exported for unit tests. - -let UnwrappedTrain = class Train extends React.Component { - state = { - headOpen: false - }; - - render() { - const state = getState(); - const yesButtonText = - state.appMode === AppMode.CreaturesVTrash ? I18n.t('yes') : state.word; - const noButtonText = - state.appMode === AppMode.CreaturesVTrash - ? I18n.t('no') - : I18n.t('notWord', {word: state.word}); - const resetTrainingFunction = () => { - helpers.resetTraining(state); - setState({showConfirmationDialog: false}); - }; - - return ( - -
{state.trainingQuestion}
-
- - -
-
- - - {Math.min(999, state.yesCount + state.noCount)} - -
-
- { - setState({ - showConfirmationDialog: true, - confirmationDialogOnYes: resetTrainingFunction - }); - }} - /> -
-
- - -
- - - ); - } -}; -export const Train = Radium(UnwrappedTrain); // Exported for unit tests. - -const defaultTimeScale = 1; -const timeScales = [1, 2]; -const MediaControl = Object.freeze({ - Rewind: 'rewind', - Play: 'play', - FastForward: 'fast-forward' -}); - -let UnwrappedPredict = class Predict extends React.Component { - state = { - displayControls: false, - timeScale: defaultTimeScale - }; - - onRun = () => { - const state = setState({isRunning: true, runStartTime: $time()}); - if (state.appMode !== AppMode.CreaturesVTrashDemo) { - this.setState({displayControls: true}); - } - }; - - onContinue = () => { - const state = getState(); - if (state.appMode === AppMode.CreaturesVTrashDemo && state.onContinue) { - state.onContinue(); - } else { - setState({showRecallFish: false}); - modeHelpers.toMode(Modes.Pond); - } - }; - - finishMovement = () => { - const state = getState(); - - const t = currentRunTime(state); - if (state.rewind) { - finishMovement(state.lastPauseTime - t); - } else { - finishMovement(state.lastPauseTime + t); - } - }; - - onPressPlay = () => { - const state = getState(); - this.finishMovement(); - setState({ - isRunning: !state.isRunning, - isPaused: !state.isPaused, - rewind: false, - moveTime: constants.defaultMoveTime / defaultTimeScale - }); - this.setState({timeScale: defaultTimeScale}); - }; - - onScaleTime = rewind => { - this.finishMovement(); - const nextIdx = timeScales.indexOf(this.state.timeScale) + 1; - const timeScale = - nextIdx > timeScales.length - 1 ? timeScales[0] : timeScales[nextIdx]; - - setState({ - rewind, - isRunning: true, - isPaused: false, - moveTime: constants.defaultMoveTime / timeScale - }); - this.setState({timeScale}); - }; - - render() { - const state = getState(); - let selectedControl; - if (state.isRunning && state.rewind) { - selectedControl = MediaControl.Rewind; - } else if ( - state.isRunning && - !state.rewind && - this.state.timeScale !== defaultTimeScale - ) { - selectedControl = MediaControl.FastForward; - } - - return ( - - {this.state.displayControls && ( -
- this.onScaleTime(true)} - style={[ - styles.mediaControl, - selectedControl === MediaControl.Rewind && - styles.selectedControl - ]} - key={MediaControl.Rewind} - > - - {selectedControl === MediaControl.Rewind && - this.state.timeScale !== defaultTimeScale && - `x${this.state.timeScale}`} - - - - - - - this.onScaleTime(false)} - style={[ - styles.mediaControl, - selectedControl === MediaControl.FastForward && - styles.selectedControl - ]} - key={MediaControl.FastForward} - > - - - {selectedControl === MediaControl.FastForward && - this.state.timeScale !== defaultTimeScale && - `x${this.state.timeScale}`} - - -
- )} - {!state.isRunning && !state.isPaused && ( - - )} - {(state.isRunning || state.isPaused) && state.canSkipPredict && ( - - )} - - ); - } -}; -export const Predict = Radium(UnwrappedPredict); // Exported for unit tests. - -class PondPanel extends React.Component { - onPondPanelClick = e => { - setState({pondPanelShowing: false}); - e.stopPropagation(); - }; - - render() { - const state = getState(); - - const maxExplainValue = state.showRecallFish - ? state.pondRecallFishMaxExplainValue - : state.pondFishMaxExplainValue; - - return ( -
- {!state.pondClickedFish && ( -
- {state.pondExplainGeneralSummary && ( -
-
- {I18n.t('mostImportantParts')} -
- {state.pondExplainGeneralSummary.slice(0, 5).map((f, i) => ( -
- {f.importance > 0 && ( -
-   -
-   -
-
- {I18n.t(f.partType)} -
-
- )} -
- ))} -
- {I18n.t('clickIndividualFish')} -
-
- )} -
- )} - {state.pondClickedFish && ( -
this.onPondPanelClick(e)} - > - {state.pondExplainFishSummary && ( -
-
- -
- {state.pondExplainFishSummary.slice(0, 4).map((f, i) => ( -
- {f.impact < 0 && ( -
-   -
-   -
-
- {I18n.t(f.partType)} -
-
- )} - {f.impact > 0 && ( -
-   -
-   -
-
- {I18n.t(f.partType)} -
-
- )} -
- ))} -
- )} -
- )} -
- ); - } -} - -let UnwrappedPond = class Pond extends React.Component { - constructor(props) { - super(props); - } - - toggleRecall = e => { - const state = getState(); - - // No-op if transition is already in progress. - if (state.pondFishTransitionStartTime) { - return; - } - - let currentFishSet, nextFishSet; - if (state.showRecallFish) { - currentFishSet = state.recallFish; - nextFishSet = state.pondFish; - soundLibrary.playSound('yes'); - } else { - currentFishSet = state.pondFish; - nextFishSet = state.recallFish; - soundLibrary.playSound('no'); - } - - // Don't call arrangeFish if fish have already been arranged. - if (nextFishSet.length > 0 && !nextFishSet[0].getXY()) { - arrangeFish(nextFishSet); - } - - if (currentFishSet.length === 0) { - // Immediately transition to nextFishSet rather than waiting for empty animation. - setState({showRecallFish: !state.showRecallFish, pondClickedFish: null}); - } else { - setState({pondFishTransitionStartTime: $time(), pondClickedFish: null}); - } - - if (e) { - e.stopPropagation(); - } - }; - - onPondClick = e => { - // Don't allow pond clicks if a Guide is currently showing. - if (guide.getCurrentGuide()) { - return; - } - - const state = getState(); - const clickX = e.nativeEvent.offsetX; - const clickY = e.nativeEvent.offsetY; - - const boundingRect = e.target.getBoundingClientRect(); - const pondWidth = boundingRect.width; - const pondHeight = boundingRect.height; - // Scale the click to the pond canvas dimensions. - const normalizedClickX = (clickX / pondWidth) * constants.canvasWidth; - const normalizedClickY = (clickY / pondHeight) * constants.canvasHeight; - - const fishCollection = state.showRecallFish - ? state.recallFish - : state.pondFish; - - if (state.pondFishBounds) { - let fishClicked = false; - // Look through the array in reverse so that we click on a fish that - // is rendered topmost. - _.reverse(state.pondFishBounds).forEach(fishBound => { - // If we haven't already clicked on a fish in this current iteration, - // and we're not clicking on a fish that is already actively clicked, - // and we have a collision, then we have clicked on a new fish! - if ( - !fishClicked && - !( - state.pondClickedFish && - fishBound.fishId === state.pondClickedFish.id - ) && - Collide( - fishBound.x, - fishBound.y, - fishBound.w, - fishBound.h, - normalizedClickX, - normalizedClickY, - 1, - 1 - ) - ) { - setState({ - pondClickedFish: { - id: fishBound.fishId, - x: fishBound.x, - y: fishBound.y - } - }); - fishClicked = true; - soundLibrary.playSound('yes'); - - if ( - state.appMode === AppMode.FishShort || - state.appMode === AppMode.FishLong - ) { - const clickedFish = fishCollection.find( - f => f.id === fishBound.fishId - ); - setState({ - pondExplainFishSummary: state.trainer.explainFish(clickedFish) - }); - if (normalizedClickX < constants.canvasWidth / 2) { - setState({pondPanelSide: 'right'}); - } else { - setState({pondPanelSide: 'left'}); - } - } - } - }); - - if (!fishClicked) { - setState({pondClickedFish: null}); - soundLibrary.playSound('no'); - } - } - }; - - onPondPanelButtonClick = e => { - const state = getState(); - - if ([AppMode.FishShort, AppMode.FishLong].includes(state.appMode)) { - setState({ - pondPanelShowing: !state.pondPanelShowing - }); - - if (state.pondPanelShowing) { - soundLibrary.playSound('sortno'); - } else { - soundLibrary.playSound('sortyes'); - } - } - - if (e) { - e.stopPropagation(); - } - }; - - render() { - const state = getState(); - - const showInfoButton = - [AppMode.FishShort, AppMode.FishLong].includes(state.appMode) && - state.pondFish.length > 0 && - state.recallFish.length > 0; - const recallIconsStyle = showInfoButton - ? styles.recallIcons - : {...styles.recallIcons, right: '1.2%'}; - - return ( - -
-
- - -
- {showInfoButton && ( -
- -
- )} - - {state.canSkipPond && ( -
- {state.appMode === AppMode.FishLong ? ( -
- - -
- ) : ( - - )} -
- -
-
- )} - {state.pondPanelShowing && } - - ); - } -}; -export const Pond = Radium(UnwrappedPond); // Exported for unit tests. - -let UnwrappedGuide = class Guide extends React.Component { - onShowing() { - clearInterval(getState().guideTypingTimer); - setState({guideShowing: true, guideTypingTimer: null}); - } - - dismissGuideClick() { - const dismissed = guide.dismissCurrentGuide(); - if (dismissed) { - soundLibrary.playSound('other'); - } - } - - render() { - const state = getState(); - const currentGuide = guide.getCurrentGuide(); - - let guideBgStyle = [styles.guideBackground]; - if (currentGuide) { - if (currentGuide.noDimBackground) { - guideBgStyle = [styles.guideBackgroundHidden]; - } - - // Info guides should have a darker background color. - if (currentGuide.style === 'Info') { - guideBgStyle.push({backgroundColor: colors.transparentBlack}); - } - } +import {getState, setState} from './state'; +import {Modes} from './constants'; - // Start playing the typing sounds. - if (!state.guideShowing && !state.guideTypingTimer && currentGuide) { - const guideTypingTimer = setInterval(() => { - soundLibrary.playSound('no', 0.5); - }, 1000 / 10); - setState({guideTypingTimer}); - } +import {Loading, ConfirmationDialog} from '@ml/oceans/components/common' +import Words from "@ml/oceans/components/scenes/words"; +import Train from "@ml/oceans/components/scenes/train"; +import Predict from "@ml/oceans/components/scenes/predict"; +import Pond from "@ml/oceans/components/scenes/pond"; - return ( -
- {currentGuide && currentGuide.image && ( - - )} - {!!currentGuide && ( -
-
-
-
- {currentGuide.style === 'Info' && ( -
- {I18n.t('didYouKnow')} -
- )} -
- - {currentGuide.textFn(getState())} - -
-
-
- {currentGuide.textFn(getState())} -
-
- {currentGuide.style === 'Info' && ( - - )} -
-
-
- {currentGuide.arrow && ( - - )} -
- )} -
- ); - } -}; -export const Guide = Radium(UnwrappedGuide); // Exported for unit tests. export default class UI extends React.Component { render() { diff --git a/test/unit/oceans/ui.test.js b/test/unit/oceans/ui.test.js index 2e3ac03f..c17ede6c 100644 --- a/test/unit/oceans/ui.test.js +++ b/test/unit/oceans/ui.test.js @@ -2,16 +2,13 @@ import React from 'react'; import ReactDOM from 'react-dom'; import {shallow} from 'enzyme'; import sinon from 'sinon'; -import { - Button, - ConfirmationDialog, - Words, - wordSet, - Train, - Predict, - Pond, - Guide -} from '@ml/oceans/ui'; + +import Words, {wordSet} from "@ml/oceans/components/scenes/words"; +import Train from "@ml/oceans/components/scenes/train"; +import Predict from "@ml/oceans/components/scenes/predict"; +import Pond from "@ml/oceans/components/scenes/pond"; + +import {Guide, Button, ConfirmationDialog} from '@ml/oceans/components/common'; import guide from '@ml/oceans/models/guide'; import soundLibrary from '@ml/oceans/models/soundLibrary'; import train from '@ml/oceans/models/train'; @@ -19,7 +16,7 @@ import modeHelpers from '@ml/oceans/modeHelpers'; import helpers from '@ml/oceans/helpers'; import {setState, getState, resetState} from '@ml/oceans/state'; import {AppMode, Modes} from '@ml/oceans/constants'; -import colors from '@ml/oceans/colors'; +import colors from '@ml/oceans/styles/colors'; import I18n from '@ml/oceans/i18n'; const DEFAULT_PROPS = {