見出し画像

【教材自作】欲しい教材がないならAIで作ればいい!Claudeで「地震の波の広がりアプリ」を即席で作る方法

導入:授業で「ちょうどいい素材」がない問題

授業の準備をしていて、「こういう動きを見せたいのに、ネットで検索しても理想のシミュレーターや動画が見つからない…」と困ったことはありませんか?

例えば理科の授業で、「地震の波(P波・S波)が同心円状に広がっていく様子」を説明する時。 教科書の図(静止画)だけでは、波の伝わり方や震源からの距離感をイメージするのが難しい生徒もいます。

「動く教材があれば一発で伝わるのに…」

昔ならプログラミングの知識がなければ諦めていたこの悩み、今はClaudeを使えば無料版でも一発で解決します!


なぜ「Claude」なのか?

AIチームの中で、プログラミングやコード生成において圧倒的な精度を誇るのが Claude です。 特別な開発環境や難しい設定は一切不要。「こういうアプリを作って」と日本語で頼むだけで、ブラウザ上でそのまま動くアプリ(Webアプリ)を瞬時に組み上げてくれます。

実践!AIに頼んだプロンプトと完成したアプリ

具体的に作成方法を説明します。「AIでアプリを作るなんて難しそう…」と思うかもしれませんが、操作はたったの3ステップです!

①画面からカテゴリーを選ぶ

Claudeにログインして選択します。もちろん無料版で構いません。そして、左の項目のアーティファクトを押します。もし表示されない場合は、設定(Feature Preview)を確認してください

新規アーティファクト⇒アプリとウェブサイト


②プロンプト(作ってほしいもの)を入力する

Claudeさんと対話をしながらアプリを作成できますが、今回は、こんな感じで頼んでみました。

【プロンプト例】(チャット欄にコピーして貼り付けて送ってください。)

地震の波(P波とS波)の伝わり方を視覚的に学べる、中学校理科向けのWebアプリを作成してください。

【機能・要件】
1. 日本列島の簡易的なマップ(または福岡・山口・広島・神戸・大阪などの都市の位置関係)を表示してください。
2. 画面上の任意の場所(または福岡などの都市)をクリックすると、そこが「震源」となります。
3. 震源から速さの異なる2つの波が同心円状に広がっていくアニメーションを描画してください。
   ・P波(初期微動):速い波(例:青色の円)
   ・S波(主要動):遅い波(例:赤色の円)
4. 各都市(福岡、山口、広島、神戸、大阪など)にP波・S波が到達した瞬間、画面の隅に「〇〇にP波到達!」「〇〇にS波到達(〇秒後)」といったログや到達タイムライン(秒数)を表示してください。
5. アニメーションの「一時停止・リセット・速度変更(1倍・0.5倍など)」ができるボタンも配置してください。

中学生が「震源からの距離」と「P波・S波の到達時間の差(初期微動継続時間)」を直感的に理解できる、シンプルでスタイリッシュなデザインにしてください。

③画面右側に「動くアプリ(Artifacts)」が完成!

送信すると、Claudeがコードを書き始め、画面の右半分(Artifactsエリア)にその場で直接動くWebアプリが表示されます。
ダウンロードして自分のPCに保存することもできますし、その画面上で直接クリックして動作テストをすることも可能です。動作テストをして、きになるところがあれば、コードを打つのではなく、チャットで指示を出すだけです。

作成したアプリを公開!

完成したコードを無料公開!

うまく動作するアプリが完成しましたので、そのコードを以下に公開します!

【使い方】

  1. 配布ファイルを保存、もしくは下記コードをコピーして、テキストエディタ(メモ帳など)に貼り付ける

  2. (コードをコピーした場合は、)ファイル名を earthquake-wave-simulator.html として保存する (拡張子を必ず .html にしてください)

  3. 保存したファイルをダブルクリックする

  4. ブラウザが自動で開いて、シミュレーターが動きます。

※インストール不要・ネット接続も不要で、パソコンやタブレットがあればすぐ使えます。

