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