0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【Rails × JS】世界地図 × 国別投稿機能実装ガイド

Last updated at Posted at 2025-08-07

image.png

1. 概要

Rails 7(Importmap構成)と JavaScript の D3.js を組み合わせて、
世界地図を表示し、管理者のみ国別に投稿できるWebアプリを構築します。

  • 世界地図は D3.js + world-atlas で描画
  • 投稿がある国だけ色付き(安定ランダム色)
  • マウスホバーで国名表示
  • 管理者ログイン後に新規投稿可能
  • タイトル・内容・国選択は必須入力

Railsのサーバーサイド機能(Devise認証やDB管理)と、D3.jsを使ったフロントエンド描画を組み合わせることで、
シンプルながら拡張性の高い「国別情報共有アプリ」が作れます。

2. 導入手順

1. Devise導入

コマンドプロンプト
bundle add devise
rails generate devise:install

2. Userモデル(Devise + admin)

コマンドプロンプト
rails generate devise User
rails generate migration AddAdminToUsers admin:boolean
rails db:migrate

管理者ユーザー作成

db/seeds.rb
User.destroy_all
User.create!(
  email: "admin@example.com",
  password: "password",
  password_confirmation: "password",
  admin: true
)

メールアドレス、パスワード(6文字以上)は自分用に変更してね!

コマンドプロンプト
rails db:seed

3. モデル作成(Country / CountryPost)

コマンドプロンプト
rails generate model Country code:string name:string
rails generate model CountryPost title:string content:text country:references user:references
rails db:migrate

モデル定義

app/models/country.rb
class Country < ApplicationRecord
    has_many :country_posts, dependent: :destroy
    validates :code, presence: true, uniqueness: true, length: { is: 2 }
    def to_param
        code
    end
end
app/models/country_post.rb
class CountryPost < ApplicationRecord
    belongs_to :country
    belongs_to :user
    validates :title, :content, presence: true
    attr_accessor :new_country_code
end

国データのシード

db/seeds.rb
Country.destroy_all
Country.create!(code: "JP", name: "Japan")
Country.create!(code: "US", name: "United States")
コマンドプロンプト
rails db:seed

4. ルーティング

config/routes.rb
root "countries#map"

resources :countries, param: :code do
    resources :country_posts, only: [:index, :edit, :update, :destroy]
end

resources :country_posts, only: [:new, :create]

5. コントローラー

CountriesController(マップ表示+投稿国リスト+国名リスト)

コマンドプロンプト
rails generate controller countries map
app/controllers/countries_controller.rb
class CountriesController < ApplicationController
  def map
    @countries     = Country.all
    @posted_codes  = Country.joins(:country_posts).distinct.pluck(:code)
    @code_to_name  = Country.pluck(:code, :name).to_h  # ← ホバー用
  end
end

CountryPostsController(管理者のみ投稿可)

コマンドプロンプト
rails generate controller country_posts
app/controllers/country_posts_controller.rb
class CountryPostsController < ApplicationController
    before_action :authenticate_user!, except: [:index]
    before_action :check_admin, except: [:index]
    before_action :set_country, only: [:index, :edit, :update, :destroy]
    before_action :set_country_post, only: [:edit, :update, :destroy]

    def index
        @country_posts = @country.country_posts.includes(:user)
    end

    def new
        @countries = Country.all
        @country_post = CountryPost.new
    end

    def create
        code = country_post_params[:new_country_code]
        country = Country.find_by!(code: code)
        @country_post = country.country_posts.build(country_post_params.except(:new_country_code))
        @country_post.user = current_user

        if @country_post.save
            redirect_to root_path, notice: "投稿が完了しました(地図に戻りました)"
        else
            @countries = Country.all
            render :new, status: :unprocessable_entity
        end
    end

    def edit
        @countries = Country.all
        @country_post.new_country_code = @country.code
    end

    def update
        @countries = Country.all
        if (code = country_post_params[:new_country_code]).present?
          @country = Country.find_by!(code: code)
          @country_post.country = @country
        end

        if @country_post.update(country_post_params.except(:new_country_code))
            redirect_to root_path, notice: "更新しました(地図に戻りました)"
        else
            render :edit, status: :unprocessable_entity
        end
    end

    def destroy
        @country_post.destroy
        redirect_to root_path, notice: "削除しました(地図に戻りました)"
    end

    private

    def set_country
        code_or_num = params[:country_code] || params[:code]
        @country = code_or_num.to_s.match?(/\A\d+\z/) ? Country.find(code_or_num) : Country.find_by!(code: code_or_num)
    end

    def set_country_post
        @country_post = @country.country_posts.find(params[:id])
    end

    def check_admin
        redirect_to root_path, alert: "管理者のみ利用可能です" unless current_user.admin?
    end

    def country_post_params
        params.require(:country_post).permit(:title, :content, :new_country_code)
    end
end

6. CSS(map.cssを作成)

app/assets/stylesheets/map.css
#world-map {
    display: flex;
    align-items: center;
    justify-content: center;
    min-height: 70vh;
    margin: 0 auto;
}

#world-map svg {
    width: min(960px, 95vw);
    height: auto;
    aspect-ratio: 960 / 600;
    display: block;
}

