React Flow + Firebase でAWS構成図を「組み立てて採点」できる問題集を個人開発した
作ったもの
ArchiCraft というWebサービスを個人開発しました。
AWSサービスのアイコンをブラウザ上でドラッグ&ドロップして構成図を組み立て、採点を受ける形式のAWS認定試験問題集です。SAP-C02(Solutions Architect Professional)に対応しており、460問を収録しています。
この記事では、構成図エディタ部分の実装を中心に紹介します。
技術スタック
| カテゴリ | 技術 |
|---|---|
| フロントエンド | React 18 + Vite + Tailwind CSS |
| 構成図エンジン | React Flow |
| 認証・DB | Firebase Authentication + Firestore |
| ホスティング | Firebase Hosting |
| 決済 | Stripe Checkout(Firebase Functions 経由) |
| バックエンド | Firebase Cloud Functions(Node.js 20) |
構成図エディタの実装(React Flow)
なぜ React Flow を選んだか
ノードとエッジをインタラクティブに操作できるライブラリはいくつかありますが、React Flow を選んだ理由は以下の3点です。
- カスタムノードが作りやすい:AWSアイコンをノードとして表示するのに、JSXをそのまま書ける
- ドラッグ操作が標準装備:キャンバスへのドロップ、ノードの移動、エッジの接続がすぐ動く
-
fitViewが便利:採点後に正解構成を全体表示する際、自動でズーム調整できる
カスタムノードの実装
AWSサービスのアイコンノードはこのような構造です。
// AWSServiceNode.jsx
import { memo } from 'react'
import { Handle, Position } from 'reactflow'
function AWSServiceNode({ data, selected }) {
return (
<div className="group relative">
<Handle type="target" position={Position.Left} />
<div
className="flex flex-col items-center gap-1.5"
style={{
background: `linear-gradient(145deg, ${data.color}33, ${data.color}55)`,
border: `2px solid ${selected ? '#FF9900' : data.color}`,
}}
>
<img src={data.icon} alt={data.label} className="w-10 h-10 object-contain" />
<span className="text-xs font-bold text-white/90">{data.label}</span>
</div>
<Handle type="source" position={Position.Right} />
</div>
)
}
export default memo(AWSServiceNode)
Handle を両側に置くことで、ノード間を矢印でつなぐ操作が自然に使えます。
スマホ対応(タップで追加)
スマホではドラッグ&ドロップが使いにくいため、タップで追加する方式にしました。
// サービスパレットのアイテムをタップ → キャンバス中央に追加
const handleServiceTap = (service) => {
const newNode = {
id: `${service.id}-${Date.now()}`,
type: 'awsService',
position: getCanvasCenter(), // 現在のビューポート中央を計算
data: { label: service.name, iconUrl: service.iconUrl },
};
setNodes((prev) => [...prev, newNode]);
};
追加済みのサービスはパレットから一時的に消して重複配置を防いでいます。
採点ロジック
正解判定は requiredServices(必要なサービスのID配列)と requiredConnections(必要な接続のペア配列)で定義しています。
// questions.js の問題データ例
{
id: 7,
title: '高頻度決済基盤の鍵保護と監査ログ保管',
requiredServices: ['ec2', 'cloudhsm', 's3'],
requiredConnections: [
{ from: 'ec2', to: 'cloudhsm' },
{ from: 'ec2', to: 's3' },
],
isFree: false,
}
採点時は、ユーザーが配置したノードとエッジを requiredServices / requiredConnections と照合します。
// 採点ロジックの要点(LearningPage.jsx より)
const actualConnections = edges.map((e) => ({
from: nodes.find((n) => n.id === e.source)?.data.serviceId,
to: nodes.find((n) => n.id === e.target)?.data.serviceId,
})).filter((ac) => ac.from && ac.to)
question.requiredConnections.forEach((rc) => {
const exists = actualConnections.some(
(ac) => ac.from === rc.from && ac.to === rc.to
)
if (exists) correctConnections++
})
決済の実装(Stripe + Firebase Functions)
フロー
ユーザーが「購入する」をクリック
→ Firebase Functions: createCheckoutSession()
→ Stripe Checkout Session を作成して URL を返す
→ window.location.href = url でリダイレクト
→ 決済完了後 Stripe Webhook → Firebase Functions: stripeWebhook()
→ Firestore の users/{uid}.plan を更新
Cloud Functions の主要部分
// functions/index.js
exports.createCheckoutSession = onCall(async (request) => {
const { plan } = request.data;
const uid = request.auth.uid;
const priceId =
plan === 'plan_100'
? process.env.STRIPE_PRICE_ID_PLAN_100
: process.env.STRIPE_PRICE_ID_PLAN_450;
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{ price: priceId, quantity: 1 }],
success_url: `${process.env.APP_URL}/payment/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${process.env.APP_URL}/payment/cancel`,
metadata: { uid },
});
return { url: session.url };
});
シークレットキー類は Firebase Secret Manager に保存し、コードには直接書いていません。
アクセス制御
// firebase.js
export const canAccessQuestion = (userData, question) => {
if (!question) return false
if (question.isFree) return true // 問題1〜10は常時アクセス可
const plan = userData?.plan
if (plan === 'plan_100') return question.id <= 110
if (plan === 'plan_450') return !question.isFree
return false
}
plan_450 の条件を !question.isFree にしているため、isFree: true を誤って増やすと plan_450 ユーザーがアクセスできなくなる罠があります。問題1〜10以外は必ず isFree: false にしています。
ハマったポイント
React Flow の fitView タイミング問題
採点後に正解構成を表示して fitView を呼ぶと、ノードが描画される前に実行されてズームが合わないことがありました。
// setTimeout で描画を待ってから fitView を呼ぶ
setTimeout(() => {
reactFlowInstance.fitView({ padding: 0.2, duration: 300 });
}, 100);
スマホのタッチイベント
React Flow のデフォルト設定ではスマホでのパンとノード操作が競合します。panOnScroll と zoomOnPinch の設定を調整して解決しました。
まとめ
- React Flow でカスタムノード+採点ロジックを実装
- Firebase + Stripe の組み合わせで認証・DB・決済を完結
- スマホ対応はタップ追加方式で解決
460問・買い切り¥1,999で公開中です。無料で10問体験できます。
👉 https://archicraft-1b747.web.app
フィードバックや質問があれば気軽にコメントください。
開示:筆者はArchiCraftの開発者です。