impress/app/javascript/wardrobe-2020/WardrobePage/PosePicker.js
Emi Matchu 9ed3c4ff96 Add Alt Styles reference page link to the Alt Styles picker
Clearing the way to be able to delete the announcement banner, which is
currently the only link!

I feel like there's room to redo the site layout to find a place to
more properly link to this from, but I don't have one yet! And this is
enough of a niche reference that I think this is good enough?
2024-02-25 10:44:00 -08:00

1031 lines
30 KiB
JavaScript

import React from "react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import { ClassNames } from "@emotion/react";
import {
Box,
Button,
Flex,
Popover,
PopoverArrow,
PopoverContent,
PopoverTrigger,
Portal,
Tab,
Tabs,
TabList,
TabPanel,
TabPanels,
VisuallyHidden,
useBreakpointValue,
useColorModeValue,
useTheme,
useToast,
useToken,
} from "@chakra-ui/react";
import { ChevronDownIcon, WarningTwoIcon } from "@chakra-ui/icons";
import { loadable } from "../util";
import { petAppearanceFragment } from "../components/useOutfitAppearance";
import getVisibleLayers from "../components/getVisibleLayers";
import { OutfitLayers } from "../components/OutfitPreview";
import SupportOnly from "./support/SupportOnly";
import { useAltStylesForSpecies } from "../loaders/alt-styles";
import useSupport from "./support/useSupport";
import { useLocalStorage } from "../util";
// From https://twemoji.twitter.com/, thank you!
import twemojiSmile from "../images/twemoji/smile.svg";
import twemojiCry from "../images/twemoji/cry.svg";
import twemojiSick from "../images/twemoji/sick.svg";
import twemojiSunglasses from "../images/twemoji/sunglasses.svg";
import twemojiQuestion from "../images/twemoji/question.svg";
import twemojiMasc from "../images/twemoji/masc.svg";
import twemojiFem from "../images/twemoji/fem.svg";
import twemojiHourglass from "../images/twemoji/hourglass.svg";
const PosePickerSupport = loadable(() => import("./support/PosePickerSupport"));
const PosePickerSupportSwitch = loadable(() =>
import("./support/PosePickerSupport").then((m) => m.PosePickerSupportSwitch),
);
/**
* PosePicker shows the pet poses available on the current species/color, and
* lets the user choose which want they want!
*
* NOTE: This component is memoized with React.memo. It's relatively expensive
* to re-render on every outfit change - the contents update even if the
* popover is closed! This makes wearing/unwearing items noticeably
* slower on lower-power devices.
*
* So, instead of using `outfitState` like most components, we specify
* exactly which props we need, so that `React.memo` can see the changes
* that matter, and skip updates that don't.
*/
function PosePicker({
speciesId,
colorId,
pose,
altStyleId,
appearanceId,
dispatchToOutfit,
onLockFocus,
onUnlockFocus,
...props
}) {
const initialFocusRef = React.useRef();
const posesQuery = usePoses(speciesId, colorId, pose);
const altStylesQuery = useAltStylesForSpecies(speciesId);
const [isInSupportMode, setIsInSupportMode] = useLocalStorage(
"DTIPosePickerIsInSupportMode",
false,
);
const { isSupportUser } = useSupport();
const toast = useToast();
const loading = posesQuery.loading || altStylesQuery.isLoading;
const error = posesQuery.error ?? altStylesQuery.error;
const poseInfos = posesQuery.poseInfos;
const altStyles = altStylesQuery.data ?? [];
const [isOpen, setIsOpen] = React.useState(false);
const [tabIndex, setTabIndex] = React.useState(0);
const altStyle = altStyles.find((s) => s.id === altStyleId);
const placement = useBreakpointValue({ base: "bottom-end", md: "top-end" });
React.useEffect(() => {
// While the popover is open, don't change which tab is open.
if (isOpen) {
return;
}
// Otherwise, set the tab to Styles if we're wearing an Alt Style, or
// Expressions if we're not.
setTabIndex(altStyle != null ? 1 : 0);
}, [altStyle, isOpen]);
// Resize the Popover when we toggle support mode, because it probably will
// affect the content size.
React.useLayoutEffect(() => {
// HACK: To trigger a Popover resize, we simulate a window resize event,
// because Popover listens for window resizes to reposition itself.
// I've also filed an issue requesting an official API!
// https://github.com/chakra-ui/chakra-ui/issues/1853
window.dispatchEvent(new Event("resize"));
}, [isInSupportMode]);
// Generally, the app tries to never put us in an invalid pose state. But it
// can happen with direct URL navigation, or pet loading when modeling isn't
// updated! Let's do some recovery.
const selectedPoseIsAvailable = Object.values(poseInfos).some(
(pi) => pi.isSelected && pi.isAvailable,
);
const firstAvailablePose = Object.values(poseInfos).find(
(pi) => pi.isAvailable,
)?.pose;
React.useEffect(() => {
if (loading) {
return;
}
if (!selectedPoseIsAvailable) {
if (!firstAvailablePose) {
// TODO: I suppose this error would fit better in SpeciesColorPicker!
toast({
status: "error",
title: "Oops, we don't have data for this pet color!",
description:
"If it's new, this might be a modeling issue—try modeling it on " +
"Classic DTI first. Sorry!",
duration: null,
isClosable: true,
});
return;
}
console.warn(
`Pose ${pose} not found for speciesId=${speciesId}, ` +
`colorId=${colorId}. Redirecting to pose ${firstAvailablePose}.`,
);
dispatchToOutfit({ type: "setPose", pose: firstAvailablePose });
}
}, [
loading,
selectedPoseIsAvailable,
firstAvailablePose,
speciesId,
colorId,
pose,
toast,
dispatchToOutfit,
]);
// This is a low-stakes enough control, where enough pairs don't have data
// anyway, that I think I want to just not draw attention to failures.
if (error) {
return null;
}
const numStandardPoses = Object.values(poseInfos).filter(
(p) => p.isAvailable && STANDARD_POSES.includes(p.pose),
).length;
const onChangePose = (e) => {
dispatchToOutfit({ type: "setPose", pose: e.target.value });
};
const onChangeStyle = (altStyleId) => {
dispatchToOutfit({ type: "setStyle", altStyleId });
};
return (
<Popover
placement={placement}
returnFocusOnClose
onOpen={() => {
setIsOpen(true);
onLockFocus();
}}
onClose={() => {
setIsOpen(false);
onUnlockFocus();
}}
initialFocusRef={initialFocusRef}
isLazy
lazyBehavior="keepMounted"
>
{() => (
<>
<PopoverTrigger>
<PosePickerButton
pose={pose}
altStyle={altStyle}
isOpen={isOpen}
loading={loading}
{...props}
/>
</PopoverTrigger>
<Portal>
<PopoverContent>
<Tabs
size="sm"
variant="soft-rounded"
display="flex"
flexDirection={{ base: "column", md: "column-reverse" }}
paddingY="2"
gap="2"
index={tabIndex}
onChange={setTabIndex}
>
<TabList paddingX="2" paddingY="0">
<Tab width="50%">Expressions</Tab>
<Tab width="50%">Styles</Tab>
</TabList>
<TabPanels position="relative">
<TabPanel paddingX="4" paddingY="0">
{isInSupportMode ? (
<PosePickerSupport
speciesId={speciesId}
colorId={colorId}
pose={pose}
appearanceId={appearanceId}
initialFocusRef={
tabIndex === 0 ? initialFocusRef : null
}
dispatchToOutfit={dispatchToOutfit}
/>
) : (
<>
<PosePickerTable
poseInfos={poseInfos}
onChange={onChangePose}
initialFocusRef={
tabIndex === 0 ? initialFocusRef : null
}
/>
{numStandardPoses == 0 && (
<PosePickerEmptyExplanation />
)}
</>
)}
<SupportOnly>
<Box position="absolute" top="1" left="3">
<PosePickerSupportSwitch
isChecked={isInSupportMode}
onChange={(e) => setIsInSupportMode(e.target.checked)}
/>
</Box>
</SupportOnly>
</TabPanel>
<TabPanel paddingX="4" paddingY="0">
<StyleSelect
selectedStyleId={altStyleId}
altStyles={altStyles}
onChange={onChangeStyle}
initialFocusRef={tabIndex === 1 ? initialFocusRef : null}
/>
<StyleExplanation />
</TabPanel>
</TabPanels>
</Tabs>
<PopoverArrow />
</PopoverContent>
</Portal>
</>
)}
</Popover>
);
}
function PosePickerButton({ pose, altStyle, isOpen, loading, ...props }, ref) {
const theme = useTheme();
const icon = altStyle != null ? twemojiSunglasses : getIcon(pose);
const label = altStyle != null ? altStyle.seriesName : getLabel(pose);
return (
<ClassNames>
{({ css, cx }) => (
<Button
variant="unstyled"
textShadow={`${theme.colors.blackAlpha["700"]} 0 1px 2px`}
d="flex"
alignItems="center"
justifyContent="center"
_focus={{ borderColor: "gray.50" }}
_hover={{ borderColor: "gray.50" }}
outline="initial"
fontSize="sm"
fontWeight="normal"
minWidth="12ch"
disabled={loading}
className={cx(
css`
border: 1px solid transparent !important;
color: ${theme.colors.gray["100"]};
cursor: ${loading ? "wait" : "pointer"} !important;
transition:
color 0.2s,
background: 0.2s,
border-color 0.2s !important;
padding-left: 0.75em;
padding-right: 0.5em;
&:hover,
&.is-open {
border-color: ${theme.colors.gray["50"]} !important;
color: ${theme.colors.gray["50"]};
background: ${theme.colors.blackAlpha["600"]};
text-shadow: transparent 0 1px 2px;
}
&:focus {
border-color: ${theme.colors.gray["50"]} !important;
box-shadow: ${theme.shadows.outline};
}
&.is-open {
border-width: 2px !important;
}
`,
isOpen && "is-open",
)}
{...props}
ref={ref}
>
<EmojiImage src={icon} alt="Style" />
<Box width=".5em" />
{label}
<Box width=".5em" />
<ChevronDownIcon />
</Button>
)}
</ClassNames>
);
}
PosePickerButton = React.forwardRef(PosePickerButton);
function PosePickerTable({ poseInfos, onChange, initialFocusRef }) {
return (
<Box display="flex" flexDirection="column" alignItems="center">
<table width="100%">
<thead>
<tr>
<th />
<Cell as="th">
<EmojiImage src={twemojiSmile} alt="Happy" />
</Cell>
<Cell as="th">
<EmojiImage src={twemojiCry} alt="Sad" />
</Cell>
<Cell as="th">
<EmojiImage src={twemojiSick} alt="Sick" />
</Cell>
</tr>
</thead>
<tbody>
<tr>
<Cell as="th">
<EmojiImage src={twemojiMasc} alt="Masculine" />
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.happyMasc}
onChange={onChange}
inputRef={poseInfos.happyMasc.isSelected && initialFocusRef}
/>
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.sadMasc}
onChange={onChange}
inputRef={poseInfos.sadMasc.isSelected && initialFocusRef}
/>
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.sickMasc}
onChange={onChange}
inputRef={poseInfos.sickMasc.isSelected && initialFocusRef}
/>
</Cell>
</tr>
<tr>
<Cell as="th">
<EmojiImage src={twemojiFem} alt="Feminine" />
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.happyFem}
onChange={onChange}
inputRef={poseInfos.happyFem.isSelected && initialFocusRef}
/>
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.sadFem}
onChange={onChange}
inputRef={poseInfos.sadFem.isSelected && initialFocusRef}
/>
</Cell>
<Cell as="td">
<PoseOption
poseInfo={poseInfos.sickFem}
onChange={onChange}
inputRef={poseInfos.sickFem.isSelected && initialFocusRef}
/>
</Cell>
</tr>
</tbody>
</table>
{poseInfos.unconverted.isAvailable && (
<Flex
align="center"
justify="center"
gap="1"
marginTop="2"
marginBottom="2"
>
<PoseOption
poseInfo={poseInfos.unconverted}
onChange={onChange}
inputRef={poseInfos.unconverted.isSelected && initialFocusRef}
size="sm"
label="Retired UC"
/>
<RetiredUCWarning isSelected={poseInfos.unconverted.isSelected} />
</Flex>
)}
</Box>
);
}
function Cell({ children, as }) {
const Tag = as;
return (
<Tag>
<Flex justify="center" p="1">
{children}
</Flex>
</Tag>
);
}
const EMOTION_STRINGS = {
HAPPY_MASC: "Happy",
HAPPY_FEM: "Happy",
SAD_MASC: "Sad",
SAD_FEM: "Sad",
SICK_MASC: "Sick",
SICK_FEM: "Sick",
};
const GENDER_PRESENTATION_STRINGS = {
HAPPY_MASC: "Masculine",
SAD_MASC: "Masculine",
SICK_MASC: "Masculine",
HAPPY_FEM: "Feminine",
SAD_FEM: "Feminine",
SICK_FEM: "Feminine",
};
const STANDARD_POSES = Object.keys(EMOTION_STRINGS);
function PoseOption({
poseInfo,
onChange,
inputRef,
size = "md",
label,
...otherProps
}) {
const theme = useTheme();
const genderPresentationStr = GENDER_PRESENTATION_STRINGS[poseInfo.pose];
const emotionStr = EMOTION_STRINGS[poseInfo.pose];
let poseName =
poseInfo.pose === "UNCONVERTED"
? "Unconverted"
: `${emotionStr} and ${genderPresentationStr}`;
if (!poseInfo.isAvailable) {
poseName += ` (not modeled yet)`;
}
const borderColor = useColorModeValue(
theme.colors.green["600"],
theme.colors.green["300"],
);
return (
<ClassNames>
{({ css, cx }) => (
<Box
as="label"
cursor="pointer"
display="flex"
alignItems="center"
borderColor={poseInfo.isSelected ? borderColor : "gray.400"}
boxShadow={label ? "md" : "none"}
borderWidth={label ? "1px" : "0"}
borderRadius={label ? "full" : "0"}
paddingRight={label ? "3" : "0"}
onClick={(e) => {
// HACK: We need the timeout to beat the popover's focus stealing!
const input = e.currentTarget.querySelector("input");
setTimeout(() => input.focus(), 0);
}}
{...otherProps}
>
<VisuallyHidden
as="input"
type="radio"
aria-label={poseName}
name="pose"
value={poseInfo.pose}
checked={poseInfo.isSelected}
disabled={!poseInfo.isAvailable}
onChange={onChange}
ref={inputRef || null}
/>
<Box
aria-hidden
borderRadius="full"
boxShadow="md"
overflow="hidden"
width={size === "sm" ? "30px" : "50px"}
height={size === "sm" ? "30px" : "50px"}
title={
poseInfo.isAvailable
? // A lil debug output, so that we can quickly identify glitched
// PetStates and manually mark them as glitched!
window.location.hostname.includes("localhost") &&
`#${poseInfo.id}`
: "Not modeled yet"
}
position="relative"
className={css`
transform: scale(0.8);
opacity: 0.8;
transition: all 0.2s;
input:checked + & {
transform: scale(1);
opacity: 1;
}
`}
>
<Box
borderRadius="full"
position="absolute"
top="0"
bottom="0"
left="0"
right="0"
zIndex="2"
className={cx(
css`
border: 0px solid ${borderColor};
transition: border-width 0.2s;
&.not-available {
border-color: ${theme.colors.gray["500"]};
border-width: 1px;
}
input:checked + * & {
border-width: 1px;
}
input:focus + * & {
border-width: 3px;
}
`,
!poseInfo.isAvailable && "not-available",
)}
/>
{poseInfo.isAvailable ? (
<Box
width="100%"
height="100%"
transform={getTransform(poseInfo)}
>
<OutfitLayers visibleLayers={getVisibleLayers(poseInfo, [])} />
</Box>
) : (
<Flex align="center" justify="center" width="100%" height="100%">
<EmojiImage src={twemojiQuestion} boxSize={24} />
</Flex>
)}
</Box>
{label && (
<Box
marginLeft="2"
fontSize="xs"
fontWeight={poseInfo.isSelected ? "bold" : "normal"}
>
{label}
</Box>
)}
</Box>
)}
</ClassNames>
);
}
function PosePickerEmptyExplanation() {
return (
<Box
fontSize="xs"
fontStyle="italic"
textAlign="center"
opacity="0.7"
marginTop="2"
>
We're still working on labeling these! For now, we're just giving you one
of the expressions we have.
</Box>
);
}
function RetiredUCWarning({ isSelected }) {
return (
<Popover placement="right" trigger="hover">
<PopoverTrigger>
<Box
as="button"
tabIndex="0"
aria-label="Warning"
cursor="help"
lineHeight="1"
opacity={isSelected ? "1" : "0.75"}
transform={isSelected ? "scale(1)" : "scale(0.8)"}
color={isSelected ? "yellow.500" : "inherit"}
transition="all 0.2s"
padding="1"
>
<WarningTwoIcon />
</Box>
</PopoverTrigger>
<PopoverContent
background="blackAlpha.800"
borderColor="blackAlpha.900"
color="white"
padding="2"
fontSize="sm"
>
"Unconverted" pets are no longer available on Neopets.com, and have been
replaced with the very similar Styles feature. We're just keeping this
as an archive!
</PopoverContent>
</Popover>
);
}
function StyleSelect({
selectedStyleId,
altStyles,
onChange,
initialFocusRef,
}) {
const defaultStyle = { id: null, adjectiveName: "Default" };
const styles = [defaultStyle, ...altStyles];
return (
<Flex
as="form"
direction="column"
gap="2"
maxHeight={{ base: "30vh", md: "10rem" }}
padding="4px"
overflow="auto"
>
{styles.map((altStyle) => (
<StyleOption
key={altStyle.id}
altStyle={altStyle}
checked={selectedStyleId === altStyle.id}
onChange={onChange}
inputRef={selectedStyleId === altStyle.id ? initialFocusRef : null}
/>
))}
</Flex>
);
}
function StyleOption({ altStyle, checked, onChange, inputRef }) {
const theme = useTheme();
const selectedBorderColor = useColorModeValue(
theme.colors.green["600"],
theme.colors.green["300"],
);
const outlineShadow = useToken("shadows", "outline");
return (
<ClassNames>
{({ css, cx }) => (
<Box
as="label"
cursor="pointer"
onClick={(e) => {
// HACK: We need the timeout to beat the popover's focus stealing!
const input = e.currentTarget.querySelector("input");
setTimeout(() => input.focus(), 0);
}}
>
<VisuallyHidden
as="input"
type="radio"
name="style"
value={altStyle.id}
checked={checked}
onChange={(e) => onChange(altStyle.id)}
ref={inputRef}
/>
<Flex
alignItems="center"
gap="2"
borderRadius="md"
// HACK: Don't let the thumbnail image overlap the border
paddingLeft="2px"
paddingY="2px"
className={cx(css`
border: 2px solid transparent;
opacity: 0.8;
transition: all 0.2s;
input:focus + & {
box-shadow: ${outlineShadow};
}
input:checked + & {
border-color: ${selectedBorderColor};
opacity: 1;
font-weight: bold;
}
`)}
>
{altStyle.thumbnailUrl ? (
<Box
as="img"
src={altStyle.thumbnailUrl}
alt="Item thumbnail"
width="40px"
height="40px"
loading="lazy"
/>
) : (
<Box width="40px" height="40px" />
)}
<Box width="2" />
{altStyle.adjectiveName}
</Flex>
</Box>
)}
</ClassNames>
);
}
function StyleExplanation() {
return (
<Box
fontSize="xs"
fontStyle="italic"
textAlign="center"
opacity="0.7"
marginTop="2"
>
<Box as="a" href="/alt-styles" target="_blank" textDecoration="underline">
Alt Styles
</Box>{" "}
are NC items that override the pet's appearance via the{" "}
<Box
as="a"
href="https://www.neopets.com/stylingchamber/"
target="_blank"
textDecoration="underline"
>
Styling Chamber
</Box>
. Not all items fit Alt Style pets. The pet's color doesn't have to match.
</Box>
);
}
function EmojiImage({ src, alt, boxSize = 16 }) {
return <img src={src} alt={alt} width={boxSize} height={boxSize} />;
}
function usePoses(speciesId, colorId, selectedPose) {
const { loading, error, data } = useQuery(
gql`
query PosePicker($speciesId: ID!, $colorId: ID!) {
happyMasc: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: HAPPY_MASC
) {
...PetAppearanceForPosePicker
}
sadMasc: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: SAD_MASC
) {
...PetAppearanceForPosePicker
}
sickMasc: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: SICK_MASC
) {
...PetAppearanceForPosePicker
}
happyFem: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: HAPPY_FEM
) {
...PetAppearanceForPosePicker
}
sadFem: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: SAD_FEM
) {
...PetAppearanceForPosePicker
}
sickFem: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: SICK_FEM
) {
...PetAppearanceForPosePicker
}
unconverted: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: UNCONVERTED
) {
...PetAppearanceForPosePicker
}
unknown: petAppearance(
speciesId: $speciesId
colorId: $colorId
pose: UNKNOWN
) {
...PetAppearanceForPosePicker
}
}
${petAppearanceForPosePickerFragment}
`,
{ variables: { speciesId, colorId }, onError: (e) => console.error(e) },
);
const poseInfos = {
happyMasc: {
...data?.happyMasc,
pose: "HAPPY_MASC",
isAvailable: Boolean(data?.happyMasc),
isSelected: selectedPose === "HAPPY_MASC",
},
sadMasc: {
...data?.sadMasc,
pose: "SAD_MASC",
isAvailable: Boolean(data?.sadMasc),
isSelected: selectedPose === "SAD_MASC",
},
sickMasc: {
...data?.sickMasc,
pose: "SICK_MASC",
isAvailable: Boolean(data?.sickMasc),
isSelected: selectedPose === "SICK_MASC",
},
happyFem: {
...data?.happyFem,
pose: "HAPPY_FEM",
isAvailable: Boolean(data?.happyFem),
isSelected: selectedPose === "HAPPY_FEM",
},
sadFem: {
...data?.sadFem,
pose: "SAD_FEM",
isAvailable: Boolean(data?.sadFem),
isSelected: selectedPose === "SAD_FEM",
},
sickFem: {
...data?.sickFem,
pose: "SICK_FEM",
isAvailable: Boolean(data?.sickFem),
isSelected: selectedPose === "SICK_FEM",
},
unconverted: {
...data?.unconverted,
pose: "UNCONVERTED",
isAvailable: Boolean(data?.unconverted),
isSelected: selectedPose === "UNCONVERTED",
},
unknown: {
...data?.unknown,
pose: "UNKNOWN",
isAvailable: Boolean(data?.unknown),
isSelected: selectedPose === "UNKNOWN",
},
};
return { loading, error, poseInfos };
}
function getIcon(pose) {
if (["HAPPY_MASC", "HAPPY_FEM"].includes(pose)) {
return twemojiSmile;
} else if (["SAD_MASC", "SAD_FEM"].includes(pose)) {
return twemojiCry;
} else if (["SICK_MASC", "SICK_FEM"].includes(pose)) {
return twemojiSick;
} else if (pose === "UNCONVERTED") {
return twemojiHourglass;
} else {
return twemojiSmile;
}
}
function getLabel(pose) {
if (pose === "HAPPY_MASC" || pose === "HAPPY_FEM") {
return "Happy";
} else if (pose === "SAD_MASC" || pose === "SAD_FEM") {
return "Sad";
} else if (pose === "SICK_MASC" || pose === "SICK_FEM") {
return "Sick";
} else if (pose === "UNCONVERTED") {
return "Retired UC";
} else {
return "Default";
}
}
function getTransform(poseInfo) {
const { pose, bodyId } = poseInfo;
if (pose === "UNCONVERTED") {
return transformsByBodyId.default;
}
if (bodyId in transformsByBodyId) {
return transformsByBodyId[bodyId];
}
return transformsByBodyId.default;
}
export const petAppearanceForPosePickerFragment = gql`
fragment PetAppearanceForPosePicker on PetAppearance {
id
bodyId
pose
...PetAppearanceForOutfitPreview
}
${petAppearanceFragment}
`;
const transformsByBodyId = {
93: "translate(-5px, 10px) scale(2.8)",
106: "translate(-8px, 8px) scale(2.9)",
47: "translate(-1px, 17px) scale(3)",
84: "translate(-21px, 22px) scale(3.2)",
146: "translate(2px, 15px) scale(3.3)",
250: "translate(-14px, 28px) scale(3.4)",
212: "translate(-4px, 8px) scale(2.9)",
74: "translate(-26px, 30px) scale(3.0)",
94: "translate(-4px, 8px) scale(3.1)",
132: "translate(-14px, 18px) scale(3.0)",
56: "translate(-7px, 24px) scale(2.9)",
90: "translate(-16px, 20px) scale(3.5)",
136: "translate(-11px, 18px) scale(3.0)",
138: "translate(-14px, 26px) scale(3.5)",
166: "translate(-13px, 24px) scale(3.1)",
119: "translate(-6px, 29px) scale(3.1)",
126: "translate(3px, 13px) scale(3.1)",
67: "translate(2px, 27px) scale(3.4)",
163: "translate(-7px, 16px) scale(3.1)",
147: "translate(-2px, 15px) scale(3.0)",
80: "translate(-2px, -17px) scale(3.0)",
117: "translate(-14px, 16px) scale(3.6)",
201: "translate(-16px, 16px) scale(3.2)",
51: "translate(-2px, 6px) scale(3.2)",
208: "translate(-3px, 6px) scale(3.7)",
196: "translate(-7px, 19px) scale(5.2)",
143: "translate(-16px, 20px) scale(3.5)",
150: "translate(-3px, 24px) scale(3.2)",
175: "translate(-9px, 15px) scale(3.4)",
173: "translate(3px, 57px) scale(4.4)",
199: "translate(-28px, 35px) scale(3.8)",
52: "translate(-8px, 33px) scale(3.5)",
109: "translate(-8px, -6px) scale(3.2)",
134: "translate(-14px, 14px) scale(3.1)",
95: "translate(-12px, 0px) scale(3.4)",
96: "translate(6px, 23px) scale(3.3)",
154: "translate(-20px, 25px) scale(3.6)",
55: "translate(-16px, 28px) scale(4.0)",
76: "translate(-8px, 11px) scale(3.0)",
156: "translate(2px, 12px) scale(3.5)",
78: "translate(-3px, 18px) scale(3.0)",
191: "translate(-18px, 46px) scale(4.4)",
187: "translate(-6px, 22px) scale(3.2)",
46: "translate(-2px, 19px) scale(3.4)",
178: "translate(-11px, 32px) scale(3.3)",
100: "translate(-13px, 23px) scale(3.3)",
130: "translate(-14px, 4px) scale(3.1)",
188: "translate(-9px, 24px) scale(3.5)",
257: "translate(-14px, 25px) scale(3.4)",
206: "translate(-7px, 4px) scale(3.6)",
101: "translate(-13px, 16px) scale(3.2)",
68: "translate(-2px, 13px) scale(3.2)",
182: "translate(-6px, 4px) scale(3.1)",
180: "translate(-15px, 22px) scale(3.6)",
306: "translate(1px, 14px) scale(3.1)",
default: "scale(2.5)",
};
export default React.memo(PosePicker);