📘

散歩×写真×AI!風景を共有するWebアプリを作った話

2025/03/10に公開

アプリ概要

散歩している際に、撮った風景の写真を共有できるアプリです。
散歩を通じた人との交流や、散歩の目的地づくりに使えます。アップロードされた画像は画像生成AIによって特定の世界観の画像に変換され、他のユーザーに共有されます。

🎯開発の目的

主にこんなことが目的です

  1. Next.jsをはじめとしたモダンなフレームワークを使った開発をしてみる
  2. REST API、Websocket、WebRTC、MLモデル処理を含む様々な技術をアプリケーションに組み込む際の課題の発見と解決
  3. チーム開発とそのアウトプット

リアルとバーチャル的な世界をつなぐようなアプリケーションになればいいな~という思い

📽 画面


🛠 技術スタック

このアプリでは以下の技術を使用しました。

技術 用途
Next.js v15.1.5 フロントエンド、バックエンド
Tailwind CSS スタイリング
Stability AI 画像変換
Google Cloud Storage 画像ストレージ
Cloud run デプロイ環境
Cloud SQL or supabase データベース
PostgreSQL + Prisma データベース管理
Cloud Build CI
NextAuth ユーザー認証、セッション管理
Websocket リアルタイム通信

🔍 技術選定

個人開発ということもあり有名でリファレンスが多そうというのが理由の一つになっていたり。

  • next.js:
    クライアントとバックのAPIの開発をするうえで、どちらもtypescriptで書けるので言語や型の定義を共有することができるまた最近のwebアプリケーションにおける採用例の多さから、web上のリファレンスや参考例が豊富です。
    コンポーネントのSSR(サーバーサイドレンダリング)ができるのでページ表示の高速化ができます。ただ、位置情報に応じた表示など、クライアント側しか動作しないモジュールを使用することになるのでサーバーサイドorクライアントサイドでのレンダリングを分離することを考慮しながら開発することになります。

  • Tailwind CSS
    デザインの自由度と開発の速度がちょうどいいというのと、最近だとllmを使用したコーディングとの相性がいいという話があるらしいです。

  • インフラ
    無料枠で使いやすいものとしてGCPを触ってみました。GCPやAWSだとインフラ構築を一つのプラットフォームに統一しやすいです。
    あと、アプリケーションのパフォーマンスを見る際に、通信量やアクセス数の閲覧がしやすいです。
    VercelなどはNext.jsをデプロイする際に一番お手軽ですが、リアルタイム通信用のカスタムサーバーなど、いろんな拡張をする際にできないことが多い気がします。

Cloud run: サーバーレスのホスティング
Cloud Build: CI/CD
GCS: 画像保管用のストレージ
Cloud SQL or supabase: データの保存先。認証の手軽さから現状はSupabaseを使っています

  • react-leaflet, OpenStreetMap
    無料で利用可能な構成の地図データとその表示のためのコンポーネントです。軽量さもあり、地図とアイコンの描画を行う分には十分。
    Google map APIが代替手段としてあるけど、無料枠のAPIアクセス数の制限や、カスタマイズしたい場合のAPIが有料になりそうでした。住所検索やルート検索、大規模なトラフィックなどがある場合はGoogle map APIが良さそうです。

📂 ディレクトリ構成

プロジェクトの構造を簡単に紹介します。

root/
├── prisma/
├── src/
│   ├── app/
│   │   ├── api/
│   │   │   ├── endpoint/route.ts
│   │   ├── page-name/page.tsx
│   │   ├── page.tsx
│   │   ├── layout.tsx
│   ├── components/
│   │   ├── icon/
│   │   ├── common/
│   │   ├── feature-name/
│   ├── hooks/ 
│   ├── lib/ 
│   ├── schema/
│   │   ├── inputTypeSchema/
│   │   ├── modelSchema/
│   │   ├── outputTypeSchema/
│   ├── types/ 
│   ├── route.ts/
│   ├── middleware.ts  
└── README.md
  • /api: apiのエンドポイントのルーティングをしています。NextAuthの処理の関係上app/以下に置くのが良いみたい
  • /components: クライアントで使用するコンポーネントをおいています。この下はfeatureごとに分けており、全体で使用するものに関しては/commonや/iconなどがあります
  • /hooks: クライアントで使用するカスタムhooksを置く場所です
  • /schema: DBのスキーマをもとにしたzodとその型を置く場所です。クライアントとサーバーの両方から参照されます。
  • /types: DBデータに関係しない型を置く場所です。

あとから機能を付け足していく場合はコンポーネントやAPIの開発をする際にはfeatureごとにディレクトリを作るのが良さそう。加えてクライアントとサーバーそれぞれでロジックを参照する場所を分けました。


プロジェクト全体での方針

プロジェクト全体での基本方針。

App Routerを使ったAPIとクライアントの実装

Next.js 15を使用しているので、App Routerによるアーキテクチャになっています。各機能について、次のような形でAPIのクライアントコンポーネントを作成しています。

