LoginSignup
57
41

【個人開発 / PictoMemory】旅の思い出の投稿・地図の色塗り・旅先探しができるアプリを作りました

Last updated at Posted at 2024-03-02

はじめに

エンジニア目指して学習中のさば🐟(@saba7678pg)と申します。
2023年6月からエンジニア転職するためにプログラミングスクールで学習を行っております。

これまでの学習の成果として「PictoMemory」という旅の思い出投稿アプリを作成致しました。
アプリの紹介、使用技術を紹介させていただきます。

万が一誤り・ご意見・ご指摘事項等ございましたら、
コメントやX宛にDM頂けますと幸いです。

アプリ概要

サービスURL:https://pictomemory.com
サービスリポジトリ:https://github.com/SabaCrevette/travel_app

Image from Gyazo

旅の思い出を投稿する、CGM型のサービスです。
投稿した都道府県に紐づいて、真っ白な日本地図が彩られていきます。

  • 「47都道府県制覇したい」
  • 「旅の記録をつけたい、思い出を振り返りたい」
  • 「他の人の思い出を見たい、色んな観光地を探したい」

そんな旅人をターゲットにしたWebサービスです。

開発した背景

私自身旅行が好きで、いつか47都道府県を制覇するぞ!という野望を抱えています。
色々な旅先を訪れて、絶景を味わったり、温泉に浸かったり、美味しいものを食べたり。
日常では得られない体験をたくさんしたいなと思っています。

旅が終わると、思い出、という形で記憶に残ると思うのですが、
それをもっと視覚的に、形に残せればなと考えておりました。

スマホに眠っている旅の思い出写真から厳選した数枚を投稿していただき、
旅をした痕跡を彩りながら日本地図を埋めていく。
自分がどの都道府県にどれだけ訪れたんだろう、まだ訪れていない地はどこだろう。
そんな旅人のお供になるようなアプリが作りたいなと思い今回の企画に至りました。

設計について

使用技術

使用技術
フロントエンド TailwindCSS(+daisyUI)
JavaScript
Hotwire(Turbo / Stimulus)
バックエンド Ruby 3.2.2
Ruby on Rails 7.1.2
データベース PostgreSQL
インフラ Heroku
AmazonS3
API GoogleGeocodingAPI
Maps JavaScript API
その他 docker

ER図

Image from Gyazo

実装した機能紹介やこだわり

TOPページのレイアウト

Image from Gyazo

TOPページ1画面でどんなことができるサイトなのかコンパクトに収めました。

日本地図の彩りアニメーション レスポンシブデザイン対応
Image from Gyazo Image from Gyazo

地味ですが、TOPページの日本地図はランダムに色が変わるように設計してあります。
JavaScriptを用いて、SVGファイルの各path毎(都道府県毎)に対して3秒〜10秒の間隔で、
top-count-0~3のCSSをランダムで付与する、という処理を行なっています。

Webアプリの特徴を象徴する機能になったかなと考えております。

