ポケモン図鑑:ポケモンの説明文編
ポケモン図鑑を作る途中で、最低限の情報を表示するより、PokéAPIにおけるできるだけたくさんのデータを画面に表示すればさらなる勉強になると考えた。この記事でDBスキーマの拡張とAPIから自分のDBへのデータ移動をどうやって行なったか説明する。
更新前の状態までの開発にご興味のある方は次の記事をご覧ください。
更新前の状態:
目標:hybridshivamさんのポケモン図鑑をご参照いただければと存じます。
更新前のスキーマ
更新前はDBポケモンの基本情報しか保管していなかった(フィールド名はポケモン王国攻略館を参考にしている):
- 名前
- 分類
- 全国図鑑の番号
- 高さ
- 重さ
- 特性と隠れ特性
- タイプ
- 形態
- 種族値(HP・攻撃・防御・特攻・特防・素早さ・合計)
目標
新たに追加した情報はこちら:
- ポケモンの説明、タイトル別(分類ウィジェットをクリックすることでアクセスできる)
- 進化の流れ
- タイプ相性(ダメージ倍率)
スキーマ更新
ポケモンスキーマはほとんど変わっていない:
model Pokemon {
// 全国図鑑の番号
id Int @id
// 特性
abilities String[]
// 形態
forms Form[]
height Int
name String @unique
// 複数言語対応の名前
names Name[]
// URL
species String
sprite String
// 種族値
stats Stat[]
type1 String
type2 String?
weight Int
}
しかし、ポケモンスキーマに加え、ポケモン種のスキーマも追加し、進化の流れ情報を入手する:
model Species {
id Int @id
evolutionChain EvolutionChain?
evolvesFromSpecies String?
flavorTextEntries SpeciesFlavorTextEntry[]
genderRate Int
// 分類(言語別)
genera Genus[]
name String
}
なぜポケモンとポケモン種に分割したかというと、APIデータの設計を真似るためだ。
次に、Genus
で分類も取得する:
model Genus {
id Int @id @default(autoincrement())
genus String
language String
// 複数のポケモンが同じ分類に所属することがよくある(例えば、フシギ一家)
// そのため、分類と種類の間に1対多のリレーションを設置する
Species Species[]
}
最後に、タイプ相性情報を取得するにはタイプスキーマが必要だ:
model Type {
id Int @id
doubleDamageFrom String[]
doubleDamageTo String[]
halfDamageFrom String[]
halfDamageTo String[]
noDamageFrom String[]
noDamageTo String[]
name String
// タイプ名(言語別)
names TypeName[]
// 本タイプに分類されるポケモンの名前
pokemon String[]
}
// タイプ名(言語別)
model TypeName {
id Int @id @unique @default(autoincrement())
language String
name String
Type Type? @relation(fields: [typeId], references: [id])
typeId Int?
}
データ挿入
DBにデータを挿入にはまずAPIからデータを取得しないといけない。さらに、依存関係に基づき、データ取得を行う必要がある。それは、TypeName
を挿入するときtypeId
になるタイプのid
がないと無理だからね。
全体的な流れはこちら:
- APIからポケモン名をまず取得
- 次に一匹のポケモンのデータを取得
- ポケモンデータから
species
フィールドを使い、種類データを取得(species
はURLだから) - 種類データの
id
を使ってたくさんのgenera
を生成 - 最後のポケモンデータを取得するまで2から4までを繰り返す
タイプデータは同様、タイプ名を取得してからid
を使ってTypeName
を生成する。
UIの変更
ポケモンの説明ポップアップ
ポケモンの説明は中央に寄せ、タイトル別に表示したいだ。幸いなことに、データはタイトルの公開年順に並んでいるため、そのままHTML要素に変換すればいいというわけだ:
// flavorTextEntries:タイトル別にポケモンに関する一言を表す変数
// `species`データからもらう
{species.flavorTextEntries
.filter(({ language }) => language.startsWith('en'))
.map(entry => (
<div key={entry.id} className='flex flex-col'>
{`Pokémon ${entry.version}`}
{entry.flavorText}
</div>
))}
ボックス自体も中央に寄せる:
{/* `display: flex`と`justify-center`で子要素中央に寄せる */}
<div className='flex justify-center '>
{/* 白色の背景にちょっとした影を加えるとだいぶ目立ってくる */}
<div className='absolute bg-white z-10 w-[40vw] h-[90%] p-[2rem] shadow-lg'>
// 上記のコード
</div>
</div>
ポップアップ外にクリックすれば閉じるようにする
ポップアップが開いており、閉じたいとすれば、単にwindow
やdocument
にイベントリスナーを追加すればいい:
const [show, setShow] = useState(false);
window.addEventListener('mousedown', () => {
// windowのどこかにクリックすればポップアップを閉じる
setShow(false);
});
return ({show && (...)})
だが、これではポップアップを開く方法はない。なぜなら開いた側からまた閉じられるからだ。
そこで、クリックイベントがどの要素から発生したのか特定する方法も必要なわけだ。やり方は少なくとも二つある。
子から親へ検索する
例えば、closest(selectors)を使えば、クリックされた要素の親から、selectors
(クラスメイなど)に一致する最近要素を見つけることができる。ただ、ここではとりあえずそのような親が存在するかどうか分かれば十分だ:
window.addEventListener('mousedown', e => {
// イベント元の近くにクラスpopupに所属する要素がなければ、ポップアップの外をクリックした
if (display && !e.target.closest('.popup')) {
setShow(false);
}
});
あとはポップアップにクラスpopup
を追加するだけ。
親から子へ検索する
逆にcontains(node)を使い、子要素に指定した要素が含まれているのか特定できる。
const closePopup = (e: MouseEvent) => {
// ポップアップの子にイベントの対象が含まれていなければ、ポップアップの外をクリックした
// 註:残念ながら、型をキャストしないといけない
if (display && !popup.current?.contains(e.target as Node)) {
setDisplay(false);
}
};
また、popup.current
とはuseRef
を使った名残りで、再レンダーしてもポップアップへの参照を保持するために活用している。
Discussion