route.ts
export async function POST(request: NextRequest){
    try{
        const body = await requese.json()

        // メイン処理
        const output = //

        return NextResponse.json(output, {status:200}   
    }catch(error){
        // エラー処理
    }
client.tsx
const Page = () =>{
    const [data, setData] = useState<DataType>(null)
    const handleFetch = async () => {
        const response = await fetch(<endpoint-url>.
            method: "POST",
            body: body)
    }

    return (
        <div>{data}</div>

DBスキーマとzodによる型生成、バリデーション

zodは型安全な方法でのデータ構造の定義と、データの検証ができます。

import { z } from 'zod'

// 文字列のスキーマの作成
const stringScheme = z.string()

stringScheme.parse("hoge")
// => "hoge"
stringScheme.parse(12)
// => throws ZodError

fetchを型安全に行うために以下のように使うことができます。

import { z } from "zod";

const UserSchema = z.object({
  name: z.string(),
  age: z.number(),
});

const res = await fetch("http://localhost:3001/user/1")
  .then((res) => res.json())
  .then(UserSchema.safeParse); // any → User

if (!res.success) return res.error;

const user = res.data;
//    ^? const user: { name: string; age: number; }

また、prismaとの連携も可能であり定義したDBスキーマをもとにzodobjectと型を生成することができます。
https://zenn.dev/toraco/articles/3eb567b0d716b2
生成したzodオブジェクトはバリデーションに、それをもとに生成した型はデータを扱う際に使用できます。次はImageのスキーマでの例です。

ImageSchema.ts
import { z } from 'zod';

/////////////////////////////////////////
// IMAGE SCHEMA
/////////////////////////////////////////

export const ImageSchema = z.object({
  id: z.string().uuid(),
  userId: z.string(),
  fileName: z.string(),
  originalUrl: z.string(),
  generatedUrl: z.string(),
  expiration: z.coerce.date().nullable(),
  latitude: z.number(),
  longitude: z.number(),
  description: z.string().nullable(),
  prompt: z.string().nullable(),
  tag: z.string(),
  created_at: z.coerce.date(),
  updated_at: z.coerce.date(),
})

export type Image = z.infer<typeof ImageSchema>

export default ImageSchema;

書式設定

🔥 機能一覧 & 実装のポイント

具体的な機能はこんなものです

  • ユーザー登録と認証: emailとパスワードを使った認証を行います。
  • 写真の投稿とそれをもとにした画像生成
  • マップ画面(メインページ): 地図上で生成した画像たちが見れる
  • ギャラリー画面: 画像だけ見れる。新規、いいね数順、ランダム、近くのものでのソート
  • 設定画面:
  • 通知機能: 画像処理の完了や他のユーザーの投稿時に通知を行う

✅ 画像の生成と投稿

  1. クライアントでの画像ファイルと位置情報のアップロード→ 送信後メインページに遷移
  2. もとの画像データのGCSへのアップロード
  3. 画像をもとにしたimg2imgによる画像生成
  4. 生成した画像のGCSへのアップロード
  5. DBへの登録
  6. 他のユーザーへの投稿通知&処理の完了通知
    といった処理のフローになっています。

✅ 地図上への画像の描画

  • 地図の描画
  • インタラクションと画像取得
  • アイコンによる画像表示
    の3つ!

まずは地図の表示です

const GeneralMap = () => {
    const [show, setShow] = useState(false);
    const [selectedImage, setSelectedImage] = useState<ImageOutput | null>(null);
    // 初期位置、ズームの定義
    const [zoom, setZoom] = useState<number>(13);
    const [position, setPosition] = useState<LatLngTuple>([35.681236, 139.767125]);
    const [images, setImages] = useState<ImageOutput[]>([]);

    const {location, error} = useGeoLocation();

    const handleReset = () =>{
      setPosition([35.681236, 139.767125]);
  }

    useEffect(() => {
        if (navigator.geolocation) {
            console.log("location found");
          navigator.geolocation.getCurrentPosition((pos) => {
            setPosition([pos.coords.latitude, pos.coords.longitude]);
          });
        }else{
            console.log("location not found");
        }
    }, []);

    return (<>
        <div className="w-full h-screen z-0">
            <MapContainer key={`${location.latitude}`} center={[location.latitude, location.longitude]} zoom={zoom} className="w-full h-full z-0">      
                <MapCompoent setImages={setImages}/>
                <TileLayer     url="https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png"
                          attribution='&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>'
                          /> 
                {images.map((image: ImageOutput) => (
                  <ImageMarker key={image.id} image={image} setShow={setShow} setSelectedImage={setSelectedImage}/>
                ))}
                  
                  <UserMarker position={[location.latitude, location.longitude]} />

                <CommonModal
                    isOpen={show}
                    closeModal={() => setShow(false)}
                    elem={
                        selectedImage ? <Detail
                       image={selectedImage}
                    /> : <></>
                        
                    }
                    /> 
            </MapContainer>
        </div>
                    </>
    );
};

次にインタラクションとデータの取得です。react-leafletにあるイベントを使うためのhookであるuseMapEventsを使用し、ズームやパンのインタラクションがあった際に、画面内の緯度経度範囲を取得し、データをリクエストします。

const MapCompoent = ({setImages}: { setImages: React.Dispatch<React.SetStateAction<ImageOutput[]>> }) => {
  
    const fetchImage = async () => {
      try{
        const latlangBounds = map.getBounds();
        const minLatitude = latlangBounds.getSouthWest().lat;
        const maxLatitude = latlangBounds.getNorthEast().lat;
        const minLongitude = latlangBounds.getSouthWest().lng;
        const maxLongitude = latlangBounds.getNorthEast().lng;
        const zoom = map.getZoom();
        fetch('/api/map/getSquareMapImage', {
          method: 'POST',
          headers: {
            'Content-Type': 'application/json',
          },
          body: JSON.stringify({
            minLatitude,
            maxLatitude,
            minLongitude,
            maxLongitude,
            zoom,
          }),
        })
          .then((res) => res.json())
          .then((data) => {
            console.log(data);
            setImages(data);
          });
      }catch(error){
        console.log(error);

      } 
    }

    const map  = useMapEvents({
      click: () => {
        map.locate()
        // get lat and long of square
        const latlangBounds = map.getBounds();
      },
      loading: () => {
        fetchImage();
      },
      dragend: () => {
        fetchImage();
      },
      zoomend: () => {
        fetchImage();      
      }    
    })

仕組みとしてはReactのContextを使用してLeaflet Mapインスタンスを作成し、子コンポーネント内で使用されるモジュールが状態を参照できるようにしているみたいです。
https://react-leaflet.js.org/docs/api-map/

最後に画像のアイコン表示です
react-leafletで提供されているMarkerをカスタムすることで表示を行います。
https://react-leaflet.js.org/docs/example-popup-marker/

<Marker position={[image.latitude, image.longitude]} icon={customIcon} />

✅ 画像変換

風景写真とプロンプトを使ったimg2imgモデルによる画像の生成を行います。

✅ NextAuthによるユーザー認証とセッション管理

Next.jsでユーザー認証をするということでNextAuth.jsを使ってみました。
emailとパスワードによるクレデンシャルの認証dす。ユーザーIDなどのセッション情報を保持し、処理で必要な際に取得できるようにしています。

セッションの型の拡張

lib/options.ts
declare module "next-auth" {
  interface Session extends DefaultSession{
    user: {
      id: string
      role: string
      email: string
    }
  }
}
declare module "next-auth/jwt" {
  interface JWT {
    id: string
    role: string
  }
}
...

次にNextAuthのオプションを定義します

const options: NextAuthOptions = {
  providers: [
  ...
    Credentials({
      id: 'credentials',
      name: 'Credentials',
      credentials: {
        email: {
          label: 'Email',
          type: 'text',
        },
        password: {
          label: 'Password',
          type: 'password',
        },
      },
      async authorize(credentials) {
       // 登録時の処理
    }),
  ],
  pages: {
    signIn: '/auth',
  },
  debug: process.env.NODE_ENV === 'development',
  adapter: PrismaAdapter(prismadb),
  session: {
    strategy: 'jwt',
  },
  jwt: {
    secret: process.env.NEXTAUTH_JWT_SECRET,
  },
  secret: process.env.NEXTAUTH_SECRET,
    /
  callbacks: {
    async jwt({ token, user, account, profile }) {
      if (user) {
        token.user = user
        // const u = user as any
        token.id = user.id
        token.role = "admin" // u.role
      }
      if (account) {
        token.accessToken = account.access_token
      }
      console.log("token", token)

      return token
    },
// セッションに関する情報の拡張
    session: ({ session, token, user}) => {
      return {
        ...session,
        user: {
          ...session.user,
          role: token.role,
          id: token.id,
          
        },
      }
    },
  },
}

クライアントからは次のコードで取得

import { useSession } from "next-auth/react";
const {data: session, status} = useSession();

サーバー側からは次のコードで取得

import { getServerSession } from "next-auth";
const session = await getServerSession();

✅ Optimistic Update

✅ websocketを使った通知機能の実装

画像生成に時間がかかるのでサーバーからの完了のレスポンスを受け取る前に画面更新をします。その際に処理が完了して地図上に新しく画像が追加された際にユーザーに知らせるのが良いと思い通知機能を追加しました。
詳しくはこちら
https://zenn.dev/wbcat/articles/4ce4d8efbc3433

✅ コンポーネント表示などのアニメーション


🏗 課題と今後の改善点

💡 現在の課題

  • 画像生成に時間がかかる → 画像生成用のサーバーの作成
  • アプリケーションの双方向性が足りない → 時間や位置座標によって変化する要素

Future Work...

💡 画像生成サーバーの作成

💡 負荷テスト

💡 ジオフェンシングによる地域ごとのイベント設定

💡 WebRTCを使用した音のストリーミング

💡 いいね機能、コメント機能の実装

  • 音声ガイド → 音声による散歩ルート提案

🎉 まとめ

リポジトリはこちら
https://github.com/ho-hokekyo/map-app

アプリはこちらからアクセスできます!(モバイルでのアクセス推奨)

Discussion