コード詳細
.top-count-0  { fill: #FFFFFF; stroke: #C5EDDD; }
.top-count-1  { fill: #FCF1D3; }
.top-count-2  { fill: #FFFBD5; }
.top-count-3  { fill: #C8EFD3; }
  setRandomInterval(path) {
    const changeClass = () => {
      // 以前のクラスを削除
      for (let i = 0; i <= 3; i++) {
        path.classList.remove('top-count-' + i);
      }

      // ランダムなクラスを追加
      const randomClass = 'top-count-' + Math.floor(Math.random() * 4);
      path.classList.add(randomClass);

      // 次のクラス変更までのランダムな待機時間を設定
      setTimeout(changeClass, Math.random() * 7000 + 3000);
    };

    // 初回の実行
    changeClass();

また、私のアプリの特性上、スマートフォンからの使用が想定されましたので、
MVPリリース時点からスマホでも快適に使っていただけるように設計いたしました。

マイページ 投稿詳細
Image from Gyazo Image from Gyazo

モバイルファーストを掲げるTailwindCSSを使用しております。 
また、検索ウィンドウの開閉やタグ、ボタンはレイアウト統一、分かりやすさのためにdaisyUIを用いました。

sorceryによる一般的なログイン機能とGoogleログイン

私のアプリでは2種類のログイン方法を採用しています。

Image from Gyazo

既にEmail,Password登録を行なっているユーザーでも、
同じGmailを持つGoogleアカウントでログインを行えば、データの紐付けが行えるように調整してあります。

Googleログインで少し困った時の話

MVPリリース時点ではGoogleログインを実装していなかったので、
sorceryによるEmailとPasswordによるアカウント登録をしていただきました。

本リリースでGoogleログインを実装しようとしたのですが、
既にEmail登録をしているユーザーが、同じGmailを持つGoogleアカウントでログインを試みようとすると、
「Emailが存在している」というバリデーションに引っかかってしまい登録・ログインができない、という状態になっておりました。

このままだとGoogleログインを使っていただけない、Emailのバリデーションを外すわけにはいかないし、
それだと今まで投稿していただいたデータと別のアカウントを作ることになってしまう…

ということでsorceryのwikiから具体的なメソッドを解読し、紐付けする方法を探りました。

Googleログインのサブモジュール内には

  • provider Google、uuidという情報が関連づけられて保存される
  • Emailを探索し、存在しなければ新たに登録処理を行う

という処理があるようです。

これを利用し、新しく登録するユーザーは通常通りGoogleログイン・アカウント登録の処理を行い、
既に登録しているユーザーに対しては以下の処理を行うようにControllerを修正しました。

  • Googleログインを試みたEmailを探索
  • Userテーブルに同じEmailを見つけた場合は取得した情報(priveder googleとuuid)を関連づけて保存する
# 一部抜粋

    @user = login_from(provider)
    unless @user
      # プロバイダ情報の取得
      sorcery_fetch_user_hash(provider)
      # 既存のユーザーを探す
      @user = User.find_by(email: @user_hash[:user_info]['email'])

      if @user
        # 既存のユーザーにプロバイダ情報を追加
        @user.add_provider_to_user(provider, @user_hash[:uid].to_s)
      else
        # 新しいユーザーを作成
        @user = create_from(provider)
      end

これによりGoogleログインを試みた際に自動的に既存ユーザーとの関連付けが行えるようになりました。

その他、ActionMailerを用いたパスワードリセット機能も実装しております。

パスワードリセット パスワードリセット用のURL送付
Image from Gyazo Image from Gyazo

自身の投稿一覧ページ

投稿した都道府県に応じて、マイページの日本地図が彩られます。

Image from Gyazo

色味を変更するための処理

「UserPrefecturesテーブル」
このテーブルはuser_idとprefecture_id、post_countカラムを持ち、
ユーザー毎の各投稿を都道府県毎にカウントした値をpost_countに保存しています。
また、編集・削除した際もpost_countが変動するよう処理を加えています。

  # 以前と新しい都道府県のカウントを調整
  def adjust_prefecture_post_count
    # 以前の都道府県を取得し、カウントを減らす
    old_prefecture = UserPrefecture.find_by(user:, prefecture_id: prefecture_id_was)
    old_prefecture.decrement!(:post_count) if old_prefecture.present?

    # 新しい都道府県を取得し、カウントを増やす
    new_prefecture = UserPrefecture.find_or_create_by(user:, prefecture_id:)
    new_prefecture.increment!(:post_count)
  end

after_create :increment_user_prefecture_post_count
after_destroy :decrement_user_prefecture_post_count
before_update :adjust_prefecture_post_count, if: :prefecture_changed?

  def increment_user_prefecture_post_count
    user_prefecture = UserPrefecture.find_or_create_by(user:, prefecture:)
    user_prefecture.increment!(:post_count)
  end

  def decrement_user_prefecture_post_count
    user_prefecture = UserPrefecture.find_by(user:, prefecture:)
    user_prefecture.decrement!(:post_count) if user_prefecture.present?
  end

投稿データのprefectureの値に変化があった際に上記の処理が行われます。

レコメンド機能

ユーザーの嗜好に基づいて、投稿一覧から3パターンの関連投稿を表示します。
ヘルプボタンを押していただくと、「レコメンドの基準」と「プロフィールへのリンク」がモーダル表示されます。

ユーザーへのオススメ レコメンドヘルプボタン
Image from Gyazo Image from Gyazo
レコメンドロジック

①あなたの投稿したタグに基づいた関連投稿(RecommendPostQuery)
②プロフィールに登録された都道府県近隣の投稿(RecommendPrefectureQuery)
③プロフィールに登録された旅の嗜好に基づく関連投稿(RecommendCategoryQuery)

該当の投稿取得ロジックはqueriesディレクトリ(Queryオブジェクト)として切り分けています。

①RecommendPostQuery

「ユーザーが投稿したタグが所属するCategoryに一致する投稿一覧を取得し、
その中から1件ピックアップする」、というロジックです。
自身の投稿や非公開の投稿は除外されます。

例:食べ物の投稿をしている場合は、食べ物が所属するカテゴリ「食べ物・グルメ」に所属するタグのついた投稿から1件ピックアップされます。

②RecommendPrefectureQuery

「ユーザーがプロフィールで設定した出身都道府県周辺の投稿」を取得します。

例:神奈川県に登録→関東圏の投稿から1件ピックアップされます。

③RecommendCategoryQuery

「ユーザーがプロフィールで設定した旅行の醍醐味(カテゴリー)に一致するタグを持つ投稿」を1件取得します。

全ユーザーの投稿一覧 / 検索ページ

このページでユーザーの投稿一覧を閲覧、絞り込み検索ができます。

投稿一覧〜検索タブの開閉 検索実行後の画面
Image from Gyazo Image from Gyazo

【検索項目】
・都道府県検索
・エリア検索(都道府県を選択した場合のみ、例:茨城県を選んだ場合、茨城・県央、茨城・県北等から絞り込み)
・観光地名検索
・本文検索
・タグ検索
・通年開催の観光地・イベントへの絞り込み
・未踏破の都道府県への絞り込み

ransackをベースに一部調整を加えた検索ロジックとなっています。
旅先を探すツールとしても運用したかったので、
自分が投稿していない都道府県に絞り込んでの検索もできるようにしています。

また、post_countを利用して、自身の色塗りと全ユーザーの投稿を合算した色塗り状況を見ることができる要素を配置しています。

検索関連のコード
def load_posts
    @q = Post.ransack(params[:q])
    @posts = @q.result
    @posts = @posts.with_tag(params[:tag_name]) if params[:tag_name].present?
    @posts = @posts.exclude_unvisited_prefectures(current_user) if params[:exclude_unvisited_prefectures] == 'true'
    @posts = @posts.by_status(current_user)
    @posts = @posts.order(created_at: :desc).page(params[:page]) # ページネーション
end
    <!-- 未踏破の都道府県:Ransackの検索条件にはない独自のチェックボックス -->
    <% if logged_in? %>
      <div>
      <%= check_box_tag :exclude_unvisited_prefectures, "true", params[:exclude_unvisited_prefectures] == "true", class: "checkbox checkbox-primary" %>
      <%= label_tag :exclude_unvisited_prefectures, '未踏破の都道府県に絞り込む', class: "ml-2 text-sm text-gray-800 sm:text-base" %>
      </div>
    <% end %>
みんなの色塗りとユーザー個人の色塗り切り替え関連コード
<!-- 自身の投稿とみんなの投稿切り替え -->
<% if logged_in? %>
    <label class="label cursor-pointer justify-end">
      色塗り切り替え
      <span class="mr-2"></span>
      <input type="checkbox" class="toggle" id="toggle-switch" data-action="change->toggle#switch">
    </label>
<% end %>
<!-- 色塗りマップ -->
<turbo-frame id="japan_map">
    <%= render 'users/japanmap', user_prefectures: @user_prefectures, text: @text %>
</turbo-frame>
document.addEventListener('turbo:load', () => {
  document.getElementById('toggle-switch').addEventListener('change', (event) => {
    let isChecked = event.target.checked;
    let frame = document.getElementById('japan_map');

    fetch(`/posts/toggle_partial?checked=${isChecked}`)
      .then(response => response.text())
      .then(html => frame.innerHTML = html);
  });
});

また、フリーワード検索にはStimulusを用いたオートコンプリート機能を実装しております。

エリア検索 オートコンプリート
Image from Gyazo Image from Gyazo
オートコンプリート関連コード

Stimulusを用いたオートコンプリート機能です。

import { Autocomplete } from "stimulus-autocomplete"
application.register("autocomplete", Autocomplete)

stimulus-autocompleteライブラリからAutocompleteクラスをインポートし。
HTMLにdata-controller="autocomplete属性を持つ要素にオートコンプリート機能を付与。

  def autocomplete_location
    @locations = Post.select('DISTINCT location').where('location LIKE ?', "%#{params[:q]}%").limit(5)
    render partial: 'posts/autocompletes/location', locals: { locations: @locations }
  end

  def autocomplete_text
    @texts = Post.select('DISTINCT text').where('text LIKE ?', "%#{params[:q]}%").limit(5)
    render partial: 'posts/autocompletes/text', locals: { texts: @texts }
  end

  def autocomplete_tag_name
    @tag_names = Tag.select('DISTINCT name').where('name LIKE ?', "%#{params[:q]}%").limit(5)
    render partial: 'posts/autocompletes/tag_name', locals: { tag_names: @tag_names }
  end

観光地名(location)、本文(text)、タグ(tag)に対して、
ユーザーが入力した値(params[:q])に基づき、データベースから該当のレコードを検索し、
一致する結果を最大5件取得し、各パーシャルに渡してレンダリング。

<% locations.each do |location| %>
  <li class="w-80 flex items-center gap-x-3.5 py-2 px-3 rounded-md text-sm text-gray-800 hover:bg-blue-200 focus:ring-2 focus:ring-blue-500 dark:text-gray-400 dark:hover:bg-blue-200 dark:hover:text-gray-300" role="option" data-autocomplete-value="<%= location.id %>" data-autocomplete-label="<%= location.location %>">
    <%= location.location %>
  </li>
<% end %>

リスト項目を作成するためのパーシャル。

<!-- postsテーブルのlocationに対する検索 -->
<div data-controller="autocomplete" data-autocomplete-url-value="/posts/autocomplete_location" role="combobox">
  <%= f.label :location_cont_any, "観光地名検索", class: "mt-2 block text-sm font-bold mb-2" %>
  <%= f.text_field :location_cont_any, placeholder: "キーワードを入力してください", class: "w-full rounded border bg-gray-50 px-3 py-2 text-gray-800 outline-none ring-indigo-300 transition duration-100 focus:ring", data: { autocomplete_target: "input" } %>
  <ul class="list-group bg-white absolute w-full md:text-sm max-w-max" data-autocomplete-target="results"></ul>
</div>

実際に検索フィールドに表示するためのフィールドを記載。

<div data-controller="autocomplete" data-autocomplete-url-value="/posts/autocomplete_location" role="combobox">
<ul ... data-autocomplete-target="results"></ul>

投稿機能(住所・緯度経度の自動取得)

投稿していただく際に、住所を入力せずに取得できるように設計しました。

投稿画面 投稿詳細
Image from Gyazo Image from Gyazo

都道府県と観光地名のキーワードを入れていただくと、
バックエンドでキーワードを1つにまとめて、そのキーワードを基にGeocodingAPIを叩いています。

また、取得した住所をマッピングテーブルと照合して、エリア区分(茨城県水戸市→茨城・県央等)の情報も保存しています。

具体的なロジック(住所・緯度経度の取得)
# models/post.rb

  geocoded_by :full_address
  after_validation :geocode, if: ->(obj) { obj.location_changed? || obj.prefecture_id_changed? }
  after_validation :save_formatted_address, if: ->(obj) { obj.location_changed? || obj.prefecture_id_changed? }

  def full_address
    prefecture_name = Prefecture.find(prefecture_id).name
    [prefecture_name, location].compact.join(' ')
  end

  def save_formatted_address
    return unless geocoded?

    formatted = Geocoder.search([latitude, longitude], language: :ja).first.formatted_address
    # '289F+GC 日本、' のような形式を削除
    formatted = formatted.sub(/^\w+\+\w+\s日本、\s*/, '')
    # '日本、' を削除
    self.address = formatted.sub(/^日本、\s*/, '')
  end
# gemfile
gem 'geocoder'

## 上記gemの導入によって使えるようになったメソッド
geocoded_by :XXX
    XXXメソッドの値を用いて、GeocodingAPIへのクエリとして利用

:geocode
    geocoded_byで指定された属性を基に緯度経度を取得

Geocoder.search
    Gitはを基に地理情報を検索し、結果を取得

ユーザーが選択・入力した「都道府県+観光地名」をキーワードとしてGeocodingAPIを叩き、
住所に一部加工を施し、postテーブルのaddress、latitude、longitudeに保存。

<script>
function initMap() {
  var location = {lat: <%= @post.latitude %>, lng: <%= @post.longitude %>};
  var map = new google.maps.Map(document.getElementById('map'), {
    zoom: 14,
    center: location
  });
  var marker = new google.maps.Marker({
    position: location,
    map: map
  });
}
</script>
<script async defer
  src="https://maps.googleapis.com/maps/api/js?key=<%= ENV['GOOGLE_MAPS_API_KEY'] %>&callback=initMap">
</script>

Maps Javascript APIを用いて取得した緯度経度から詳細画面に緯度経度を表示。

また、CarrierWave、minimagic、AmazonS3を用いた、
画像の加工・保存を行なっています。

リリース後に頂いたフィードバック

お褒めの言葉

  • 彩り機能が楽しい
  • 住所を入れる手間がないのが便利
  • スマホからでも見やすい、レイアウトが分かりやすい

私がこだわりを持って作った部分についてはある程度評価を頂けて大変嬉しく感じております🙇‍♂️🙇‍♂️

頂いた要望

投稿画像の削除・変更ができない

画像を5枚以上選択 エラーが出た際にキャッシュが保存され
変更・削除ができない
Image from Gyazo Image from Gyazo

画像を1度選択すると、cacheが保持され削除できないという不具合です。
新規投稿であればフォームをリロードしていただくとキャッシュが削除できるのですが、
1度投稿したものを編集するには管理者が直接DBの画像を削除する、
もしくは投稿を削除し作り直していただくしか方法がない状態です。

原因は特定できているのですが、どのように修正しようか検討中です。

  <!-- 画像選択 -->
    <%= f.label :images, "画像を選択(最低1枚、最大4枚)*", class: "mt-2 block text-sm font-bold mb-2" %>
    <%= f.file_field :images, multiple: true, class: "mb-2 block file-input file-input-bordered w-full max-w-xs", accept: 'image/jpeg,image/png,image/webp,image/heic' %>
    <!-- <%= f.hidden_field :images_cache %> -->
    <% if @post.new_record? %>
      <% @post.images.each do |image| %>
        <%= hidden_field :post, :images, multiple: true, value: image.cache_name %>
      <% end %>
    <% else %>
      <% @post.images.each do |image| %>
        <%= hidden_field :post, :images, multiple: true, value: image.identifier %>
      <% end %>
    <% end %>

ここでcacheを持たせなければよいのですが、
そうすると編集の際に画像がリセットされてしまう(再度追加の手間が発生する)状態となります。

「画像を個別に削除・追加するコントローラを追加し1枚ずつ処理する方法」に変更しようかと検討しておりますので、しばらくお待ち頂けますと幸いです🙇‍♂️

検索時に予測変換が欲しい

これについてはオートコンプリートを実装することで対応致しましたが、
フリーワードにかすらないと予測が出てこない、微妙に使いづらさが残っているなと感じています。

例えば、タグ登録はあえて規定のものを選んでもらわず、自由に登録していただく形をとっています。
(Xやインスタのタグのように、旅の思い出(本文)に添えて感情や想いを自由に書いていただきたいなと考えております。)
それゆえに、検索の時にパッと思い浮かばないキーワードが登録されていることもあり、
検索の際は使い勝手が悪くなってるかなあと感じています。

今後タグをカテゴリーに分類するロジックやカテゴリー検索、
タグをランダムにピックアップする、など改良を施そうかと思案中ですので実装をお待ちくださいませ🙇‍♂️

今後の展望

現状の取り組み

  • リファクタリングとRSpecの追加

リファクタリングを行うと一部の処理に不具合が出てしまったことがありましたので、
現状の品質・挙動を担保する意味でもRSpecの重要性を感じております。

実際テストを書いていると、「登録時にパスワードを入力しなくても登録できてしまう」、まあまあよろしくない不具合が残っておりました😱
引き続き現在の実装項目に対してRSpecを追記しながらアプリの質を高めていこうと思います。

今後実装したい機能

・実績機能(関東圏制覇等々、投稿に応じた実績)
・Google Cloud Vision APIを用いた画像のバリデーション
・投稿待ち時間のモーダル表示やパフォーマンス改善

もっと投稿がしたくなるような要素を加えつつ、
セキュリティ面への配慮やサイトを触っていてのストレスを軽減できるように修正を加えたいなと考えております。

最後に

就活用のPFに留めず、

  • 自分のスキルアップの場として
  • 自分が使うためのアプリとして

継続運用していきますので、是非触ってみていただけるととても嬉しいです!
何かご意見ご要望等ございましたらXやHPのお問い合わせからご連絡くださいませ🙇‍♂️🙇‍♂️

初めての本格的なアプリ作成で大変な部分もありましたが、
それ以上に自分が思い描いたものが形になっていく様が楽しく、とてもいい経験になりました。

アプリを触っていただいた皆様、フィードバックをくれた受講生・講師の方々、支えてくださった皆様全員に感謝とお礼を伝えたいです🙇‍♂️🙇‍♂️

今後も何かを作ったり、その過程での学びを発信できたらと思います!

拙い文章でしたが、最後まで読んでいただきありがとうございました!🐟💨

57
41
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
57
41