nisshiee.org

TOP画像をランダムに表示してみた

2022-06-28

これまで本サイト「nisshiee.org」のTOPページは↓の写真を飾ってきた。

この画像に意味は何も無く、

  • TOPページって何も書くもの無いな?
  • 何も無いのは見た目が良くないので、画像でも置くか
  • 当時、カメラには興味無かったし、置きたい画像も無かった
  • スマホの写真を眺めてたらいつ撮ったかわからない東京タワーの写真があり、とても無難だった
  • 採用

という経緯で設置された。ついにこの度、御役御免となったので、ここに供養しよう。

数年が経ち・・・

先日ブログに書いたように、ブログシステムを刷新した。

この作業を通じて、当然ながらサイト全体を何度も見たわけだが、そのときにどうしてもこの、意味は無いけど一等地を占拠している写真が気になってしまった。折角今は良いカメラ買って写真もたくさん撮っているのだから、お気に入りの写真にしたいよね、と。

しかし問題は、とにかくたくさん写真を撮っていること。1枚だけTOPページを飾る写真を選べと言われても、難しかった。

そこで今回は、

  • 複数の写真を溜めたプールからランダムに表示する
  • TOPページを開いたままにしておいたら、一定間隔で写真を変更する

という仕様にすることにした。今ならこのサイトはnext.jsで動いているので、クライアントサイドで画像をランダムに入れ換えるのはさほど難しくないだろうという想定だ。

ざっくり設計

  • 写真はAWS Consoleまたは適当なS3 Clientから特定のS3バケットにアップロードするだけ
    • サイズは合わせておく
    • S3はウェブサイトホスティングする
  • ビルド時にS3のAPIから画像一覧を取得しておく
    • 一覧情報はgetStaticPropsの結果としてPropsに渡る
    • revalidateを設定しておけば、勝手に更新される
  • ClientSideで一覧からランダムに画像を選び、nextのremote image optimizationになるようにnext/imageで表示
    • 一応トランジションにも対応しておく

この設計は以下のような意図がある。

  • 画像の数は100ぐらいまで想定する
    • カメラ趣味が続いてるうちは無限に素材がある
    • とはいえ、多すぎても表示されないだけ
    • vercel無料プランの画像ソース数limitは1000なので、枠を逼迫させない範囲
  • 画像はGit Commitしたくない
  • かといって画像配信のために重厚な仕組みの用意はしたくない
  • 写真は撮り続けているので、簡単に追加できるようにしたい
    • やることはアップロードだけが良い
    • next/imageに頼ることで、最適化すら自分でやらなくて良いとベスト
  • あくまでTOPページなので、写真以外のナビゲーションは瞬時に表示されて欲しい
    • SSGできること

できあがったもの

TOPページはもう反映済みなので見て欲しい(この記事を公開したタイミングでは写真3枚しか置いてないかも)。

作ってみて、やっぱりなんだかんだ言ってもクライアントサイドの処理がややこしかった。Imageタグ3つをposition: absoluteで重ねていて、

  • 上: フェードアウトして消える画像
  • 中: 今表示している画像
  • 下: 予めレンダリングしておくことで画像ロードしておき、ロード前に表に出ないようにする

という仕組みになっている。

写真表示のコンポーネントはこんな感じ(しばらく様子見ながらチューニングする予定なので、このコードは初代バージョンだと思って)。

import styled from "@emotion/styled";
import Image from "next/image";
import { FC, useEffect, useState } from "react";
import { shufflePhotos, TopPhoto } from "../lib/top-photo";

type Props = {
  photos: TopPhoto[];
};

type State = {
  fading: boolean;
  currentPhotoIdx: number;
};

const TopPhotoViewer: FC<Props> = (props) => {
  const [shuffled] = useState(shufflePhotos(props.photos));
  const [state, setState] = useState<State>({
    fading: false,
    currentPhotoIdx: 0,
  });
  useEffect(() => {
    const update = () => {
      const currentPhotoIdx = (state.currentPhotoIdx + 1) % shuffled.length;
      setState({
        fading: true,
        currentPhotoIdx,
      });
    };
    const handler = setTimeout(update, 10000);
    return () => {
      clearTimeout(handler);
    };
  }, [state.currentPhotoIdx]);

  const fading =
    shuffled[(state.currentPhotoIdx - 1 + shuffled.length) % shuffled.length];
  const current = shuffled[state.currentPhotoIdx];
  const loading = shuffled[(state.currentPhotoIdx + 1) % shuffled.length];

  return (
    <Wrapper>
      <Image
        key={loading.key}
        src={loading.url}
        width="1080"
        height="1080"
        className="loading"
        style={{ zIndex: 1 }}
      />
      <Image
        key={current.key}
        src={current.url}
        width="1080"
        height="1080"
        style={{ zIndex: 2 }}
        className="current"
      />
      {state.fading && (
        <Image
          key={fading.key}
          src={fading.url}
          width="1080"
          height="1080"
          style={{ zIndex: 3 }}
          className="fading"
        />
      )}
    </Wrapper>
  );
};

export default TopPhotoViewer;

const Wrapper = styled.div`
  width: 100%;
  aspect-ratio: 1;
  max-height: 1080px;
  position: relative;

  & > * {
    position: absolute !important;
    top: 0;
    left: 0;
  }

  & .current {
    opacity: 1;
  }

  & .fading {
    animation-name: top-photo-fadeing;
    animation-duration: 2s;
    animation-fill-mode: forwards;
    opacity: 1;
  }

  @keyframes top-photo-fadeing {
    from {
      opacity: 1;
    }
    to {
      opacity: 0;
    }
  }

  & .loading {
    animation-name: top-photo-loading;
    animation-delay: 2s;
    animation-duration: 0s;
    animation-fill-mode: forwards;
    opacity: 0;
  }

  @keyframes top-photo-loading {
    from {
      opacity: 0;
    }
    to {
      opacity: 1;
    }
  }
`;