Show thumbnail in wardrobe page while outfit loads

To help the load time for outfits feel shorter, we now reuse the outfit thumbnail from the Your Outfits page as a placeholder!

This doesn't add any overhead when the thumbnail data _isn't_ in your session cache, e.g. if you navigate to the outfit directly. But if we have the thumbnail on hand already, we just show it, easy peasy!
This commit is contained in:
Emi Matchu 2021-01-08 01:23:24 -08:00
parent fe799a9bbc
commit 2a8be58973
5 changed files with 186 additions and 65 deletions

View file

@ -6,12 +6,11 @@ import { useQuery } from "@apollo/client";
import { Link } from "react-router-dom"; import { Link } from "react-router-dom";
import { ErrorMessage, Heading1, useCommonStyles } from "./util"; import { ErrorMessage, Heading1, useCommonStyles } from "./util";
import {
getVisibleLayers,
petAppearanceFragmentForGetVisibleLayers,
itemAppearanceFragmentForGetVisibleLayers,
} from "./components/useOutfitAppearance";
import HangerSpinner from "./components/HangerSpinner"; import HangerSpinner from "./components/HangerSpinner";
import OutfitThumbnail, {
outfitThumbnailFragment,
getOutfitThumbnailRenderSize,
} from "./components/OutfitThumbnail";
import useRequireLogin from "./components/useRequireLogin"; import useRequireLogin from "./components/useRequireLogin";
import WIPCallout from "./components/WIPCallout"; import WIPCallout from "./components/WIPCallout";
@ -38,13 +37,11 @@ function UserOutfitsPageContent() {
outfits { outfits {
id id
name name
...OutfitThumbnailFragment
# For alt text
petAppearance { petAppearance {
id
layers {
id
svgUrl
imageUrl(size: $size)
}
species { species {
id id
name name
@ -53,16 +50,6 @@ function UserOutfitsPageContent() {
id id
name name
} }
...PetAppearanceForGetVisibleLayers
}
itemAppearances {
id
layers {
id
svgUrl
imageUrl(size: $size)
}
...ItemAppearanceForGetVisibleLayers
} }
wornItems { wornItems {
id id
@ -71,10 +58,15 @@ function UserOutfitsPageContent() {
} }
} }
} }
${petAppearanceFragmentForGetVisibleLayers} ${outfitThumbnailFragment}
${itemAppearanceFragmentForGetVisibleLayers}
`, `,
{ variables: { size: "SIZE_" + getBestImageSize() }, skip: userLoading } {
variables: {
// NOTE: This parameter is used inside `OutfitThumbnailFragment`!
size: "SIZE_" + getOutfitThumbnailRenderSize(),
},
skip: userLoading,
}
); );
if (userLoading || queryLoading) { if (userLoading || queryLoading) {
@ -112,14 +104,9 @@ function OutfitCard({ outfit }) {
const image = ( const image = (
<ClassNames> <ClassNames>
{({ css }) => ( {({ css }) => (
<Box <OutfitThumbnail
as="img" petAppearance={outfit.petAppearance}
src={buildOutfitThumbnailUrl( itemAppearances={outfit.itemAppearances}
outfit.petAppearance,
outfit.itemAppearances
)}
width={150}
height={150}
alt={buildOutfitAltText(outfit)} alt={buildOutfitAltText(outfit)}
// Firefox shows alt text as a fallback for images it can't show yet. // Firefox shows alt text as a fallback for images it can't show yet.
// Show our alt text clearly if the image failed to load... but hide // Show our alt text clearly if the image failed to load... but hide
@ -186,16 +173,6 @@ function OutfitCardLayout({ image, caption }) {
); );
} }
function buildOutfitThumbnailUrl(petAppearance, itemAppearances) {
const size = getBestImageSize();
const visibleLayers = getVisibleLayers(petAppearance, itemAppearances);
const layerUrls = visibleLayers.map(
(layer) => layer.svgUrl || layer.imageUrl
);
return `/api/outfitImage?size=${size}&layerUrls=${layerUrls.join(",")}`;
}
function buildOutfitAltText(outfit) { function buildOutfitAltText(outfit) {
const { petAppearance, wornItems } = outfit; const { petAppearance, wornItems } = outfit;
const { species, color } = petAppearance; const { species, color } = petAppearance;
@ -216,20 +193,4 @@ function buildOutfitAltText(outfit) {
return altText; return altText;
} }
/**
* getBestImageSize returns the right image size to render at 150x150, for the
* current device.
*
* On high-DPI devices, we'll download a 300x300 image to render at 150x150
* scale. On standard-DPI devices, we'll download a 150x150 image, to save
* bandwidth.
*/
function getBestImageSize() {
if (window.devicePixelRatio > 1) {
return 300;
} else {
return 150;
}
}
export default UserOutfitsPage; export default UserOutfitsPage;

View file

@ -0,0 +1,78 @@
import React from "react";
import gql from "graphql-tag";
import { useQuery } from "@apollo/client";
import OutfitThumbnail, {
outfitThumbnailFragment,
getOutfitThumbnailRenderSize,
} from "../components/OutfitThumbnail";
import OutfitPreview from "../components/OutfitPreview";
function WardrobeOutfitPreview({
isLoading,
outfitState,
onChangeHasAnimations,
}) {
return (
<OutfitPreview
isLoading={isLoading}
speciesId={outfitState.speciesId}
colorId={outfitState.colorId}
pose={outfitState.pose}
appearanceId={outfitState.appearanceId}
wornItemIds={outfitState.wornItemIds}
onChangeHasAnimations={onChangeHasAnimations}
placeholder={<OutfitThumbnailIfCached outfitId={outfitState.id} />}
/>
);
}
/**
* OutfitThumbnailIfCached will render an OutfitThumbnail as a placeholder for
* the outfit preview... but only if we already have the data to generate the
* thumbnail stored in our local Apollo GraphQL cache.
*
* This means that, when you come from the Your Outfits page, we can show the
* outfit thumbnail instantly while everything else loads. But on direct
* navigation, this does nothing, and we just wait for the preview to load in
* like usual!
*/
function OutfitThumbnailIfCached({ outfitId }) {
const { data } = useQuery(
gql`
query OutfitThumbnailIfCached($outfitId: ID!, $size: LayerImageSize!) {
outfit(id: $outfitId) {
id
...OutfitThumbnailFragment
}
}
${outfitThumbnailFragment}
`,
{
variables: {
outfitId,
// NOTE: This parameter is used inside `OutfitThumbnailFragment`!
size: "SIZE_" + getOutfitThumbnailRenderSize(),
},
fetchPolicy: "cache-only",
}
);
if (!data?.outfit) {
return null;
}
return (
<OutfitThumbnail
petAppearance={data.outfit.petAppearance}
itemAppearances={data.outfit.itemAppearances}
alt=""
objectFit="contain"
width="100%"
height="100%"
filter="blur(2px)"
/>
);
}
export default WardrobeOutfitPreview;

View file

@ -3,11 +3,11 @@ import { useToast } from "@chakra-ui/react";
import loadable from "@loadable/component"; import loadable from "@loadable/component";
import ItemsAndSearchPanels from "./ItemsAndSearchPanels"; import ItemsAndSearchPanels from "./ItemsAndSearchPanels";
import OutfitPreview from "../components/OutfitPreview";
import SupportOnly from "./support/SupportOnly"; import SupportOnly from "./support/SupportOnly";
import useOutfitState, { OutfitStateContext } from "./useOutfitState"; import useOutfitState, { OutfitStateContext } from "./useOutfitState";
import { usePageTitle } from "../util"; import { usePageTitle } from "../util";
import WardrobePageLayout from "./WardrobePageLayout"; import WardrobePageLayout from "./WardrobePageLayout";
import WardrobeOutfitPreview from "./WardrobeOutfitPreview";
const OutfitControls = loadable(() => const OutfitControls = loadable(() =>
import(/* webpackPreload: true */ "./OutfitControls") import(/* webpackPreload: true */ "./OutfitControls")
@ -61,13 +61,9 @@ function WardrobePage() {
</SupportOnly> </SupportOnly>
<WardrobePageLayout <WardrobePageLayout
preview={ preview={
<OutfitPreview <WardrobeOutfitPreview
isLoading={loading} isLoading={loading}
speciesId={outfitState.speciesId} outfitState={outfitState}
colorId={outfitState.colorId}
pose={outfitState.pose}
appearanceId={outfitState.appearanceId}
wornItemIds={outfitState.wornItemIds}
onChangeHasAnimations={setHasAnimations} onChangeHasAnimations={setHasAnimations}
/> />
} }

View file

@ -165,6 +165,10 @@ export function OutfitLayers({
// point we fade it out. // point we fade it out.
opacity={visibleLayers.length === 0 ? 1 : 0} opacity={visibleLayers.length === 0 ? 1 : 0}
transition="opacity 0.2s" transition="opacity 0.2s"
width="100%"
height="100%"
maxWidth="600px"
maxHeight="600px"
> >
{placeholder} {placeholder}
</Box> </Box>

View file

@ -0,0 +1,82 @@
import React from "react";
import { Box } from "@chakra-ui/react";
import gql from "graphql-tag";
import {
getVisibleLayers,
petAppearanceFragmentForGetVisibleLayers,
itemAppearanceFragmentForGetVisibleLayers,
} from "./useOutfitAppearance";
function OutfitThumbnail({ petAppearance, itemAppearances, ...props }) {
return (
<Box
as="img"
src={buildOutfitThumbnailUrl(petAppearance, itemAppearances)}
{...props}
/>
);
}
function buildOutfitThumbnailUrl(petAppearance, itemAppearances) {
const size = getOutfitThumbnailRenderSize();
const visibleLayers = getVisibleLayers(petAppearance, itemAppearances);
const layerUrls = visibleLayers.map(
(layer) => layer.svgUrl || layer.imageUrl
);
return `/api/outfitImage?size=${size}&layerUrls=${layerUrls.join(",")}`;
}
/**
* getOutfitThumbnailRenderSize returns the right image size to render at
* 150x150, for the current device.
*
* On high-DPI devices, we'll download a 300x300 image to render at 150x150
* scale. On standard-DPI devices, we'll download a 150x150 image, to save
* bandwidth.
*/
export function getOutfitThumbnailRenderSize() {
if (window.devicePixelRatio > 1) {
return 300;
} else {
return 150;
}
}
// NOTE: The query must include a `$size: LayerImageSize` parameter, probably
// with the return value of `getOutfitThumbnailRenderSize`!
export const outfitThumbnailFragment = gql`
fragment OutfitThumbnailFragment on Outfit {
petAppearance {
id
layers {
id
svgUrl
imageUrl(size: $size)
}
species {
id
name
}
color {
id
name
}
...PetAppearanceForGetVisibleLayers
}
itemAppearances {
id
layers {
id
svgUrl
imageUrl(size: $size)
}
...ItemAppearanceForGetVisibleLayers
}
}
${petAppearanceFragmentForGetVisibleLayers}
${itemAppearanceFragmentForGetVisibleLayers}
`;
export default OutfitThumbnail;