import React from "react"; import gql from "graphql-tag"; import { useQuery } from "@apollo/client"; import { Box, Flex, Select, Text, useColorModeValue } from "@chakra-ui/react"; import { Delay, logAndCapture, useFetch } from "../util"; /** * SpeciesColorPicker lets the user pick the species/color of their pet. * * It preloads all species, colors, and valid species/color pairs; and then * ensures that the outfit is always in a valid state. * * NOTE: This component is memoized with React.memo. It's not the cheapest to * re-render on every outfit change. This contributes to * wearing/unwearing items being noticeably slower on lower-power * devices. */ function SpeciesColorPicker({ speciesId, colorId, idealPose, showPlaceholders = false, colorPlaceholderText = "", speciesPlaceholderText = "", stateMustAlwaysBeValid = false, isDisabled = false, size = "md", onChange, }) { const { loading: loadingMeta, error: errorMeta, data: meta } = useQuery(gql` query SpeciesColorPicker { allSpecies { id name standardBodyId # Used for keeping items on during standard color changes } allColors { id name isStandard # Used for keeping items on during standard color changes } } `); const { loading: loadingValids, error: errorValids, data: validsBuffer, } = useFetch("/api/validPetPoses", { responseType: "arrayBuffer" }); const valids = React.useMemo( () => validsBuffer && new DataView(validsBuffer), [validsBuffer] ); const allColors = (meta && [...meta.allColors]) || []; allColors.sort((a, b) => a.name.localeCompare(b.name)); const allSpecies = (meta && [...meta.allSpecies]) || []; allSpecies.sort((a, b) => a.name.localeCompare(b.name)); const textColor = useColorModeValue("inherit", "green.50"); if ((loadingMeta || loadingValids) && !showPlaceholders) { return ( Loading species/color data… ); } if (errorMeta || errorValids) { return ( Error loading species/color data. ); } // When the color changes, check if the new pair is valid, and update the // outfit if so! const onChangeColor = (e) => { const newColorId = e.target.value; const species = allSpecies.find((s) => s.id === speciesId); const newColor = allColors.find((c) => c.id === newColorId); const validPoses = getValidPoses(valids, speciesId, newColorId); const isValid = validPoses.size > 0; if (stateMustAlwaysBeValid && !isValid) { // NOTE: This shouldn't happen, because we should hide invalid colors. console.error( `Assertion error in SpeciesColorPicker: Entered an invalid state, ` + `with prop stateMustAlwaysBeValid.` ); } const closestPose = getClosestPose(validPoses, idealPose); onChange(species, newColor, isValid, closestPose); }; // When the species changes, check if the new pair is valid, and update the // outfit if so! const onChangeSpecies = (e) => { const newSpeciesId = e.target.value; const newSpecies = allSpecies.find((s) => s.id === newSpeciesId); let color = allColors.find((c) => c.id === colorId); let validPoses = getValidPoses(valids, newSpeciesId, colorId); let isValid = validPoses.size > 0; if (stateMustAlwaysBeValid && !isValid) { // If `stateMustAlwaysBeValid`, but the user switches to a species that // doesn't support this color, that's okay and normal! We'll just switch // to one of the four basic colors instead. const basicColorId = ["8", "34", "61", "84"][ Math.floor(Math.random() * 4) ]; const basicColor = allColors.find((c) => c.id === basicColorId); color = basicColor; validPoses = getValidPoses(valids, newSpeciesId, color.id); isValid = true; } const closestPose = getClosestPose(validPoses, idealPose); onChange(newSpecies, color, isValid, closestPose); }; // In `stateMustAlwaysBeValid` mode, we hide colors that are invalid on this // species, so the user can't switch. (We handle species differently: if you // switch to a new species and the color is invalid, we reset the color. We // think this matches users' mental hierarchy of species -> color: showing // supported colors for a species makes sense, but the other way around feels // confusing and restrictive.) // // Also, if a color is provided that wouldn't normally be visible, we still // show it. This can happen when someone models a new species/color combo for // the first time - the boxes will still be red as if it were invalid, but // this still smooths out the experience a lot. let visibleColors = allColors; if (stateMustAlwaysBeValid && valids && speciesId) { visibleColors = visibleColors.filter( (c) => getValidPoses(valids, speciesId, c.id).size > 0 || c.id === colorId ); } return ( { // If the selected color isn't in the set we have here, show the // placeholder. (Can happen during loading, or if an invalid color ID // like null is intentionally provided while the real value loads.) !visibleColors.some((c) => c.id === colorId) && ( ) } { // A long name for sizing! Should appear below the placeholder, out // of view. visibleColors.length === 0 && } {visibleColors.map((color) => ( ))} { // If the selected species isn't in the set we have here, show the // placeholder. (Can happen during loading, or if an invalid species // ID like null is intentionally provided while the real value // loads.) !allSpecies.some((s) => s.id === speciesId) && ( ) } { // A long name for sizing! Should appear below the placeholder, out // of view. allSpecies.length === 0 && } {allSpecies.map((species) => ( ))} ); } const SpeciesColorSelect = ({ size, valids, speciesId, colorId, isDisabled, isLoading, ...props }) => { const backgroundColor = useColorModeValue("white", "gray.600"); const borderColor = useColorModeValue("green.600", "transparent"); const textColor = useColorModeValue("inherit", "green.50"); const loadingProps = isLoading ? { // Visually the disabled state is the same as the normal state, but // with a wait cursor. We don't expect this to take long, and the flash // of content is rough! opacity: "1 !important", cursor: "wait !important", } : {}; return (