【個人開発】オリジナルのスタンプを作成、収集できるデジタルスタンプラリーサービスをつくりました
Intoro
初めまして、Zenn 初投稿です。今回、Minsta という少し変わったデジタルスタンプラリーサービスを個人開発で制作したので宣伝も込めて記事を作成しました。是非記事を読んで実際に触ってみてもらえると嬉しいです!
以下のリンクからアクセスできます👇
どんなサービス?
Minsta は画像から生成したオリジナルのスタンプを地図上に作成したり、近くにある誰かが作ったスタンプを押して集めることができる位置情報を使用したデジタルスタンプラリーサービスです。
既存のデジタルスタンプラリーサービスは、サービス提供者があらかじめ地図上にスタンプを配置してそれをユーザーが集めるという形が一般的ですが、Minsta ではユーザー自身がスタンプを作成できることで、スタンプを押して集める楽しさだけでなく、作成したスタンプが誰かに押されるという2つの楽しさを味わえるというコンセプトになっています。
スタンプを押す | スタンプを作る |
---|---|
主な画面と機能
ホーム画面 | スタンプ詳細画面 | スタンプ作成画面 |
---|---|---|
地図とスタンプの位置を表示 | スタンプの詳細を表示、スタンプを押す | スタンプ画像の編集、スタンプの作成 |
探索画面 (新着) | 探索画面 (ランキング) | 探索画面 (周辺) |
---|---|---|
作成日時が新しい順に表示 | 押された回数が多い順に表示 | 現在地周辺にあるスタンプを近い順に表示 |
ユーザー詳細画面 | ユーザースタンプ詳細画面 | 設定画面 |
---|---|---|
押したスタンプ、作成したスタンプを表示 | スタンプの詳細を表示、スタンプの削除 | ユーザー情報の更新、アカウントの削除 |
技術スタック
フロントエンドは純粋な React のみを使用した SPA で、バックエンドには Firebase を採用しました。今回はサービスを作り切ることが一番の目的だったので、使用している技術に関しては特に挑戦的な要素はなく、なるべく自分が触ったことがあるもの又は既に情報が出回っているような有名な技術要素を採用するようにしました。
- 開発言語: TypeScript
- ビルド周り: Vite
- UIライブラリ: React
- スタイル: Tailwind CSS
- バックエンド: Firebase
- その他のUI系ライブラリ
- ルーティング: React Router
- トースト: React Hot Toast
- カラーピッカー: React Color
- 画像切り抜き: react-easy-crop
- 無限スクロール: react-infinite-scroller
- SNSシェアボタン: react-share
- 地図・位置情報系ライブラリ
- 地図表示: mapbox-gl-js, react-map-gl
- ピンのクラスター化: supercluster, use-supercluster
- ジオクエリ生成: geofire-js
- 距離の計算等: turf.js
デザイン
デザインはあまり時間をかけたくなかったので最初に iPad の GoodNotes というノートアプリでざっくりとした画面イメージだけ作り、あとは実際に動くものを作りながら調整していく形で進めました。結果的に当初のデザインの面影を残しつつ、いい感じにブラッシュアップできたような気がします。
最初に書いてた絵
サービスロゴやアイコンのデザインは iPad の Vectornator というベクター画像が作れるアプリで作成しました。あまり凝ったことはできないので、Minsta の M とスタンプのアイコンを重ねたデザインにしました。素人が考えた割にはアイコンとしての収まりも良く、意外と気に入っています笑。
スタンプ画像生成
このサービスの核ともいえるスタンプ画像を生成する部分ですが、やっていることはシンプルで、読み込んだ画像を Canvas に描画してピクセルごとの輝度が閾値より高いか低いかで 2 値化して再度画像化しています。2 値化する際の閾値調整と白黒反転、色選択をできるようにして枠線をつけることでスタンプらしい画像を生成できるようにしています。
PWA
Minsta では PWA 対応しました。ホーム画面に追加すると、起動時にネイティブアプリのようにスプラッシュスクリーンが出て Web ページが全画面で表示されます。ブラウザの URL バーやメニューも表示されないので、ほとんどネイティブアプリと見分けがつかないです。PWA 化自体は Vite のプラグインを使用することで比較的簡単にできるのですが、画面サイズごとのスプラッシュスクリーンの作成やアプリアイコンの用意、SafeArea の考慮などプラットフォームごとの細かい対応がめんどくさかったです。。
また、ServiceWorker を使用してアセットファイルやスタンプ画像等をブラウザにキャッシュさせており、Firestore 側もオフラインの永続性を有効化しているため、一時的なオフライン状態でもある程度動くようになっています。
地図・位置情報
地図
開発初期は OpenStreetMap.org のタイルサーバと Leaflet というライブラリを使用して地図を表示していましたが、利用規約上 OpenStreetMap.org のタイルサーバに高い負荷をかけるのは禁止されているのと、地図のデザインが気に入らなかったので途中から MapBox に切り替えました。MapBox も OpenStreatMap ベースなのですが、地図のデザインのカスタマイズも可能で(現状デフォルトのまま使ってますが)、GoogleMap より無料で使用できる枠が多かったので採用しました。
ジオクエリ
Firestore では各ドキュメントに Geohash という値を持たせておくことで、座標を使用してドキュメントをクエリ (ある範囲内に存在するドキュメントを取得するなど) できます。元々、地図上にスタンプを表示するために Geohash を導入したのですが結局そこではうまく使えず、実装を消すのは勿体無かったので探索画面に周辺のスタンプを探す機能を追加し、そこで Geohash を使ったジオクエリを行なっています。
逆ジオコーディング
スタンプ詳細画面などでスタンプが作成された住所を表示させていますが、この時、逆ジオコーディングする (座標から住所を割り出す) 必要があり、本サービスでは Nominatim API という WebAPI を使用しています。
https://nominatim.openstreetmap.org/reverse?format=json&accept-language=ja&zoom=14&lat=${lat}&lon=${lng}`;
クラスター化
地図上にスタンプ画像を全て表示すると、数が多い場合 UI 的にとても見づらくなるのと描画のパフォーマンスにも影響するので、隣接するスタンプはクラスター化して表示させています。クラスター化には supercluster というライブラリ (実際には React 用の use-supercluster というカスタムフックのライブラリ) を使用し、GeoJson 形式のデータと現在の表示エリアや表示倍率を渡して良い感じにクラスター化してくれたものを表示しています。
Firebase アーキテクチャ
Firestore
Firestore のデータ構造は以下のようになっており、スタンプ情報をもつ stamp とユーザー情報をもつ user ドキュメントの他に、地図上にスタンプを表示するための mapStamp という単一の配列のみを持ったドキュメントを管理しています。地図上にスタンプを表示するために Firestore から今ある stamp ドキュメントを全部取ってくるようなことをすると、スタンプの数が増えた分だけ読み取り回数も増えてすぐに破産💸するので、stamp ドキュメントとは別に全スタンプの座標等の情報をもつ mapStamp ドキュメントを導入し、常に 1 つのドキュメントを取得するだけで地図上に全てのスタンプが表示できるようにしました。
~/
├── stamps
│ ├── {stamp_id}
│ │ ├── name: スタンプ名
│ │ ├── uid: 作成者のユーザーID
│ │ ├── pos: 座標
│ │ ├── address: 住所
│ │ ├── geohash: ジオハッシュ
│ │ ├── createdAt: 作成日時
│ │ └── stampedCount: 押された回数
│ ...
├── users
│ ├── {user_id}
│ │ ├── name: ユーザー名
│ │ ├── iconUrl: プロフィール画像のURL
│ │ ├── stampedList[]: 押したスタンプリスト
│ │ │ ├── sid: スタンプID
│ │ │ ├── uid: 作成者のユーザーID
│ │ │ └── date: 押した日時
│ │ └── createdList[]: 作成したスタンプリスト
│ │ ├── sid: スタンプID
│ │ ├── uid: 作成者のユーザーID
│ │ └── date: 作成時日時
│ ...
└── mapStamps
└── {mapStamp_id}
└── list[]: マップ上のスタンプリスト
├── sid: スタンプID
├── uid: 作成者のユーザーID
├── date: 作成日時
└── pos: 座標
Cloud Storage
画像は以下のような構造で Cloud Storage に格納しています。今回の場合、Cloud Storage で管理するリソースは全て特定のユーザーに紐づくので user_id ごとに管理しておくと、セキュリティルールとしても表現しやすく、ユーザーがアカウント削除したときにそのユーザーの user_id フォルダごとまるっと消すだけでリソースの削除が終わるので良かったです。
~/
├── users
│ ├── {user_id}
│ │ ├── icons/ プロフィール画像
│ │ ├── stamps/ スタンプ画像
│ │ └── ogps/ OGP画像
│ ...
...
また画像自体は public で公開しておき、以下のように user_id や stamp_id を置き換えてパーマネントリンクでアクセスできるようにしています。
https://firebasestorage.googleapis.com/v0/b/${bucket_name}/o/users%2F${user_id}%2Fstamps%2F${stamp_id}.png?alt=media
Cloud Functions
クライアントから直接 Firestore や Cloud Storage を操作したくない場合や、動的 OGP に対応するために Cloud Fuctions を使用しました。以下、今回実装した Cloud Fuctions を入れた処理フローです。
スタンプ作成時
stamp ドキュメントが追加される度に Cloud Functions が trigger され、前述した mapStamps を更新することで地図上に作成したスタンプが表示されます。また、Web アプリ側でスタンプ画像と Minsta のサービスロゴを合成して OGP 画像を生成し、スタンプ画像と一緒に Cloud Storage にアップロードするようにしています。
スタンプ削除時
stamp ドキュメントが削除された時は、Cloud Functions によって mapStamps から対象のスタンプのデータが削除され、地図上からもスタンプが削除されます。この時、対象のスタンプ画像や OGP 画像も Cloud Functions によって削除するようにしています。
ユーザー削除時
ユーザーがアカウント削除を行なった場合、そのユーザーに関するすべてのデータを削除する必要があります。Web アプリから Firebase Authentication へのユーザー削除実行を trigger として Cloud Functions を実行し、Firestore と Cloud Storage 上のそのユーザーに関するデータをすべて削除するようにしています。
動的 OGP
SNS 等にスタンプをシェアしたときに、OGP としてそのスタンプの名前や画像を表示させたいのですが、クローラーは js を実行しないので SPA だと動的に OGP を変えることはできません。そこで Firebase Hosting の rewrites という機能を使用して Cloud Functions を呼び出すことで OGP 用の meta タグを動的に更新しています。
OGP の表示はこんな感じ👇
さいごに
まだ色々手を入れたい部分はありますが、とりあえずサービスとして公開できる形まで完走できてよかったです。開発を振り返ると、ある程度定番な技術スタックを使っているのもあり、ネット上に情報も多く、技術的には大きくつまづくことなく進められたように思います。(どちらかというと開発のモチベーション維持の方が大変でした。。) また、今回作ったサービスは地図上に位置情報に紐づくコンテンツを作成したり閲覧するという点で汎用性はあると思うので、Minsta をベースに地図と位置情報を使った別のサービスを作ったりしたいなとかも考えています。
最後に、Minsta はログインしなくてもスタンプを閲覧したり試しに押すことはできるようになっているので、ぜひ触ってみてもらえると嬉しいです!ログインも Google アカウントがあればできるので、スタンプ作成もやってみてもらえると作者が泣いて喜びます🙇♂🙇♂
ここまで読んでいただきありがとうございました!
Discussion
初めまして!
スランプラリーのサイトとのことで、発想が斬新でとても楽しく使わせていただきました。
私が運営しているサイトで、皆さんの個人開発のプロダクトを投稿してもらいレビューを集めるお手伝いをしています。ご興味あればぜひ投稿していただきたいです!