[{"data":1,"prerenderedAt":651},["ShallowReactive",2],{"content-/prefecture-quiz-map-modes":3,"all-pages-for-dir":630,"related-/prefecture-quiz-map-modes":631,"og-image-/prefecture-quiz-map-modes":650},{"id":4,"title":5,"body":6,"category":611,"concepts":612,"description":613,"extension":614,"meta":615,"navigation":577,"ogImage":612,"path":616,"project_name":617,"published":618,"publishedAt":619,"seo":620,"source":621,"stem":622,"tags":623,"todo":612,"unpublished":618,"updatedAt":612,"__hash__":629},"pages/2026-05/2026-05-15/prefecture-quiz-map-modes.md","都道府県クイズに地図モードを4つ追加してずんだもん音声をR2配信に乗せた",{"type":7,"value":8,"toc":599},"minimark",[9,13,17,21,24,44,47,51,58,61,72,150,157,161,164,171,175,186,204,303,314,318,333,340,363,366,373,495,498,509,513,516,535,538,541,563,566,595],[10,11,12],"h2",{"id":12},"やったこと",[14,15,16],"p",{},"都道府県クイズに地図系モードを4つ追加した。Phase 5（県→特徴 逆引き）、Phase 6A（白地図クリック）、Phase 6B（地形図モード）、Phase 7（study-map）まで Claude Code に作らせて、最後に Phase 8 でずんだもん音声を VOICEVOX ローカルエンジン直接呼び出しから事前生成 MP3 + Cloudflare R2 配信に切り替えた。途中で「神経衰弱を作らせたら自分の意図は地図トグルだった」「VOICEVOX 直接呼びは開発でしか動かない」という方針転換が2回挟まった。",[10,18,20],{"id":19},"phase-5-県特徴の逆引きクイズ","Phase 5: 県→特徴の逆引きクイズ",[14,22,23],{},"左に日本地図ハイライト、右に「○○県の特徴は？」の4択。最初に Wikimedia の SVG を拾わせたら path がひとかたまりで、県別ハイライトができない。仕方なく Financial Times 風の簡略タイル地図で出させたら見た目に納得がいかず、「もうちょっと頑張ってほしい」と差し戻した。",[14,25,26,27,31,32,35,36,39,40,43],{},"Geolonia の ",[28,29,30],"code",{},"japan-prefectures.svg"," に差し替えさせて解決した。30KB、viewBox は ",[28,33,34],{},"0 0 1000 1000","、各県が ",[28,37,38],{},"\u003Cg class=\"hokkaido prefecture\" data-code=\"01\">"," の単位でグループ化されていて、CSS で ",[28,41,42],{},".active"," を当てるだけでハイライトできる。GFDL ライセンスなので帰属表記を入れさせた。地図サイズも画面いっぱいまで拡大させた。",[14,45,46],{},"カバー範囲は 43 県。神奈川・大阪・福岡・佐賀は「日本一」級の確実な出典が取れず、無理に出さず外した。",[10,48,50],{"id":49},"phase-6a-白地図クリックfind-prefecture","Phase 6A: 白地図クリック（find-prefecture）",[14,52,53,54,57],{},"「○○県はどこ？」と問題が出て、地図上の県をクリックして当てるモード。HUD（問題文・タイマー・誤答数・ベスト）は ",[28,55,56],{},"position: absolute"," で地図に重ねて、PC では地図を画面いっぱいに表示するように指示した。",[14,59,60],{},"正解は緑、誤答は赤で永続ハイライトする。ハードモード（正解するまで開示しない）とイージーモード（1回間違えたら正解県を緑＋ラベル表示）の切替を入れさせた。",[14,62,63,64,67,68,71],{},"履歴管理は自分の独自指示で組み込ませた。直近3回の正誤を ",[28,65,66],{},"localStorage"," に ",[28,69,70],{},"['o', 'x', 'o']"," で保存して、各県の履歴ドットを表示する。",[73,74,79],"pre",{"className":75,"code":76,"language":77,"meta":78,"style":78},"language-ts shiki shiki-themes vitesse-light vitesse-light","type PrefHistory = Record\u003Cstring, ('o' | 'x')[]>\n// 「○が2連続できれば卒業」のメンタルモデルで\n// フィルター: 全47県 / 直近1回× / 2連続× / 3連続×\n","ts","",[28,80,81,137,144],{"__ignoreMap":78},[82,83,86,90,94,98,101,104,107,110,113,117,121,123,126,129,132,134],"span",{"class":84,"line":85},"line",1,[82,87,89],{"class":88},"stQ0i","type",[82,91,93],{"class":92},"sSkh3"," PrefHistory",[82,95,97],{"class":96},"shFtX"," =",[82,99,100],{"class":92}," Record",[82,102,103],{"class":96},"\u003C",[82,105,106],{"class":92},"string",[82,108,109],{"class":96},",",[82,111,112],{"class":96}," (",[82,114,116],{"class":115},"sMJiu","'",[82,118,120],{"class":119},"sdGka","o",[82,122,116],{"class":115},[82,124,125],{"class":96}," |",[82,127,128],{"class":115}," '",[82,130,131],{"class":119},"x",[82,133,116],{"class":115},[82,135,136],{"class":96},")[]>\n",[82,138,140],{"class":84,"line":139},2,[82,141,143],{"class":142},"sxvE3","// 「○が2連続できれば卒業」のメンタルモデルで\n",[82,145,147],{"class":84,"line":146},3,[82,148,149],{"class":142},"// フィルター: 全47県 / 直近1回× / 2連続× / 3連続×\n",[14,151,152,153,156],{},"フィルターを切り替えると出題範囲が絞り込まれる。最終的にベスト記録はフィルター=all のときだけ更新するよう ",[28,154,155],{},"finalize()"," でガードさせた。HUD には地方名「東北」を縦書き rowspan で1列目に置き、2列目に県名と履歴ドットを並べたテーブルを表示する設計にした。地方ごとに行がまとまって眺められる。",[10,158,160],{"id":159},"phase-6b-地形図モード","Phase 6B: 地形図モード",[14,162,163],{},"当初は背景に PNG の地形図を重ねる案で進めていたが、座標を SVG の viewBox と合わせるのは現実的でないと途中で気付いた。各県の平均標高で CSS 段彩する方式に解釈変更させた。",[14,165,166,167,170],{},"長野（標高 1132m）と山梨（995m）が濃茶、関東平野が緑、と地理感が一目でわかる。標高テーブルは静的データとして持たせ、",[28,168,169],{},"\u003Cg>"," の塗りを段階別に切り替えるだけで済む。",[10,172,174],{"id":173},"phase-7-神経衰弱-却下-study-map","Phase 7: 神経衰弱 → 却下 → study-map",[14,176,177,178,181,182,185],{},"最初「県と特徴をマッチングする神経衰弱」を一通り作らせたが、動くものを見せてもらったところ自分の意図と違った。自分が欲しかったのは「地図クリックで県名カードを開閉する学習モード」だった。",[28,179,180],{},"memory-game.vue"," を全削除して、",[28,183,184],{},"study-map.vue"," を新規に書き直してもらった。",[14,187,188,191,192,195,196,199,200,203],{},[28,189,190],{},"JapanMap.vue"," には2つの機能を追加させた。複数ハイライト（",[28,193,194],{},"multiActivePrefIds","）と SVG ラベル表示（",[28,197,198],{},"labels","）。県の中心座標は ",[28,201,202],{},"getBBox()"," で取り、親 chain の transform を累積して絶対 viewBox 座標に変換するのが肝だった。",[73,205,207],{"className":75,"code":206,"language":77,"meta":78,"style":78},"// 県の SVG 内中心座標を求める\nconst getPrefCenter = (el: SVGGraphicsElement) => {\n  const bbox = el.getBBox()\n  const ctm = el.getCTM()  // 親 transform を累積\n  // ... bbox を viewBox 座標に投影\n}\n",[28,208,209,214,246,268,291,297],{"__ignoreMap":78},[82,210,211],{"class":84,"line":85},[82,212,213],{"class":142},"// 県の SVG 内中心座標を求める\n",[82,215,216,219,223,225,227,231,234,237,240,243],{"class":84,"line":139},[82,217,218],{"class":88},"const ",[82,220,222],{"class":221},"senZ8","getPrefCenter",[82,224,97],{"class":96},[82,226,112],{"class":96},[82,228,230],{"class":229},"s4oTP","el",[82,232,233],{"class":96},": ",[82,235,236],{"class":92},"SVGGraphicsElement",[82,238,239],{"class":96},")",[82,241,242],{"class":96}," =>",[82,244,245],{"class":96}," {\n",[82,247,248,251,254,256,259,262,265],{"class":84,"line":146},[82,249,250],{"class":88},"  const ",[82,252,253],{"class":229},"bbox",[82,255,97],{"class":96},[82,257,258],{"class":229}," el",[82,260,261],{"class":96},".",[82,263,264],{"class":221},"getBBox",[82,266,267],{"class":96},"()\n",[82,269,271,273,276,278,280,282,285,288],{"class":84,"line":270},4,[82,272,250],{"class":88},[82,274,275],{"class":229},"ctm",[82,277,97],{"class":96},[82,279,258],{"class":229},[82,281,261],{"class":96},[82,283,284],{"class":221},"getCTM",[82,286,287],{"class":96},"()",[82,289,290],{"class":142},"  // 親 transform を累積\n",[82,292,294],{"class":84,"line":293},5,[82,295,296],{"class":142},"  // ... bbox を viewBox 座標に投影\n",[82,298,300],{"class":84,"line":299},6,[82,301,302],{"class":96},"}\n",[14,304,305,306,309,310,313],{},"地域別カラーは「全部同じ薄マゼンタじゃなく地域で色分けしてほしい」と指示して実装させた。",[28,307,308],{},".multi-active.tohoku","、",[28,311,312],{},".multi-active.kanto"," のような CSS セレクタで 8 地方区分の色を上書きする。北海道=青、東北=緑、関東=黄、中部=オレンジ、近畿=ピンク、中国=紫、四国=ティール、九州沖縄=赤。",[10,315,317],{"id":316},"phase-8-voicevox-直接呼び-事前生成-mp3-r2","Phase 8: VOICEVOX 直接呼び → 事前生成 MP3 + R2",[14,319,320,321,324,325,328,329,332],{},"最初は ",[28,322,323],{},"useVoicevox.ts"," を作らせて、ローカルの VOICEVOX エンジン（",[28,326,327],{},"http://localhost:50021","）に直接 ",[28,330,331],{},"fetch"," でリクエストするコードにしていた。開発機ではずんだもんが「ちがうのだ」と返してきて気持ちよく動いた。",[14,334,335,336,339],{},"本番デプロイ直前で気付いた。自分が VOICEVOX を起動していないと音は鳴らない。Cloudflare Pages 上で ",[28,337,338],{},"localhost:50021"," に投げても何も帰ってこない。",[14,341,342,343,346,347,350,351,354,355,358,359,362],{},"Python スクリプトを書かせて wav → mp3 を 100 ファイル（合計 0.8MB）一括生成させた。",[28,344,345],{},"useVoiceClips.ts"," で ",[28,348,349],{},"runtimeConfig.public.voiceBaseUrl"," を切り替える設計に直して、Cloudflare R2 の ",[28,352,353],{},"oto-assets"," バケットに ",[28,356,357],{},"prefecture-quiz/"," プレフィックスでアップロードした。最終的に ",[28,360,361],{},"https://assets.otoarchive.com/prefecture-quiz/{clip}.mp3"," で公開される。",[14,364,365],{},"ずんだもんのセリフが長すぎて次の問題読み上げと被る事故に気付いた。「ちがうのだ」「おしいのだ」「すごいのだ」など短いセリフだけに絞らせ、「ここがせいかいなのだ」系の長いものは削らせた。",[14,367,368,369,372],{},"音声 3 重再生バグも踏んだ。連打すると古いリクエストの合成結果が遅れて返ってきて、新しい音と重なって鳴る。",[28,370,371],{},"pendingRequestId"," を使って「synthesize 中の古いリクエストは破棄する」設計に直させたら止まった。",[73,374,376],{"className":75,"code":375,"language":77,"meta":78,"style":78},"let pendingRequestId = 0\nconst play = async (clip: string) => {\n  const myId = ++pendingRequestId\n  const audio = await fetchClip(clip)\n  if (myId !== pendingRequestId) return  // 古いリクエストは破棄\n  audio.play()\n}\n",[28,377,378,391,419,434,457,479,490],{"__ignoreMap":78},[82,379,380,383,385,387],{"class":84,"line":85},[82,381,382],{"class":88},"let ",[82,384,371],{"class":229},[82,386,97],{"class":96},[82,388,390],{"class":389},"sM54T"," 0\n",[82,392,393,395,398,400,403,406,409,411,413,415,417],{"class":84,"line":139},[82,394,218],{"class":88},[82,396,397],{"class":221},"play",[82,399,97],{"class":96},[82,401,402],{"class":88}," async ",[82,404,405],{"class":96},"(",[82,407,408],{"class":229},"clip",[82,410,233],{"class":96},[82,412,106],{"class":92},[82,414,239],{"class":96},[82,416,242],{"class":96},[82,418,245],{"class":96},[82,420,421,423,426,428,431],{"class":84,"line":146},[82,422,250],{"class":88},[82,424,425],{"class":229},"myId",[82,427,97],{"class":96},[82,429,430],{"class":88}," ++",[82,432,433],{"class":229},"pendingRequestId\n",[82,435,436,438,441,443,447,450,452,454],{"class":84,"line":270},[82,437,250],{"class":88},[82,439,440],{"class":229},"audio",[82,442,97],{"class":96},[82,444,446],{"class":445},"sHkkW"," await",[82,448,449],{"class":221}," fetchClip",[82,451,405],{"class":96},[82,453,408],{"class":229},[82,455,456],{"class":96},")\n",[82,458,459,462,464,466,469,471,473,476],{"class":84,"line":293},[82,460,461],{"class":445},"  if",[82,463,112],{"class":96},[82,465,425],{"class":229},[82,467,468],{"class":88}," !== ",[82,470,371],{"class":229},[82,472,239],{"class":96},[82,474,475],{"class":445}," return",[82,477,478],{"class":142},"  // 古いリクエストは破棄\n",[82,480,481,484,486,488],{"class":84,"line":299},[82,482,483],{"class":229},"  audio",[82,485,261],{"class":96},[82,487,397],{"class":221},[82,489,267],{"class":96},[82,491,493],{"class":84,"line":492},7,[82,494,302],{"class":96},[10,496,497],{"id":497},"クリア演出の紙吹雪",[14,499,500,501,504,505,508],{},"全問正解時の演出は CSS のみで 18 ピースの紙吹雪を降らせた。",[28,502,503],{},"confetti"," ライブラリを足すほどではないと判断して、",[28,506,507],{},"Math.sin(seed * 9999)"," の擬似ランダムで色・角度・落下時間を決定的にばらして、ピースごとに違う動きをさせた。",[10,510,512],{"id":511},"codex-レビューで-4-件の致命的指摘","Codex レビューで 4 件の致命的指摘",[14,514,515],{},"最後に Codex に通したら 4 件返ってきた。",[517,518,519,526,529,532],"ul",{},[520,521,522,525],"li",{},[28,523,524],{},"pnpm build"," が未確認のまま push しようとしていた",[520,527,528],{},"未追跡ファイルが多数残っていた",[520,530,531],{},"音声本番 404 リスク（R2 アップロード前に URL がリンクされていた）",[520,533,534],{},"ずんだもんセリフのフォールバック未実装",[14,536,537],{},"全件反映させて締めた。",[10,539,540],{"id":540},"学んだこと",[517,542,543,546,553,556],{},[520,544,545],{},"「神経衰弱を作ってほしい」と頼んだつもりが、自分の意図は地図トグルだった。動くものを早めに見せてもらうと認識ズレが即修正できる",[520,547,548,549,552],{},"ローカル開発で動く音声合成は本番では動かない。",[28,550,551],{},"localhost"," に投げているコードは、デプロイ前に必ず疑う",[520,554,555],{},"SVG ハイライトは Geolonia のように path がグループ化された素材を使うと、CSS だけでモードを増やせる",[520,557,558,559,562],{},"連打バグの解決には「最新のリクエスト ID 以外は破棄」が効く。",[28,560,561],{},"AbortController"," でも同じ思想",[10,564,565],{"id":565},"明日やること",[517,567,570,580,586],{"className":568},[569],"contains-task-list",[520,571,574,579],{"className":572},[573],"task-list-item",[575,576],"input",{"disabled":577,"type":578},true,"checkbox"," 神奈川・大阪・福岡・佐賀の「日本一」級の出典を探して 47 県カバーに戻す",[520,581,583,585],{"className":582},[573],[575,584],{"disabled":577,"type":578}," study-map の地域別カラーをライト/ダークテーマ両方で見直す（ダークだと近畿のピンクが浮く）",[520,587,589,591,592,594],{"className":588},[573],[575,590],{"disabled":577,"type":578}," R2 の ",[28,593,353],{}," バケットに CORS 設定を入れて、別ドメインからの prefetch を有効化する",[596,597,598],"style",{},"html pre.shiki code .stQ0i, html code.shiki .stQ0i{--shiki-default:#AB5959;--shiki-dark:#AB5959}html pre.shiki code .sSkh3, html code.shiki .sSkh3{--shiki-default:#2E8F82;--shiki-dark:#2E8F82}html pre.shiki code .shFtX, html code.shiki .shFtX{--shiki-default:#999999;--shiki-dark:#999999}html pre.shiki code .sMJiu, html code.shiki .sMJiu{--shiki-default:#B5695977;--shiki-dark:#B5695977}html pre.shiki code .sdGka, html code.shiki .sdGka{--shiki-default:#B56959;--shiki-dark:#B56959}html pre.shiki code .sxvE3, html code.shiki .sxvE3{--shiki-default:#A0ADA0;--shiki-dark:#A0ADA0}html .default .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .shiki span {color: var(--shiki-default);background: var(--shiki-default-bg);font-style: var(--shiki-default-font-style);font-weight: var(--shiki-default-font-weight);text-decoration: var(--shiki-default-text-decoration);}html .dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html.dark .shiki span {color: var(--shiki-dark);background: var(--shiki-dark-bg);font-style: var(--shiki-dark-font-style);font-weight: var(--shiki-dark-font-weight);text-decoration: var(--shiki-dark-text-decoration);}html pre.shiki code .senZ8, html code.shiki .senZ8{--shiki-default:#59873A;--shiki-dark:#59873A}html pre.shiki code .s4oTP, html code.shiki .s4oTP{--shiki-default:#B07D48;--shiki-dark:#B07D48}html pre.shiki code .sM54T, html code.shiki .sM54T{--shiki-default:#2F798A;--shiki-dark:#2F798A}html pre.shiki code .sHkkW, html code.shiki .sHkkW{--shiki-default:#1E754F;--shiki-dark:#1E754F}",{"title":78,"searchDepth":139,"depth":139,"links":600},[601,602,603,604,605,606,607,608,609,610],{"id":12,"depth":139,"text":12},{"id":19,"depth":139,"text":20},{"id":49,"depth":139,"text":50},{"id":159,"depth":139,"text":160},{"id":173,"depth":139,"text":174},{"id":316,"depth":139,"text":317},{"id":497,"depth":139,"text":497},{"id":511,"depth":139,"text":512},{"id":540,"depth":139,"text":540},{"id":565,"depth":139,"text":565},"dev",null,"Phase 5-8 で by-prefecture / find-prefecture / 地形図 / study-map を実装。VOICEVOX 直接呼びを諦めて事前生成MP3 + Cloudflare R2 に移行した記録","md",{},"/prefecture-quiz-map-modes","mdx-playground",false,"2026-05-15T00:00:00.000Z",{"title":5,"description":613},"make-diary","2026-05/2026-05-15/prefecture-quiz-map-modes",[624,625,626,627,628],"prefecture-quiz","vue","svg-map","voicevox","cloudflare-r2","EudaiPuZ3BurmC4EpGlbruDSjKix4Rqu2ek8Xw4oIFk",[],[632,635,638,642,646],{"title":633,"path":634,"publishedAt":619},"2026年5月15日の開発日記 - 都道府県クイズ12ページ完成・地図クイズ4種・eurekapu 77ページ2カラム化・yomitoku 0.13.0 まで","/2026-05-15-diary",{"title":636,"path":637,"publishedAt":619},"都道府県クイズに統計編12ページを増設し、89問へ拡張した日","/prefecture-quiz-statistics-12pages",{"title":639,"path":640,"publishedAt":641},"簿記学習用インタラクティブ仕訳エンジンを純HTMLで実装した話","/interactive-journal-engine","2026-05-01T00:00:00.000Z",{"title":643,"path":644,"publishedAt":645},"ナレーションコンテンツ改善とNarrationViewer拡張 - レビュー・音声・テスト・動画レンダリング","/narration-viewer-improvements","2026-03-16T00:00:00.000Z",{"title":647,"path":648,"publishedAt":649},"会計学習コンテンツのナレーション機能構築 - VOICEVOX・GCP TTS・Cloudflare R2","/narration-feature-voicevox-gcp-tts","2026-03-15T00:00:00.000Z","https://log.eurekapu.com/og/blog/prefecture-quiz-map-modes.png?v=2026-05-15T00%3A00%3A00.000Z&title=%E9%83%BD%E9%81%93%E5%BA%9C%E7%9C%8C%E3%82%AF%E3%82%A4%E3%82%BA%E3%81%AB%E5%9C%B0%E5%9B%B3%E3%83%A2%E3%83%BC%E3%83%89%E3%82%924%E3%81%A4%E8%BF%BD%E5%8A%A0%E3%81%97%E3%81%A6%E3%81%9A%E3%82%93%E3%81%A0%E3%82%82%E3%82%93%E9%9F%B3%E5%A3%B0%E3%82%92R2%E9%85%8D%E4%BF%A1%E3%81%AB%E4%B9%97%E3%81%9B%E3%81%9F&author=Kei%20Komatsu&sig=0fd3be8e5b80b7db",1786424514746]