7. ビュー

世界地図(色付け+ホバーで国名表示)

app/views/countries/map.html.erb
<h1>世界地図</h1>
<div id="world-map"></div>

<script type="module">
    import * as d3 from "https://cdn.jsdelivr.net/npm/d3@7/+esm";
    import { feature } from "https://cdn.jsdelivr.net/npm/topojson-client@3/+esm";
    
    const posted = new Set(<%= raw @posted_codes.to_json %>);
    const codeToName = <%= raw @code_to_name.to_json %>; // ← 国コード→名前

    const palette = [
        "#5aa9e6","#7fc8f8","#fde74c","#9bc53d","#f06c9b",
        "#f18f01","#e55934","#5e548e","#2bb673","#3da5d9"
    ];

    function colorIndexFromCode(code) {
        let h = 0;
        for (let i = 0; i < code.length; i++) {
            h = (h * 31 + code.charCodeAt(i)) >>> 0;
        }
        return h % palette.length;
    }

    const svg = d3.select("#world-map").append("svg")
        .attr("width", 960).attr("height", 600);

    const projection = d3.geoMercator().rotate([-135, 0]).scale(150).translate([480, 300]);
    const path = d3.geoPath().projection(projection);

    const isoNumericToAlpha2 = { "392": "JP", "840": "US" };

    d3.json("https://cdn.jsdelivr.net/npm/world-atlas@2/countries-110m.json").then(world => {
    const countries = feature(world, world.objects.countries).features;

    svg.selectAll("path")
        .data(countries)
        .enter().append("path")
        .attr("d", path)
        .attr("stroke", "#333")
        .attr("fill", d => {
            const alpha2 = isoNumericToAlpha2[d.id];
            if (alpha2 && posted.has(alpha2)) {
                  return palette[colorIndexFromCode(alpha2)];
            }
            return "#cccccc";
        })
        .each(function(d){
            const alpha2 = isoNumericToAlpha2[d.id];
            const label  = alpha2 ? (codeToName[alpha2] || alpha2) : "未登録";
            d3.select(this).append("title").text(label); //  ホバー表示
        })
        .on("click", (_, d) => {
            const alpha2 = isoNumericToAlpha2[d.id];
            if (alpha2) {
                  window.location.href = `/countries/${alpha2}/country_posts`;
            } else {
                alert("この国は未登録です");
            }
        });
    });
</script>

<% if user_signed_in? %>
    <p>ログイン中:<%= current_user.email %></p>
    <%= button_to "ログアウト", destroy_user_session_path, method: :delete %>
<% else %>
    <%= link_to "ログイン", new_user_session_path %>
<% end %>

<% if user_signed_in? && current_user.admin? %>
    <p><%= link_to "新規投稿", new_country_post_path %></p>
<% end %>

新規投稿(必須入力)

app/views/country_posts/new.html.erb
<h2>新規投稿</h2>

<%= form_with model: @country_post, url: country_posts_path, local: true do |f| %>
    <div>
        <%= f.label :new_country_code, "国を選択" %><br>
        <%= f.select :new_country_code,
                    options_from_collection_for_select(@countries, :code, :name),
                    { prompt: "選択してください" },
                    { required: true } %>
    </div>

    <div>
        <%= f.label :title %><br>
        <%= f.text_field :title, required: true %>
    </div>

    <div>
        <%= f.label :content %><br>
        <%= f.text_area :content, required: true %>
    </div>

    <%= f.submit "投稿する" %>
<% end %>

<p><%= link_to "地図へ戻る", root_path %></p>

一覧表示

app/views/country_posts/index.html.erb
<h2><%= @country.name %>の投稿一覧</h2>

<ul>
  <% @country_posts.each do |post| %>
    <li style="margin-bottom:1rem;">
      <strong><%= post.title %></strong><br>
      <%= simple_format(post.content) %><br>
      <% if current_user&.admin? %>
        <%= link_to "編集", edit_country_country_post_path(@country, post) %> |
        <%= link_to "削除",
                    country_country_post_path(@country, post),
                    data: { turbo_method: :delete, turbo_confirm: "削除しますか?" } %>
      <% end %>
    </li>
  <% end %>
</ul>

<p><%= link_to "地図へ戻る", root_path %></p>

投稿編集

app/views/country_posts/edit.html.erb
<h2>投稿を編集<%= @country.name %>)</h2>

<%= form_with model: [@country, @country_post], local: true do |f| %>
  <div>
    <%= f.label :new_country_code, "国を選択" %><br>
    <%= f.select :new_country_code,
                 options_from_collection_for_select(@countries, :code, :name, @country_post.new_country_code || @country.code),
                 prompt: "選択してください" %>
  </div>

  <div>
    <%= f.label :title %><br>
    <%= f.text_field :title %>
  </div>

  <div>
    <%= f.label :content %><br>
    <%= f.text_area :content %>
  </div>

  <%= f.submit "更新する" %>
<% end %>

<p><%= link_to "一覧へ戻る", country_country_posts_path(@country) %></p>

8. 動作確認

コマンドプロンプト
rails db:drop db:create db:migrate
rails db:seed
rails s
0
0
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
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?