<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>地震波シミュレーター|P波とS波の伝わり方</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Shippori+Mincho:wght@500;600;700&family=Zen+Kaku+Gothic+New:wght@400;500;700;900&family=JetBrains+Mono:wght@400;500;700&display=swap" rel="stylesheet">
<style>
  :root{
    --bg: #0a0e17;
    --bg-deep: #060910;
    --panel: #121826;
    --panel-2: #161f30;
    --line: #223049;
    --line-soft: rgba(255,255,255,0.05);
    --text: #eaf0fa;
    --text-dim: #8b96ab;
    --p-color: #4fd1ff;
    --p-glow: rgba(79,209,255,0.55);
    --s-color: #ff6b5b;
    --s-glow: rgba(255,107,91,0.55);
    --amber: #ffc857;
    --amber-glow: rgba(255,200,87,0.5);
    --land: #1a2235;
    --land-line: #2c3c58;
    --font-display: 'Shippori Mincho', serif;
    --font-body: 'Zen Kaku Gothic New', sans-serif;
    --font-mono: 'JetBrains Mono', monospace;
    --radius: 10px;
  }

  *{ box-sizing: border-box; }
  html,body{ margin:0; padding:0; }
  body{
    background: radial-gradient(ellipse 120% 80% at 50% -10%, #10182a 0%, var(--bg) 45%, var(--bg-deep) 100%);
    color: var(--text);
    font-family: var(--font-body);
    min-height: 100vh;
    padding: 24px;
  }

  .app{
    max-width: 1320px;
    margin: 0 auto;
  }

  /* ---------- Header ---------- */
  header{
    display:flex;
    align-items: baseline;
    justify-content: space-between;
    flex-wrap: wrap;
    gap: 8px 24px;
    padding-bottom: 18px;
    margin-bottom: 20px;
    border-bottom: 1px solid var(--line);
  }
  header .title-block{
    display:flex;
    align-items:baseline;
    gap: 14px;
  }
  h1{
    font-family: var(--font-display);
    font-weight: 700;
    font-size: clamp(22px, 3vw, 30px);
    letter-spacing: 0.02em;
    margin: 0;
  }
  h1 span.accent{ color: var(--amber); }
  header .subtitle{
    font-size: 13px;
    color: var(--text-dim);
    letter-spacing: 0.04em;
  }
  header .clock{
    font-family: var(--font-mono);
    font-size: 13px;
    color: var(--text-dim);
    display:flex;
    align-items:center;
    gap: 10px;
  }
  header .clock b{
    font-size: 26px;
    color: var(--amber);
    font-weight: 700;
    letter-spacing: 0.02em;
  }

  /* ---------- Layout ---------- */
  main{
    display:grid;
    grid-template-columns: minmax(0,1.62fr) minmax(300px, 0.85fr);
    gap: 18px;
  }
  @media (max-width: 900px){
    main{ grid-template-columns: 1fr; }
  }

  .panel{
    background: linear-gradient(180deg, var(--panel) 0%, var(--panel-2) 100%);
    border: 1px solid var(--line);
    border-radius: var(--radius);
    overflow:hidden;
  }
  .panel-head{
    display:flex;
    align-items:center;
    justify-content: space-between;
    padding: 12px 16px;
    border-bottom: 1px solid var(--line);
    background: rgba(255,255,255,0.015);
  }
  .panel-head h2{
    font-size: 12.5px;
    font-weight: 700;
    letter-spacing: 0.09em;
    margin:0;
    color: var(--text-dim);
    text-transform: uppercase;
  }
  .panel-head h2 .num{ color: var(--amber); font-family: var(--font-mono); margin-right:6px; }

  /* ---------- Map panel ---------- */
  .map-wrap{ position:relative; }
  #mapHint{
    position:absolute;
    top: 14px; left: 50%;
    transform: translateX(-50%);
    background: rgba(6,9,16,0.82);
    border: 1px solid var(--line);
    color: var(--text-dim);
    font-size: 12.5px;
    padding: 7px 14px;
    border-radius: 999px;
    pointer-events:none;
    transition: opacity .3s ease;
    z-index: 5;
    white-space: nowrap;
  }
  #mapHint.hidden{ opacity:0; }

  svg#mapSvg{
    display:block;
    width: 100%;
    height: auto;
    cursor: crosshair;
    background: var(--bg-deep);
  }

  .city-label{
    font-family: var(--font-body);
    font-weight: 700;
    font-size: 15px;
    fill: var(--text);
    paint-order: stroke;
    stroke: var(--bg-deep);
    stroke-width: 4px;
    stroke-linejoin: round;
  }
  .city-dot{ fill: #cfd8e6; stroke: var(--bg-deep); stroke-width: 2; transition: fill .25s ease; }
  .city-dot.hit-p{ fill: var(--p-color); }
  .city-dot.hit-s{ fill: var(--s-color); }
  .city-ring{ fill:none; stroke-width:2; opacity:0; }
  .city-ring.show{ opacity:1; transition: opacity .2s ease; }

  .legend{
    display:flex;
    gap: 18px;
    flex-wrap:wrap;
    padding: 10px 16px;
    border-top: 1px solid var(--line);
    font-size: 12px;
    color: var(--text-dim);
    align-items:center;
  }
  .legend .item{ display:flex; align-items:center; gap:7px; }
  .legend .sw{ width:14px; height:14px; border-radius:50%; display:inline-block; flex:none; }
  .legend .sw.p{ background: var(--p-color); box-shadow: 0 0 8px var(--p-glow); }
  .legend .sw.s{ background: var(--s-color); box-shadow: 0 0 8px var(--s-glow); }
  .legend .sw.star{ background: var(--amber); clip-path: polygon(50% 0%, 61% 35%, 98% 35%, 68% 57%, 79% 91%, 50% 70%, 21% 91%, 32% 57%, 2% 35%, 39% 35%); box-shadow: 0 0 8px var(--amber-glow); }
  .legend .scale{ margin-left:auto; display:flex; align-items:center; gap:8px; font-family: var(--font-mono); }
  .legend .scale-bar{ width:124px; height:2px; background:var(--text-dim); position:relative; }
  .legend .scale-bar::before, .legend .scale-bar::after{ content:''; position:absolute; top:-4px; width:1px; height:10px; background:var(--text-dim); }
  .legend .scale-bar::before{ left:0; } .legend .scale-bar::after{ right:0; }

  /* ---------- Sidebar ---------- */
  .sidebar{ display:flex; flex-direction:column; gap: 16px; }

  .controls{ padding: 14px 16px; display:flex; flex-direction:column; gap:12px; }
  .btn-row{ display:flex; gap:8px; }
  button{
    font-family: var(--font-body);
    font-weight: 700;
    font-size: 13px;
    border-radius: 8px;
    border: 1px solid var(--line);
    background: var(--panel-2);
    color: var(--text);
    padding: 10px 12px;
    cursor:pointer;
    transition: all .15s ease;
    letter-spacing: 0.02em;
  }
  button:hover{ border-color:#3a4c6c; background:#1c2a42; }
  button:active{ transform: translateY(1px); }
  button:focus-visible{ outline: 2px solid var(--amber); outline-offset:2px; }
  button.primary{
    background: linear-gradient(180deg,#1c3a4a,#132433);
    border-color: var(--p-color);
    color: #d6f4ff;
    flex: 1.4;
  }
  button.ghost{ flex:1; }
  button.speed{ flex:1; }
  button.speed.active{ background: var(--amber); color:#22190a; border-color: var(--amber); }
  button:disabled{ opacity:.4; cursor:not-allowed; }

  .speed-group{ display:flex; gap:8px; }
  .field-label{ font-size:11px; color:var(--text-dim); letter-spacing:0.08em; text-transform:uppercase; margin-bottom:2px; }

  /* ---------- Distance table ---------- */
  .info-body{ padding: 12px 16px; }
  table.distances{ width:100%; border-collapse: collapse; font-size: 13px; }
  table.distances th{
    text-align:left; font-weight:700; color:var(--text-dim); font-size:11px;
    text-transform:uppercase; letter-spacing:0.06em; padding-bottom:6px; border-bottom:1px solid var(--line);
  }
  table.distances td{ padding: 7px 0; border-bottom: 1px solid var(--line-soft); font-family: var(--font-mono); }
  table.distances td.city-name{ font-family: var(--font-body); font-weight:700; }
  table.distances tr:last-child td{ border-bottom:none; }
  .placeholder{ color: var(--text-dim); font-size:13px; padding: 4px 0 8px; line-height:1.7; }

  /* ---------- Log panel ---------- */
  .log-body{
    height: 210px;
    overflow-y: auto;
    padding: 10px 14px;
    display:flex; flex-direction:column; gap:6px;
    font-family: var(--font-mono);
    font-size: 12.5px;
  }
  .log-body::-webkit-scrollbar{ width:6px; }
  .log-body::-webkit-scrollbar-thumb{ background:#2c3c58; border-radius:3px; }
  .log-entry{
    display:flex; gap:8px; align-items:baseline;
    padding: 6px 8px;
    border-radius:6px;
    background: rgba(255,255,255,0.02);
    animation: slidein .25s ease;
  }
  @keyframes slidein{ from{ opacity:0; transform: translateY(4px);} to{opacity:1; transform:translateY(0);} }
  .log-entry .t{ color: var(--text-dim); flex:none; }
  .log-entry .dot{ width:8px; height:8px; border-radius:50%; flex:none; }
  .log-entry.p .dot{ background: var(--p-color); box-shadow:0 0 6px var(--p-glow); }
  .log-entry.s .dot{ background: var(--s-color); box-shadow:0 0 6px var(--s-glow); }
  .log-entry.sys .dot{ background: var(--amber); box-shadow:0 0 6px var(--amber-glow); }
  .log-entry .msg{ color: var(--text); }
  .log-entry .msg b{ color:#fff; }

  /* ---------- Timeline panel (signature element) ---------- */
  .timeline-panel{ margin-top:18px; }
  .timeline-body{ padding: 16px 20px 20px; }
  .timeline-axis{
    position: relative;
    height: 22px;
    margin-left: 128px;
    border-bottom: 1px solid var(--line);
    font-family: var(--font-mono);
    font-size: 10.5px;
    color: var(--text-dim);
  }
  .timeline-axis span{ position:absolute; bottom:4px; transform: translateX(-50%); }
  .tl-row{ display:flex; align-items:center; height:38px; }
  .tl-row .name{
    width: 108px; flex: none; font-weight:700; font-size:13px;
    display:flex; align-items:center; gap:8px;
  }
  .tl-row .name .chip{ width:8px; height:8px; border-radius:50%; background:#3a4c6c; flex:none; }
  .tl-row .track{
    position:relative; flex:1; height: 14px;
    background: repeating-linear-gradient(90deg, var(--line-soft) 0, var(--line-soft) 1px, transparent 1px, transparent 40px);
    border-radius: 4px;
  }
  .tl-band{
    position:absolute; top:0; bottom:0; left:0; width:0;
    background: linear-gradient(90deg, rgba(79,209,255,0.16), rgba(255,107,91,0.16));
    border-radius: 4px;
    transition: width .05s linear, left .05s linear;
  }
  .tl-tick{ position:absolute; top:-4px; bottom:-4px; width:2px; opacity:0; transition: opacity .2s ease; }
  .tl-tick.p{ background: var(--p-color); box-shadow: 0 0 6px var(--p-glow); }
  .tl-tick.s{ background: var(--s-color); box-shadow: 0 0 6px var(--s-glow); }
  .tl-tick.show{ opacity:1; }
  .tl-playhead{
    position:absolute; top:-6px; bottom:-6px; width:2px; background: var(--amber);
    box-shadow: 0 0 8px var(--amber-glow); left:0; opacity:0; transition: opacity .2s ease;
  }
  .tl-playhead.show{ opacity:1; }
  .tl-readout{
    width: 190px; flex:none; text-align:right;
    font-family: var(--font-mono); font-size: 12px; color: var(--text-dim);
    padding-left: 12px;
  }
  .tl-readout b{ color: var(--text); }
  .tl-readout .gap{ color: var(--amber); font-weight:700; }

  .hint-footer{
    display:flex; align-items:flex-start; gap:10px;
    margin-top:14px; padding-top:14px; border-top:1px solid var(--line);
    font-size: 13px; color: var(--text-dim); line-height:1.6;
  }
  .hint-footer .mark{ color: var(--amber); font-size:16px; flex:none; }

  ::selection{ background: var(--amber); color:#1a1406; }
</style>
</head>
<body>
<div class="app">

  <header>
    <div class="title-block">
      <h1>地震波シミュレーター <span class="accent">P波</span> / <span style="color:var(--s-color)">S波</span></h1>
      <span class="subtitle">震源から広がる2つの波を見て、距離と時間の関係をつかもう</span>
    </div>
    <div class="clock">経過時間 <b id="clockVal">0.0</b>秒</div>
  </header>

  <main>
    <!-- ===== MAP ===== -->
    <section class="panel map-wrap">
      <div class="panel-head">
        <h2><span class="num">01</span>マップ(クリックして震源を設定)</h2>
      </div>
      <div style="position:relative;">
        <div id="mapHint">地図上をクリック — そこが震源になります</div>
        <svg id="mapSvg" viewBox="0 0 900 600" xmlns="http://www.w3.org/2000/svg">
          <defs>
            <pattern id="grid" width="30" height="30" patternUnits="userSpaceOnUse">
              <path d="M 30 0 L 0 0 0 30" fill="none" stroke="rgba(255,255,255,0.035)" stroke-width="1"/>
            </pattern>
            <filter id="glowP" x="-60%" y="-60%" width="220%" height="220%">
              <feGaussianBlur stdDeviation="3" result="b"/>
              <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
            </filter>
            <filter id="glowS" x="-60%" y="-60%" width="220%" height="220%">
              <feGaussianBlur stdDeviation="3" result="b"/>
              <feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge>
            </filter>
            <radialGradient id="seaGrad" cx="50%" cy="35%" r="75%">
              <stop offset="0%" stop-color="#0d1524"/>
              <stop offset="100%" stop-color="#060910"/>
            </radialGradient>
          </defs>

          <rect x="0" y="0" width="900" height="600" fill="url(#seaGrad)"/>
          <rect x="0" y="0" width="900" height="600" fill="url(#grid)"/>

          <!-- simplified stylised landmass: Kyushu + Chugoku/Kinki coastline -->
          <g id="land">
            <path d="M 30,370 C 5,410 0,460 35,505 C 70,548 150,555 205,520
                     C 245,495 250,455 225,425 C 260,410 300,395 335,382
                     C 375,367 415,358 455,343 C 505,325 555,308 605,286
                     C 655,264 715,244 765,208 C 800,183 828,153 838,118
                     C 848,150 842,183 820,212 C 795,246 755,270 710,294
                     C 660,320 605,340 555,362 C 505,384 460,398 415,415
                     C 375,430 340,442 300,455 C 265,467 235,478 210,495
                     C 175,518 120,522 80,500 C 45,480 25,420 30,370 Z"
                  fill="var(--land)" stroke="var(--land-line)" stroke-width="2"/>
            <!-- shikoku hint, decorative only -->
            <path d="M 460,400 C 500,388 555,392 590,408 C 615,420 615,442 585,452
                     C 545,464 495,458 468,438 C 452,426 445,410 460,400 Z"
                  fill="var(--land)" stroke="var(--land-line)" stroke-width="1.5" opacity="0.85"/>
          </g>

          <!-- wave circles (inserted dynamically) -->
          <g id="waveLayer"></g>

          <!-- epicenter marker -->
          <g id="epicenterMarker" style="display:none;">
            <circle id="epiPulse" cx="0" cy="0" r="10" fill="none" stroke="var(--amber)" stroke-width="2" opacity="0.6"/>
            <path id="epiStar" d="M0,-11 L2.9,-3.9 L10.8,-3.4 L4.6,1.7 L6.8,9.5 L0,5.1 L-6.8,9.5 L-4.6,1.7 L-10.8,-3.4 L-2.9,-3.9 Z"
                  fill="var(--amber)" stroke="#3a2a06" stroke-width="1"/>
          </g>

          <!-- city markers -->
          <g id="cities"></g>
        </svg>
      </div>
      <div class="legend">
        <span class="item"><span class="sw p"></span>P波(初期微動)秒速7km</span>
        <span class="item"><span class="sw s"></span>S波(主要動)秒速4km</span>
        <span class="item"><span class="sw star"></span>震源</span>
        <span class="scale"><span class="scale-bar"></span>100km</span>
      </div>
    </section>

    <!-- ===== SIDEBAR ===== -->
    <aside class="sidebar">

      <div class="panel controls">
        <div class="panel-head" style="margin:-14px -16px 4px; padding:12px 16px;">
          <h2><span class="num">02</span>操作パネル</h2>
        </div>

        <div>
          <div class="field-label">再生コントロール</div>
          <div class="btn-row">
            <button class="primary" id="btnPlay">▶ 再生</button>
            <button class="ghost" id="btnReset">⟲ リセット</button>
          </div>
        </div>

        <div>
          <div class="field-label">再生速度</div>
          <div class="speed-group">
            <button class="speed active" data-speed="1">1 ×</button>
            <button class="speed" data-speed="0.5">0.5 ×</button>
          </div>
        </div>
      </div>

      <div class="panel">
        <div class="panel-head">
          <h2><span class="num">03</span>震源からの距離</h2>
        </div>
        <div class="info-body" id="infoBody">
          <div class="placeholder">地図をクリックすると、各都市までの直線距離がここに表示されます。</div>
        </div>
      </div>

      <div class="panel">
        <div class="panel-head">
          <h2><span class="num">04</span>到達ログ</h2>
        </div>
        <div class="log-body" id="logBody">
          <div class="log-entry sys"><span class="dot"></span><span class="msg">震源が設定されるとログが表示されます</span></div>
        </div>
      </div>

    </aside>
  </main>

  <!-- ===== TIMELINE ===== -->
  <section class="panel timeline-panel">
    <div class="panel-head">
      <h2><span class="num">05</span>到達タイムライン(初期微動継続時間)</h2>
    </div>
    <div class="timeline-body">
      <div id="timelineRows">
        <div class="placeholder">震源を設定すると、都市ごとのP波・S波到達タイミングがここにバーで表示されます。</div>
      </div>
      <div class="timeline-axis" id="timelineAxis" style="display:none;"></div>

      <div class="hint-footer">
        <span class="mark">💡</span>
        <span>初期微動継続時間(P波が届いてからS波が届くまでの時間)が<b style="color:var(--text)">長いほど</b>、震源からの距離は<b style="color:var(--text)">遠い</b>――これが「大森公式」の考え方の基本だよ。バーの色がついた区間の長さを、都市ごとに見比べてみよう。</span>
      </div>
    </div>
  </section>

</div>

<script>
(function(){
  "use strict";

  // ---------- Physics constants ----------
  const V_P = 7;          // km/s
  const V_S = 4;          // km/s
  const KM_PER_PX = 0.807; // scale of stylised map
  const BASE_SIM_RATE = 15; // simulated seconds per real second at 1x

  // ---------- Cities ----------
  const CITIES = [
    { id:'fukuoka',   name:'福岡', x:150, y:420, labelDy: 26, labelAnchor:'middle' },
    { id:'yamaguchi', name:'山口', x:270, y:360, labelDy: -16, labelAnchor:'middle' },
    { id:'hiroshima', name:'広島', x:360, y:320, labelDy: -16, labelAnchor:'middle' },
    { id:'kobe',      name:'神戸', x:680, y:230, labelDy: -16, labelAnchor:'middle' },
    { id:'osaka',     name:'大阪', x:722, y:255, labelDy: 26,  labelAnchor:'middle' },
  ];

  // ---------- State ----------
  let epicenter = null;      // {x,y}
  let running = false;
  let simTime = 0;           // seconds
  let speedFactor = 1;
  let lastFrameTs = null;
  let finished = false;
  let cityState = {};        // id -> {distKm, tP, tS, pHit, sHit}
  let maxTimelineTime = 60;

  // ---------- DOM refs ----------
  const svg = document.getElementById('mapSvg');
  const waveLayer = document.getElementById('waveLayer');
  const citiesLayer = document.getElementById('cities');
  const epiMarker = document.getElementById('epicenterMarker');
  const mapHint = document.getElementById('mapHint');
  const clockVal = document.getElementById('clockVal');
  const infoBody = document.getElementById('infoBody');
  const logBody = document.getElementById('logBody');
  const timelineRows = document.getElementById('timelineRows');
  const timelineAxis = document.getElementById('timelineAxis');
  const btnPlay = document.getElementById('btnPlay');
  const btnReset = document.getElementById('btnReset');
  const speedBtns = document.querySelectorAll('.speed');

  // ---------- Build city markers ----------
  CITIES.forEach(c => {
    const g = document.createElementNS('http://www.w3.org/2000/svg','g');
    g.setAttribute('data-city', c.id);

    const ringP = document.createElementNS('http://www.w3.org/2000/svg','circle');
    ringP.setAttribute('class','city-ring'); ringP.setAttribute('cx', c.x); ringP.setAttribute('cy', c.y);
    ringP.setAttribute('r', 13); ringP.setAttribute('stroke','var(--p-color)'); ringP.setAttribute('id', 'ring-p-'+c.id);

    const ringS = document.createElementNS('http://www.w3.org/2000/svg','circle');
    ringS.setAttribute('class','city-ring'); ringS.setAttribute('cx', c.x); ringS.setAttribute('cy', c.y);
    ringS.setAttribute('r', 18); ringS.setAttribute('stroke','var(--s-color)'); ringS.setAttribute('id', 'ring-s-'+c.id);

    const dot = document.createElementNS('http://www.w3.org/2000/svg','circle');
    dot.setAttribute('class','city-dot'); dot.setAttribute('cx', c.x); dot.setAttribute('cy', c.y);
    dot.setAttribute('r', 6); dot.setAttribute('id', 'dot-'+c.id);

    const label = document.createElementNS('http://www.w3.org/2000/svg','text');
    label.setAttribute('class','city-label'); label.setAttribute('x', c.x); label.setAttribute('y', c.y + c.labelDy);
    label.setAttribute('text-anchor', c.labelAnchor); label.textContent = c.name;

    g.appendChild(ringS); g.appendChild(ringP); g.appendChild(dot); g.appendChild(label);
    citiesLayer.appendChild(g);
  });

  // ---------- Helpers ----------
  function svgPoint(evt){
    const pt = svg.createSVGPoint();
    pt.x = evt.clientX; pt.y = evt.clientY;
    const inv = svg.getScreenCTM().inverse();
    const p = pt.matrixTransform(inv);
    return { x: p.x, y: p.y };
  }

  function fmt(t){ return t.toFixed(1); }

  function pushLog(kind, html){
    if(logBody.querySelector('.placeholder-log')) logBody.innerHTML = '';
    const el = document.createElement('div');
    el.className = 'log-entry ' + kind;
    el.innerHTML = '<span class="dot"></span><span class="t">' + fmt(simTime) + '秒</span><span class="msg">' + html + '</span>';
    logBody.appendChild(el);
    logBody.scrollTop = logBody.scrollHeight;
  }

  function clearLog(msg){
    logBody.innerHTML = '<div class="log-entry sys"><span class="dot"></span><span class="msg">' + msg + '</span></div>';
  }

  // ---------- Set epicenter ----------
  function setEpicenter(pt){
    epicenter = pt;
    simTime = 0;
    finished = false;
    running = true;
    lastFrameTs = null;
    mapHint.classList.add('hidden');

    epiMarker.style.display = '';
    epiMarker.setAttribute('transform', 'translate(' + pt.x + ',' + pt.y + ')');

    // compute per-city data
    cityState = {};
    let maxS = 0;
    CITIES.forEach(c => {
      const pxDist = Math.hypot(c.x - pt.x, c.y - pt.y);
      const kmDist = pxDist * KM_PER_PX;
      const tP = kmDist / V_P;
      const tS = kmDist / V_S;
      cityState[c.id] = { distKm: kmDist, tP, tS, pHit:false, sHit:false };
      if(tS > maxS) maxS = tS;
      const dot = document.getElementById('dot-'+c.id);
      dot.classList.remove('hit-p','hit-s');
      document.getElementById('ring-p-'+c.id).classList.remove('show');
      document.getElementById('ring-s-'+c.id).classList.remove('show');
    });
    maxTimelineTime = Math.max(20, maxS * 1.15);

    renderInfoTable();
    buildTimeline();
    clearLog('震源を設定しました。波が広がり始めます…');

    // reset wave circles
    waveLayer.innerHTML = '';
    const cP = document.createElementNS('http://www.w3.org/2000/svg','circle');
    cP.setAttribute('id','circleP'); cP.setAttribute('cx', pt.x); cP.setAttribute('cy', pt.y);
    cP.setAttribute('r', 0); cP.setAttribute('fill','none'); cP.setAttribute('stroke','var(--p-color)');
    cP.setAttribute('stroke-width', 2.5); cP.setAttribute('filter','url(#glowP)'); cP.setAttribute('opacity', 0.9);

    const cS = document.createElementNS('http://www.w3.org/2000/svg','circle');
    cS.setAttribute('id','circleS'); cS.setAttribute('cx', pt.x); cS.setAttribute('cy', pt.y);
    cS.setAttribute('r', 0); cS.setAttribute('fill','none'); cS.setAttribute('stroke','var(--s-color)');
    cS.setAttribute('stroke-width', 2.5); cS.setAttribute('filter','url(#glowS)'); cS.setAttribute('opacity', 0.9);

    waveLayer.appendChild(cP); waveLayer.appendChild(cS);

    setPlayUI(true);
    requestAnimationFrame(tick);
  }

  // ---------- Info table ----------
  function renderInfoTable(){
    let rows = CITIES.map(c => {
      const s = cityState[c.id];
      return '<tr><td class="city-name">' + c.name + '</td><td>' + s.distKm.toFixed(1) + ' km</td></tr>';
    }).join('');
    infoBody.innerHTML =
      '<table class="distances"><thead><tr><th>都市</th><th>震源からの距離</th></tr></thead><tbody>' + rows + '</tbody></table>';
  }

  // ---------- Timeline ----------
  function buildTimeline(){
    timelineRows.innerHTML = '';
    CITIES.forEach(c => {
      const row = document.createElement('div');
      row.className = 'tl-row';
      row.innerHTML =
        '<div class="name"><span class="chip"></span>' + c.name + '</div>' +
        '<div class="track">' +
          '<div class="tl-band" id="band-'+c.id+'"></div>' +
          '<div class="tl-tick p" id="tick-p-'+c.id+'"></div>' +
          '<div class="tl-tick s" id="tick-s-'+c.id+'"></div>' +
          '<div class="tl-playhead" id="playhead-'+c.id+'"></div>' +
        '</div>' +
        '<div class="tl-readout" id="readout-'+c.id+'">P: -- / S: -- / <span class="gap">--</span></div>';
      timelineRows.appendChild(row);
    });
    // axis ticks
    timelineAxis.style.display = '';
    timelineAxis.innerHTML = '';
    const steps = 6;
    for(let i=0;i<=steps;i++){
      const t = (maxTimelineTime / steps) * i;
      const pct = (t / maxTimelineTime) * 100;
      const span = document.createElement('span');
      span.style.left = pct + '%';
      span.textContent = t.toFixed(0) + 's';
      timelineAxis.appendChild(span);
    }
  }

  function updateTimelineFrame(){
    CITIES.forEach(c => {
      const s = cityState[c.id];
      const band = document.getElementById('band-'+c.id);
      const tickP = document.getElementById('tick-p-'+c.id);
      const tickS = document.getElementById('tick-s-'+c.id);
      const playhead = document.getElementById('playhead-'+c.id);
      if(!band) return;

      const pPct = Math.min(100, (s.tP / maxTimelineTime) * 100);
      const sPct = Math.min(100, (s.tS / maxTimelineTime) * 100);
      const nowPct = Math.min(100, (simTime / maxTimelineTime) * 100);

      playhead.style.left = nowPct + '%';
      playhead.classList.add('show');

      if(s.pHit){
        tickP.style.left = pPct + '%'; tickP.classList.add('show');
      }
      if(s.sHit){
        tickS.style.left = sPct + '%'; tickS.classList.add('show');
        band.style.left = pPct + '%';
        band.style.width = Math.max(0, sPct - pPct) + '%';
      }

      const ro = document.getElementById('readout-'+c.id);
      const pTxt = s.pHit ? fmt(s.tP)+'s' : '--';
      const sTxt = s.sHit ? fmt(s.tS)+'s' : '--';
      const gapTxt = s.sHit ? fmt(s.tS - s.tP)+'s' : '--';
      ro.innerHTML = 'P: <b>' + pTxt + '</b> / S: <b>' + sTxt + '</b> / <span class="gap">' + gapTxt + '</span>';
    });
  }

  // ---------- Animation loop ----------
  function tick(ts){
    if(!epicenter) return;
    if(lastFrameTs === null) lastFrameTs = ts;
    const dtReal = (ts - lastFrameTs) / 1000;
    lastFrameTs = ts;

    if(running && !finished){
      simTime += dtReal * BASE_SIM_RATE * speedFactor;
    }

    const rPpx = (simTime * V_P) / KM_PER_PX;
    const rSpx = (simTime * V_S) / KM_PER_PX;

    document.getElementById('circleP').setAttribute('r', Math.max(0, rPpx));
    document.getElementById('circleS').setAttribute('r', Math.max(0, rSpx));

    // epicenter pulse
    const pulseR = 10 + (Math.sin(simTime * 3) + 1) * 3;
    document.getElementById('epiPulse').setAttribute('r', pulseR);

    // check city hits
    let allDone = true;
    CITIES.forEach(c => {
      const s = cityState[c.id];
      const pxDist = Math.hypot(c.x - epicenter.x, c.y - epicenter.y);

      if(!s.pHit && rPpx >= pxDist){
        s.pHit = true;
        document.getElementById('dot-'+c.id).classList.add('hit-p');
        const ring = document.getElementById('ring-p-'+c.id);
        ring.classList.add('show');
        pushLog('p', c.name + 'に<b style="color:var(--p-color)">P波</b>が到達!(震源距離 ' + s.distKm.toFixed(1) + 'km)');
      }
      if(!s.sHit && rSpx >= pxDist){
        s.sHit = true;
        document.getElementById('dot-'+c.id).classList.add('hit-s');
        const ring = document.getElementById('ring-s-'+c.id);
        ring.classList.add('show');
        const gap = s.tS - s.tP;
        pushLog('s', c.name + 'に<b style="color:var(--s-color)">S波</b>が到達!(初期微動継続時間 ' + gap.toFixed(1) + '秒)');
      }
      if(!s.sHit) allDone = false;
    });

    updateTimelineFrame();
    clockVal.textContent = fmt(simTime);

    if(allDone && !finished){
      finished = true;
      running = false;
      setPlayUI(false);
      pushLog('sys', '全ての都市にS波が到達しました。シミュレーション終了。');
    }

    requestAnimationFrame(tick);
  }

  // ---------- Controls ----------
  function setPlayUI(isRunning){
    btnPlay.textContent = isRunning ? '⏸ 一時停止' : '▶ 再生';
  }

  btnPlay.addEventListener('click', () => {
    if(!epicenter) return;
    if(finished){
      // restart from zero if user presses play after completion
      finished = false;
    }
    running = !running;
    lastFrameTs = null;
    setPlayUI(running);
  });

  btnReset.addEventListener('click', () => {
    epicenter = null;
    running = false;
    finished = false;
    simTime = 0;
    lastFrameTs = null;
    waveLayer.innerHTML = '';
    epiMarker.style.display = 'none';
    mapHint.classList.remove('hidden');
    clockVal.textContent = '0.0';
    setPlayUI(false);

    CITIES.forEach(c => {
      document.getElementById('dot-'+c.id).classList.remove('hit-p','hit-s');
      document.getElementById('ring-p-'+c.id).classList.remove('show');
      document.getElementById('ring-s-'+c.id).classList.remove('show');
    });

    infoBody.innerHTML = '<div class="placeholder">地図をクリックすると、各都市までの直線距離がここに表示されます。</div>';
    timelineRows.innerHTML = '<div class="placeholder">震源を設定すると、都市ごとのP波・S波到達タイミングがここにバーで表示されます。</div>';
    timelineAxis.style.display = 'none';
    clearLog('震源が設定されるとログが表示されます');
  });

  speedBtns.forEach(b => {
    b.addEventListener('click', () => {
      speedBtns.forEach(x => x.classList.remove('active'));
      b.classList.add('active');
      speedFactor = parseFloat(b.dataset.speed);
      lastFrameTs = null;
    });
  });

  svg.addEventListener('click', (evt) => {
    const p = svgPoint(evt);
    if(p.x < 0 || p.x > 900 || p.y < 0 || p.y > 600) return;
    setEpicenter(p);
  });

})();
</script>
</body>
</html>

このアプリでできること(HOW TO USE)

  • 地図をクリックすると、そこが「震源」になります

  • 震源からP波(青)S波(赤)が同心円状に広がっていきます

  • 各都市に波が届くと、到達時刻がログとタイムラインに記録されます

  • 「震源からの距離」と「初期微動継続時間」の関係が、目で見て分かります

まとめ:AIで「自分だけの教材」を手に入れる

「欲しい教材がなければ、自分で一瞬で作れる」時代になりました。

重い処理でなければ無料版のClaudeでも十分作れますし、自分で少しパラメータを変えて遊ぶこともできます。ぜひ皆さんも、日々の「こんな教材があったらな…」をAIと一緒に形にしてみてくださいね!

おまけ:著作権について

このアプリのコードの著作権は作者(なぎ太|りかのせんせい)にあります。 そのうえで、どなたでも自由に編集・アレンジしていただいて構いません。

コードが読めなくても、コードをそのまま自分のClaude(claude.ai)に貼り付ければ、その場でシミュレーターが表示され、Claudeに日本語で命令するだけで中身を書き換えてもらえます。

  • 授業用に都市や地域を変更する

  • 色やデザインを変える

  • 機能を追加・削除する

    など、学校や塾など、教育の現場で自由に使えるように好きなようにカスタマイズしてお使いください。

また、何回もチャットでやりとりしながら、自分好みにカスタマイズできるのも、AI時代のいいところですね!

何か分からないことがあれば、私に聞くよりも、
ClaudeやAIに直接「〇〇のやり方が分からない」と聞いてみてください。
丁寧に案内してくれます。

ぜひ、自分好みに調整してアプリを育ててみてください。
どんな調整をしたのか教えていただけると嬉しいです😁


いいなと思ったら応援しよう!