第9回 タイピングWebアプリ制作|成績分析・CSVバックアップ編

この記事は約184分で読めます。

このシリーズについて

第8回では、通常練習の成績を段別に計測し、正常終了した1プレイごとの履歴をlocalStorageへ保存できるようにしました。

今回は、その保存した成績をグラフで見えるようにし、さらにCSVへバックアップ・復元できるようにします。

タイピングラボ Ver.0.9では、「保存する」から一歩進んで、「上達を確認する」「別PCへ持っていく」「もしものときに戻す」までをまとめて作っていきます。

AIと作業を分担して作ります

今回も、必要な機能や画面の使い方、実際に触ったときの違和感や修正点は私が考え、HTML・CSS・JavaScriptのソースコード作成はAIのノンに担当してもらいました。

AIへ全部おまかせするのではなく、「こうしたい」「ここは分かりにくい」「この動きの方が使いやすい」と相談しながら、一緒に作っていく形です。

今回作るもの

今回のテーマは、分析する・バックアップするです。

  • 通常練習の速度を折れ線グラフで表示する
  • 直近5回の移動平均を表示する
  • 数字段・上段・ホーム段・下段・テンキーの速度を棒グラフで比較する
  • 正確率の推移を折れ線グラフで表示する
  • 直近10回・30回・100回・すべてを切り替える
  • 保存された数値から、やさしい分析コメントを表示する
  • 通常練習とチャレンジの全履歴をBOM付きCSVへ書き出す
  • CSVを追加インポートする
  • 現在の成績を全消去してCSVから復元する
  • playIdを使って重複履歴を防ぐ
  • CSVに致命的なエラーがあった場合は、インポート全体を中止する

チャレンジモードは、今回は分析グラフへ混ぜません。チャレンジはスコアとランキングを楽しむモードとして、その役割を分けています。

第8回から追加する機能

第8回では、通常練習とチャレンジの成績保存、段別集計、ローカルランキングまで作りました。

第9回では、その保存済みデータを使って、新しく次の機能を追加します。

  • 成績グラフ
  • 直近5回の移動平均
  • 段別速度比較
  • 正確率の推移
  • 分析コメント
  • CSVバックアップ
  • 追加インポート
  • 全消去してインポート
  • CSVの完全検証

ファイル構成

今回もファイルは3つです。

typing-lab/
├ index.html
├ style.css
└ script.js

グラフもCSV処理も外部ライブラリを使わず、今回の3ファイルだけで動かしています。

完成したソースコード

最初に、今回完成したタイピングラボ Ver.0.9のソースコードを掲載します。

index.html のソースコードを表示
<!doctype html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>タイピングラボ Ver.0.9</title>
    <link rel="stylesheet" href="style.css" />
  </head>
  <body>
    <main class="app">
      <header class="app__header">
        <div>
          <div class="brand-line"><p class="app__eyebrow">のんびり研究所</p><p class="app-version">タイピングラボ Ver.0.9</p></div>
          <div class="title-line">
            <h1 id="app-title">タイピング練習</h1>
            <nav class="record-links" aria-label="成績メニュー">
              <button id="open-records" type="button">成績を見る</button>
              <button id="open-ranking" type="button">ランキング</button>
              <button id="open-charts" type="button">グラフを見る</button>
              <button id="open-csv" type="button">CSV</button>
            </nav>
          </div>
        </div>
        <dl class="status" aria-label="練習状況">
          <div class="status__item"><dt>残り時間</dt><dd><span id="time">60</span><small>秒</small></dd></div>
          <div class="status__item"><dt id="score-label">打数</dt><dd id="score">0</dd></div>
          <div id="hits-status" class="status__item" hidden><dt>打数</dt><dd id="hits">0</dd></div>
          <div class="status__item"><dt>ミス</dt><dd id="misses">0</dd></div>
        </dl>
      </header>

      <section id="play-area" class="play-area" aria-label="タイピング練習エリア">
        <div id="falling-key" class="falling-key" aria-live="off" hidden>A</div>
        <div id="challenge-keys" class="challenge-keys" aria-live="off"></div>
        <div id="effect-layer" class="effect-layer" aria-hidden="true"></div>
        <div id="combo-display" class="combo-display" hidden><strong><span id="combo-count">0</span> COMBO</strong><small>スコア ×<span id="combo-rate">1.0</span></small></div>
        <div id="combo-flash" class="combo-flash" aria-hidden="true"></div>
        <div id="numpad-question" class="numpad-question" hidden>
          <div class="number-reel" aria-label="テンキー入力問題"><div class="number-reel__frame" aria-hidden="true"></div><div id="number-track" class="number-track" aria-live="polite"></div></div>
          <p class="comma-note">カンマは打ちません</p>
        </div>

        <div id="start-panel" class="panel start-panel">
          <p class="panel__label">練習モードを選びましょう</p>
          <fieldset class="mode-selector">
            <legend class="visually-hidden">練習モード</legend>
            <label class="mode-option"><input type="checkbox" name="practice-mode" value="number" /><span>数字段<small>1 2 3 4 5 6 7 8 9 0</small></span></label>
            <label class="mode-option"><input type="checkbox" name="practice-mode" value="top" /><span>上段<small>Q W E R T Y U I O P</small></span></label>
            <label class="mode-option"><input type="checkbox" name="practice-mode" value="home" checked /><span>ホーム段<small>A S D F G H J K L ;</small></span></label>
            <label class="mode-option"><input type="checkbox" name="practice-mode" value="bottom" /><span>下段<small>Z X C V B N M , . /</small></span></label>
            <div class="mode-divider" aria-hidden="true"></div>
            <label class="mode-option mode-option--numpad"><input type="checkbox" name="practice-mode" value="numpad" /><span>テンキー<small>3~5桁+Enter</small></span></label>
          </fieldset>
          <div class="challenge-controls">
            <button id="challenge-toggle" class="challenge-toggle" type="button" aria-pressed="false">チャレンジモード</button>
            <label id="level-control" class="level-control" hidden><span>Level <strong id="level-value">1</strong></span><input id="level-slider" type="range" min="1" max="10" value="1" /></label>
          </div>
          <p id="selection-warning" class="selection-warning" role="alert" hidden>練習する段を1つ以上選択してください</p>
          <div class="start-actions"><p class="panel__main"><kbd>Space</kbd> キーでスタート</p><div class="sound-setting"><span>SE</span><button id="sound-toggle" class="sound-toggle" type="button" aria-pressed="true">ON</button><small>※音が出ます</small></div></div>
          <p id="start-description" class="panel__sub">60秒間、表示された文字を入力しましょう</p>
        </div>
        <div id="countdown" class="countdown" aria-live="assertive" hidden></div>
        <div id="exit-panel" class="panel exit-panel" role="dialog" aria-modal="true" aria-labelledby="exit-title" hidden>
          <p class="panel__label">練習を一時停止しています</p><h2 id="exit-title">練習を途中で終了しますか?</h2>
          <div class="exit-panel__buttons"><button id="exit-practice" class="dialog-button dialog-button--danger" type="button">終了する</button><button id="continue-practice" class="dialog-button" type="button">続ける</button></div>
        </div>
        <div id="result-panel" class="panel result" hidden>
          <p class="panel__label">練習終了!</p><h2>今回の結果</h2>
          <div class="result__scores">
            <p><span id="final-score-label">打数</span><strong id="final-score">0</strong></p>
            <p id="final-hits-item" hidden>打数<strong id="final-hits">0</strong></p>
            <p>ミス<strong id="final-misses">0</strong></p>
            <p id="final-max-combo-item" hidden>最大コンボ数<strong id="final-max-combo">0</strong></p>
            <p id="final-accuracy-item" hidden>正確率<strong id="final-accuracy">0.0%</strong></p>
            <p id="final-speed-item" hidden>速度<strong id="final-speed">0.00打/秒</strong><small id="final-speed-minute">0打/分</small></p>
          </div>
          <div id="result-details" class="result-details"></div>
          <p id="ranking-result" class="ranking-result" hidden></p>
          <div class="result-actions"><button id="result-record-link" type="button">成績を見る</button><button id="result-ranking-link" type="button" hidden>ランキングを見る</button></div>
          <div class="result-retry">
            <p class="panel__sub">もう一度練習するときは <kbd>Space</kbd> キー</p>
            <button id="change-mode" class="change-mode" type="button">練習モード変更</button>
          </div>
        </div>
      </section>

      <section class="guide" aria-label="キーと担当指ガイド">
        <div class="guide__header"><p id="guide-title" class="guide__title">光った指を、光ったキーに置いてみましょう</p><p id="exit-hint" class="exit-hint" hidden><kbd>Esc</kbd>:途中終了</p></div>
        <div id="standard-guide"><div class="keyboard-viewport"><div id="screen-keyboard" class="screen-keyboard" aria-label="4段の画面キーボード"></div></div><div id="hands" class="hands" aria-label="左右の担当指"></div></div>
        <div id="numpad-guide" class="numpad-guide" hidden><div id="screen-numpad" class="screen-numpad" aria-label="テンキー"></div><div id="numpad-hand" class="numpad-hand" aria-label="テンキーの右手担当指"></div></div>
      </section>
      <svg id="placement-line" class="placement-line" aria-hidden="true"><line x1="0" y1="0" x2="0" y2="0"></line></svg>
      <p id="message" class="message" aria-live="polite">練習モードを選び、Spaceキーを押してください</p>
    </main>

    <section id="records-screen" class="records-screen" role="dialog" aria-modal="true" aria-labelledby="records-title" hidden>
      <div class="records-window"><header><div><p class="panel__label">タイピングラボ Ver.0.9</p><h2 id="records-title">保存された成績</h2></div><button id="close-records" class="close-records" type="button" aria-label="閉じる">×</button></header>
        <div class="records-tabs" role="tablist"><button id="records-tab" class="is-active" type="button">練習成績</button><button id="ranking-tab" type="button">ランキング</button><button id="charts-tab" type="button">分析グラフ</button><button id="csv-tab" type="button">CSV管理</button></div>
        <div id="records-content" class="records-content"></div>
        <div id="ranking-content" class="records-content" hidden><div class="ranking-filters"><label>種別<select id="ranking-mode"><option value="standard">段別チャレンジ</option><option value="numpad">テンキーチャレンジ</option></select></label><label>Level<select id="ranking-level"><option value="all">全Level</option></select></label></div><div id="ranking-list"></div></div>
        <div id="charts-content" class="records-content" hidden>
          <div class="chart-toolbar"><label>表示範囲<select id="chart-range"><option value="10">直近10回</option><option value="30" selected>直近30回</option><option value="100">直近100回</option><option value="all">すべて</option></select></label><span id="chart-count"></span></div>
          <div id="analysis-comment" class="analysis-comment"></div>
          <section class="chart-card"><h3>速度・上達グラフ</h3><p>各プレイの速度と、直近5回の移動平均です。</p><div id="speed-chart" class="chart-frame"></div></section>
          <section class="chart-card"><h3>段別速度</h3><p>選択中の範囲に含まれるデータを段ごとに集計しています。</p><div id="row-chart" class="chart-frame"></div></section>
          <section class="chart-card"><h3>正確率</h3><p>各プレイの正確率の推移です。</p><div id="accuracy-chart" class="chart-frame"></div></section>
        </div>
        <div id="csv-content" class="records-content" hidden>
          <section class="csv-card"><h3>CSVへエクスポート</h3><p>通常練習・チャレンジ・ランキング元データを、すべてBOM付きCSVへ保存します。</p><button id="export-csv" class="csv-button csv-button--primary" type="button">バックアップCSVを保存</button></section>
          <section class="csv-card"><h3>CSVからインポート</h3><p>タイピングラボで書き出したバックアップCSVを選択してください。1件でも読み込めない項目がある場合は、全体を中止します。</p><label class="file-picker"><span>CSVファイルを選ぶ</span><input id="import-file" type="file" accept=".csv,text/csv" /></label><div id="import-summary" class="import-summary" aria-live="polite">CSVファイルを選ぶと、内容を検証して件数を表示します。</div><div class="import-actions"><button id="import-add" class="csv-button csv-button--primary" type="button" disabled>追加インポート</button><button id="import-replace" class="csv-button csv-button--danger" type="button" disabled>全消去してインポート</button></div></section>
        </div>
        <footer><button id="delete-records" class="delete-records" type="button">成績データをすべて削除</button></footer>
      </div>
    </section>
    <script src="script.js"></script>
  </body>
</html>
style.css のソースコードを表示
:root {
  color-scheme: light;
  --ink: #253044;
  --muted: #68758b;
  --line: #dbe5ed;
  --accent: #1c92a9;
  --accent-dark: #126b7d;
  --glow: #40e6ff;
  --back: #eef7f8;
  font-family: "Yu Gothic UI", "Hiragino Kaku Gothic ProN", Meiryo, sans-serif;
}
* {
  box-sizing: border-box;
}
body {
  min-width: 320px;
  min-height: 100vh;
  margin: 0;
  color: var(--ink);
  background:
    radial-gradient(circle at 15% 10%, #dff4f6 0, transparent 34rem),
    linear-gradient(145deg, #f8fbfc, var(--back));
}
button,
input {
  font: inherit;
}
.app {
  width: min(1100px, calc(100% - 32px));
  margin: auto;
  padding: 26px 0 20px;
}
.app__header {
  display: flex;
  align-items: flex-end;
  justify-content: space-between;
  gap: 24px;
  margin-bottom: 14px;
}
.app__eyebrow {
  margin: 0 0 4px;
  color: var(--accent-dark);
  font-size: 0.82rem;
  font-weight: 700;
  letter-spacing: 0.12em;
}
h1 {
  margin: 0;
  color: #126b7d;
  font-size: clamp(1.35rem, 3vw, 2rem);
}
.title-line {
  display: flex;
  align-items: center;
  gap: 12px;
}
.is-challenge h1 { color: #c35d0b; }
.status {
  display: flex;
  gap: 8px;
  margin: 0;
}
.status__item {
  min-width: 92px;
  padding: 9px 14px;
  border: 1px solid var(--line);
  border-radius: 12px;
  background: #ffffffe0;
  text-align: center;
  box-shadow: 0 5px 18px #29535e0f;
}
.status dt {
  color: var(--muted);
  font-size: 0.75rem;
  font-weight: 700;
}
.status dd {
  margin: 2px 0 0;
  color: var(--accent-dark);
  font-size: 1.55rem;
  font-weight: 800;
  line-height: 1;
}
.status small {
  font-size: 0.72rem;
}
.play-area {
  position: relative;
  height: clamp(420px, 48vh, 480px);
  overflow: hidden;
  border: 3px solid #b9dbe0;
  border-radius: 22px;
  background:
    linear-gradient(#ffffffb8, #ffffffe8),
    repeating-linear-gradient(90deg, transparent 0 79px, #d9eef0 80px);
  box-shadow: 0 18px 45px #254c581f;
}
.falling-key,
.effect-key,
.challenge-key {
  position: absolute;
  z-index: 4;
  top: 0;
  left: 0;
  width: 1.25em;
  color: var(--accent-dark);
  font:
    900 clamp(4rem, 10vw, 6.5rem) / 1 Arial,
    sans-serif;
  text-align: center;
  text-shadow: 0 5px 0 #d4edf0;
  will-change: transform, opacity;
}
.challenge-keys {
  position: absolute;
  z-index: 4;
  inset: 0;
  pointer-events: none;
}
.challenge-key {
  will-change: transform, opacity;
}
.challenge-key.is-hit {
  animation: challenge-key-hit 0.2s ease-out forwards;
}
.combo-display {
  position: absolute;
  z-index: 7;
  top: 16px;
  right: 18px;
  min-width: 150px;
  padding: 9px 14px;
  border: 2px solid #ffbf47;
  border-radius: 14px;
  color: #813b00;
  background: #fff8dced;
  text-align: center;
  box-shadow: 0 7px 24px #dc750035;
  pointer-events: none;
}
.combo-display strong,
.combo-display small { display: block; }
.combo-display strong { font: 900 1.45rem/1 Arial, sans-serif; }
.combo-display small { margin-top: 4px; font-weight: 800; }
.combo-display.is-milestone { animation: combo-milestone .48s ease-out; }
.combo-flash {
  position: absolute;
  z-index: 6;
  inset: 0;
  background: #ef334433;
  opacity: 0;
  pointer-events: none;
}
.combo-flash.is-active { animation: combo-break .25s ease-out; }
.effect-key.is-hit {
  animation: effect-key-hit 0.18s ease-out forwards;
}
.effect-layer {
  position: absolute;
  z-index: 3;
  inset: 0;
  pointer-events: none;
  overflow: hidden;
}
.panel {
  position: absolute;
  z-index: 8;
  top: 50%;
  left: 50%;
  width: min(760px, calc(100% - 32px));
  padding: clamp(18px, 3vw, 28px);
  border: 1px solid var(--line);
  border-radius: 20px;
  background: #fffffff5;
  text-align: center;
  box-shadow: 0 20px 50px #254c5826;
  transform: translate(-50%, -50%);
}
.start-panel {
  padding-block: 16px;
}
.start-panel .mode-selector {
  gap: 6px 8px;
  margin-top: 8px;
}
.start-panel .mode-option span {
  min-height: 54px;
  padding-block: 6px;
}
.start-panel .mode-divider {
  margin-block: 2px 0;
}
.start-panel .challenge-controls {
  margin-top: 8px;
}
.start-panel .panel__main {
  margin: 0;
}
.start-panel .sound-setting {
  margin: 0;
}
.panel__label,
.panel__sub {
  margin: 0;
  color: var(--muted);
}
.panel__main {
  margin: 14px 0 9px;
  font-size: clamp(1.1rem, 3vw, 1.45rem);
  font-weight: 800;
}
kbd {
  display: inline-block;
  padding: 0.28em 0.65em;
  border: 1px solid #9eb8bf;
  border-bottom-width: 4px;
  border-radius: 8px;
  color: var(--accent-dark);
  background: #f8ffff;
  font: inherit;
}
.mode-selector {
  display: grid;
  grid-template-columns: repeat(4, 1fr);
  gap: 8px;
  margin: 14px 0 0;
  padding: 0;
  border: 0;
}
.mode-option {
  cursor: pointer;
}
.mode-option--numpad {
  grid-column: 2/4;
}
.mode-option input {
  position: absolute;
  opacity: 0;
  pointer-events: none;
}
.mode-option span {
  position: relative;
  display: grid;
  place-items: center;
  min-height: 62px;
  padding: 8px 5px;
  border: 2px solid #c7dade;
  border-radius: 12px;
  color: #40576a;
  background: #f7fbfc;
  font-weight: 800;
  transition: 0.15s;
}
.mode-option small {
  display: block;
  margin-top: 4px;
  color: var(--muted);
  font:
    600 0.62rem Arial,
    sans-serif;
}
.mode-option input:checked + span {
  color: #07404c;
  border-color: #29c5db;
  background: #d8fbff;
  box-shadow:
    0 0 0 3px #40e6ff2e,
    0 7px 18px #1c92a91f;
}
.mode-option input:checked + span::before {
  content: "✓";
  position: absolute;
  top: 3px;
  left: 7px;
  color: #08798d;
  font-size: 1rem;
  line-height: 1;
}
.mode-divider {
  grid-column: 1/-1;
  height: 1px;
  margin: 5px 0 1px;
  background: linear-gradient(90deg, transparent, #aacbd1 12%, #aacbd1 88%, transparent);
}
.challenge-controls {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 14px;
  margin-top: 12px;
}
.challenge-toggle {
  padding: 8px 15px;
  border: 2px solid #b7c8ce;
  border-radius: 999px;
  color: #526477;
  background: #f7fbfc;
  font-weight: 800;
  cursor: pointer;
}
.challenge-toggle.is-on {
  color: #6a2500;
  border-color: #ff9c3d;
  background: #fff0d9;
  box-shadow: 0 0 0 3px #ffae4933;
}
.level-control {
  display: flex;
  align-items: center;
  gap: 8px;
  color: #813b00;
  font-weight: 800;
}
.level-control input { width: min(180px, 24vw); accent-color: #f08022; }
.selection-warning {
  margin: 9px 0 -5px;
  color: #b32635;
  font-weight: 800;
}
.start-actions {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(14px, 3vw, 30px);
  margin: 10px 0 7px;
}
.placement-line {
  position: fixed;
  z-index: 18;
  inset: 0;
  width: 100%;
  height: 100%;
  opacity: 0;
  pointer-events: none;
}
.placement-line.is-visible { opacity: 1; }
.placement-line line {
  stroke: #55becb;
  stroke-width: 3;
  stroke-linecap: round;
  stroke-dasharray: 5 7;
  filter: drop-shadow(0 0 4px #71eafa);
}
.is-challenge .mode-option input:checked + span {
  color: #672d00;
  border-color: #ff9c3d;
  background: #fff0d9;
  box-shadow: 0 0 0 3px #ffae4933, 0 7px 18px #d66e161f;
}
.is-challenge .mode-option input:checked + span::before { color: #c35d0b; }
.sound-setting {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  margin: 0 0 9px;
  color: var(--muted);
  font-weight: 700;
}
.sound-setting small {
  font-weight: 500;
}
.sound-toggle {
  min-width: 54px;
  padding: 4px 10px;
  border: 1px solid #75bdc9;
  border-radius: 99px;
  color: #07404c;
  background: #d8fbff;
  font-weight: 800;
  cursor: pointer;
}
.sound-toggle[aria-pressed="false"] {
  color: #68758b;
  border-color: #becbd0;
  background: #edf1f2;
}
.countdown {
  position: absolute;
  z-index: 9;
  inset: 0;
  display: grid;
  place-items: center;
  color: var(--accent-dark);
  background: #ffffffd6;
  font:
    900 clamp(6rem, 22vw, 13rem) / 1 Arial,
    sans-serif;
  text-shadow: 0 8px 0 #d4edf0;
}
.exit-panel h2,
.result h2 {
  margin: 8px 0 20px;
}
.exit-panel__buttons {
  display: flex;
  justify-content: center;
  gap: 10px;
}
.result__scores {
  display: flex;
  flex-wrap: nowrap;
  justify-content: center;
  gap: 10px;
}
.dialog-button,
.change-mode {
  padding: 10px 18px;
  border: 1px solid #9eb8bf;
  border-radius: 99px;
  color: var(--accent-dark);
  background: #f6fcfd;
  font-weight: 800;
  cursor: pointer;
}
.dialog-button--danger {
  color: #9b2833;
  border-color: #dfaab0;
  background: #fff1f2;
}
.result__scores {
  margin-bottom: 18px;
}
.result__scores p {
  flex: 0 1 150px;
  min-width: 0;
  margin: 0;
  padding: 14px;
  border-radius: 12px;
  color: var(--muted);
  background: #edf8f9;
  font-size: 0.82rem;
  font-weight: 700;
}
.result__scores strong {
  display: block;
  margin-top: 4px;
  color: var(--accent-dark);
  font-size: 2.2rem;
}
.result__scores small {
  display: block;
  margin-top: 2px;
  color: var(--muted);
  font-size: .7rem;
  font-weight: 700;
}
#final-speed {
  white-space: nowrap;
  font-size: 1.45rem;
}
.result-retry {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 12px;
  flex-wrap: wrap;
  margin-top: 12px;
}
.result-retry .panel__sub {
  margin: 0;
}
.result-retry .change-mode {
  flex: 0 0 auto;
  padding: 7px 14px;
}
.guide {
  margin-top: 12px;
  padding: 12px 14px 9px;
  border: 1px solid #cfe2e6;
  border-radius: 18px;
  background: #ffffffe0;
  box-shadow: 0 10px 30px #254c5814;
}
.guide__header {
  position: relative;
  display: flex;
  align-items: center;
  justify-content: center;
  min-height: 24px;
  margin-bottom: 9px;
}
.guide__title {
  margin: 0;
  color: var(--muted);
  font-size: 0.78rem;
  font-weight: 700;
}
.exit-hint {
  position: absolute;
  right: 0;
  margin: 0;
  color: var(--muted);
  font-size: 0.78rem;
}
.exit-hint kbd {
  padding: 0.12em 0.45em;
  border-bottom-width: 2px;
}
.keyboard-viewport {
  overflow-x: auto;
  padding: 4px 3px 7px;
}
.screen-keyboard {
  --key-width: clamp(43px, 6.3vw, 68px);
  width: calc(var(--key-width) * 10 + 63px);
  min-width: 493px;
  margin: auto;
}
.keyboard-row {
  display: grid;
  grid-template-columns: repeat(10, var(--key-width));
  gap: 7px;
  width: max-content;
  margin-bottom: 6px;
  transition: 0.18s;
}
.keyboard-row[data-row="top"] {
  margin-left: 15px;
}
.keyboard-row[data-row="home"] {
  margin-left: 29px;
}
.keyboard-row[data-row="bottom"] {
  margin-left: 43px;
}
.keyboard-row.is-inactive-row {
  opacity: 0.48;
  filter: saturate(0.65);
}
.keycap,
.numpad-key {
  position: relative;
  min-width: 0;
  min-height: 48px;
  padding: 0;
  border: 1px solid #a9c0c6;
  border-bottom: 5px solid #819ba2;
  border-radius: 9px;
  color: #33475b;
  background: linear-gradient(#fff, #eaf1f3);
  font:
    800 clamp(0.95rem, 2vw, 1.28rem) / 1 Arial,
    sans-serif;
  box-shadow: 0 3px 6px #254c581f;
  transition: 0.08s;
}
.keycap:before,
.numpad-key:before {
  content: "";
  position: absolute;
  inset: 3px;
  z-index: -1;
  border-radius: 7px;
  opacity: 0.28;
}
.keycap[data-zone*="little"]:before,
.numpad-key[data-finger="little"]:before {
  background: #f6acc1;
}
.keycap[data-zone*="ring"]:before,
.numpad-key[data-finger="ring"]:before {
  background: #efc57f;
}
.keycap[data-zone*="middle"]:before,
.numpad-key[data-finger="middle"]:before {
  background: #d9dc75;
}
.keycap[data-zone*="index"]:before,
.numpad-key[data-finger="index"]:before {
  background: #8edcc4;
}
.numpad-key[data-finger="thumb"]:before {
  background: #b6c8ef;
}
.keycap[data-bump="true"]:after,
.numpad-key[data-bump="true"]:after {
  content: "";
  position: absolute;
  left: 33%;
  right: 33%;
  bottom: 7px;
  height: 3px;
  border-radius: 2px;
  background: #718c94;
}
.is-zone-target {
  opacity: 1 !important;
  border-color: #68cbd8 !important;
  background: #e5fbfd !important;
  box-shadow: 0 0 10px #40e6ff4d !important;
}
.is-target {
  z-index: 2;
  color: #073e4b !important;
  background: #bdf8ff !important;
  box-shadow:
    0 0 0 2px #5edbec,
    0 0 18px #40e6ffd9 !important;
  transform: translateY(-1px);
}
.is-correct {
  color: #fff !important;
  background: #23cde6 !important;
  transform: translateY(2px) scale(0.98);
  box-shadow:
    0 0 0 3px #fff,
    0 0 30px var(--glow) !important;
}
.is-wrong {
  z-index: 3;
  color: #fff !important;
  background: #ef5360 !important;
  border-color: #b32635 !important;
  box-shadow: 0 0 18px #ef3243d9 !important;
  animation: wrong-shake 0.24s linear;
}
.hands {
  display: flex;
  justify-content: center;
  gap: clamp(34px, 11vw, 130px);
  margin: 7px auto 0;
}
.hand {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  padding: 5px 13px 0;
  border-bottom: 7px solid #b8c9ce;
  border-radius: 0 0 24px 24px;
}
.finger,
.numpad-finger {
  display: grid;
  place-items: start center;
  width: clamp(22px, 4vw, 34px);
  height: var(--finger-height);
  padding-top: 6px;
  border: 2px solid #a9bcc2;
  border-radius: 15px 15px 8px 8px;
  color: #60727a;
  background: linear-gradient(#edf5f6, #dce9eb);
  font-size: clamp(0.56rem, 1.4vw, 0.68rem);
  font-weight: 800;
  transition: 0.1s;
}
.finger.is-target,
.numpad-finger.is-target {
  transform: translateY(-4px);
}
.numpad-question {
  position: absolute;
  z-index: 5;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding-bottom: 30px;
}
.current-question {
  position: relative;
  z-index: 4;
  display: flex;
  align-items: center;
  min-height: 1.2em;
  color: #33475b;
  font:
    900 clamp(3.1rem, 9vw, 6rem) / 1 Arial,
    sans-serif;
  letter-spacing: 0.04em;
  text-shadow: 0 4px 0 #e3eff1;
}
.current-question span {
  transition: 0.08s;
}
.current-question span.is-entered {
  color: #1cb9cf;
  text-shadow: 0 0 14px #40e6ff99;
}
.current-question span.is-current {
  text-decoration: underline;
  text-decoration-color: #ef9f24;
  text-underline-offset: 8px;
}
.current-question .comma {
  margin-right: 0.05em;
  color: #738595;
  font-style: normal;
}
.question-enter {
  margin-left: 0.35em;
  font-size: 0.27em;
  letter-spacing: 0;
  text-shadow: none;
}
.question-enter.is-ready {
  color: #073e4b;
  border-color: #29c5db;
  background: #bdf8ff;
  box-shadow: 0 0 18px #40e6ffd9;
}
.next-question {
  position: relative;
  z-index: 4;
  display: flex;
  align-items: center;
  gap: 12px;
  margin-top: 24px;
  padding: 10px 20px;
  border: 1px solid #b9dbe0;
  border-radius: 13px;
  background: #edf8f9d9;
  color: var(--muted);
}
.next-question span {
  font-size: 0.8rem;
  font-weight: 800;
}
.next-question strong {
  color: #40576a;
  font:
    800 clamp(1.3rem, 4vw, 2rem) / 1 Arial,
    sans-serif;
  letter-spacing: 0.05em;
}
.comma-note {
  z-index: 4;
  margin: 8px 0 0;
  color: var(--muted);
  font-size: 0.76rem;
}
.current-question.is-promoting {
  animation: promote 0.3s ease-out;
}
.question-shatter {
  position: absolute;
  z-index: 6;
  top: 39%;
  animation: shatter 0.28s ease-out forwards;
  pointer-events: none;
}
.numpad-guide {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: clamp(34px, 8vw, 100px);
  padding: 4px 0 10px;
}
.screen-numpad {
  display: grid;
  grid-template-columns: repeat(3, 62px);
  grid-template-rows: repeat(4, 52px);
  gap: 7px;
}
.numpad-key {
  font-size: 1.1rem;
}
.numpad-key[data-numpad-key="0"] {
  grid-column: 1/3;
}
.numpad-key[data-numpad-key="."] {
  grid-column: 3;
  opacity: 0.32;
  filter: grayscale(1);
}
.numpad-key[data-numpad-key="Enter"] {
  grid-column: 1/4;
}

/* Ver.0.9:成績・ランキング・分析・CSV管理 */
.brand-line { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
.brand-line .app__eyebrow { margin: 0; }
.app-version { margin: 0; padding-left: 10px; border-left: 1px solid #9fc5cc; color: #496a73; font-size: .72rem; font-weight: 800; letter-spacing: .04em; }
.record-links { display: flex; flex-wrap: wrap; gap: 7px; margin: 0; }
.record-links button, .result-actions button { padding: 5px 11px; border: 1px solid #9ecbd2; border-radius: 99px; color: #176273; background: #effcfe; font-size: .72rem; font-weight: 800; cursor: pointer; }
.is-challenge .record-links button, .is-challenge .result-actions button { border-color: #e8b783; color: #9b4d0a; background: #fff6ed; }
.result { width: min(940px, calc(100% - 28px)); max-height: calc(100% - 20px); overflow: auto; }
.result-details { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 8px; width: min(850px, 100%); margin: 0 auto 12px; }
.result-card { padding: 10px; border: 1px solid #cce3e7; border-radius: 12px; background: #f8feff; text-align: left; }
.result-card h3 { margin: 0 0 6px; color: #155b69; font-size: .9rem; }
.result-card p { margin: 2px 0; color: #536c74; font-size: .78rem; }
.ranking-result { margin: 8px 0; color: #b55208; font-weight: 900; }
.result-actions { display: flex; justify-content: center; gap: 8px; margin: 8px 0; }

.records-screen { position: fixed; z-index: 100; inset: 0; padding: 20px; overflow: auto; background: #16333bd9; }
.records-window { width: min(980px, 100%); min-height: min(680px, calc(100vh - 40px)); margin: 0 auto; padding: 22px; border-radius: 22px; background: #f8fcfd; box-shadow: 0 20px 80px #0007; }
.records-window > header { display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 1px solid #cce0e4; }
.records-window h2 { margin: 4px 0 14px; color: #164c58; }
.close-records { width: 40px; height: 40px; border: 0; border-radius: 50%; color: #49656b; background: #e6f1f3; font-size: 1.5rem; cursor: pointer; }
.records-tabs { display: flex; gap: 8px; margin: 18px 0; }
.records-tabs button { padding: 9px 18px; border: 1px solid #b6d0d5; border-radius: 99px; color: #49656b; background: #fff; font-weight: 800; cursor: pointer; }
.records-tabs button.is-active { color: #fff; border-color: #19869a; background: #19869a; }
.summary-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(145px, 1fr)); gap: 10px; }
.summary-card { padding: 14px; border: 1px solid #cce0e4; border-radius: 14px; background: #fff; }
.summary-card h3 { margin: 0 0 8px; color: #1b6674; font-size: .9rem; }
.summary-card strong { display: block; color: #163e47; font-size: 1.35rem; }
.summary-card small { color: #6b7e83; }
.history-title { margin: 24px 0 10px; color: #345b64; }
.history-list { display: grid; gap: 8px; }
.history-item, .ranking-item { padding: 12px 14px; border: 1px solid #d4e4e7; border-radius: 12px; background: #fff; }
.history-item h4, .ranking-item h3 { margin: 0 0 5px; color: #215d69; }
.history-item p, .ranking-item p { margin: 2px 0; color: #586f75; font-size: .82rem; }
.ranking-filters { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 14px; }
.ranking-filters label { color: #45636a; font-size: .82rem; font-weight: 800; }
.ranking-filters select { margin-left: 6px; padding: 7px 10px; border: 1px solid #b5ced3; border-radius: 8px; background: #fff; }
#ranking-list { display: grid; gap: 8px; }
.ranking-item { border-left: 5px solid #ef912e; }
.empty-records { padding: 50px 20px; color: #71858a; text-align: center; }
.records-window footer { margin-top: 24px; padding-top: 16px; border-top: 1px solid #d4e4e7; text-align: right; }
.delete-records { padding: 8px 14px; border: 1px solid #dba4aa; border-radius: 9px; color: #9b2833; background: #fff1f2; font-weight: 800; cursor: pointer; }

.chart-toolbar { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin-bottom: 12px; padding: 10px 12px; border: 1px solid #c6dde1; border-radius: 12px; background: #fff; color: #45636a; font-size: .84rem; font-weight: 800; }
.chart-toolbar select { margin-left: 7px; padding: 7px 10px; border: 1px solid #acc9cf; border-radius: 8px; background: #fff; }
.analysis-comment { margin-bottom: 14px; padding: 14px 18px; border: 1px solid #bce0d6; border-left: 5px solid #27a47f; border-radius: 13px; background: #f2fcf8; color: #345c53; }
.analysis-comment h3 { margin: 0 0 7px; font-size: .95rem; }
.analysis-comment ul { margin: 0; padding-left: 1.3em; }
.analysis-comment li { margin: 4px 0; font-size: .84rem; }
.chart-card { margin: 12px 0; padding: 14px; border: 1px solid #d1e2e5; border-radius: 15px; background: #fff; }
.chart-card h3 { margin: 0 0 3px; color: #215d69; }
.chart-card > p { margin: 0 0 8px; color: #71858a; font-size: .78rem; }
.chart-frame { min-height: 220px; }
.data-chart { display: block; width: 100%; height: auto; overflow: visible; }
.chart-grid-line { stroke: #dbe8ea; stroke-width: 1; }
.chart-axis-label { fill: #6b7e83; font: 11px sans-serif; }
.chart-value-label { fill: #236878; font: 700 12px sans-serif; }
.chart-line { fill: none; stroke-linecap: round; stroke-linejoin: round; stroke-width: 3; }
.chart-line--main { stroke: #238fa5; }
.chart-line--average { stroke: #ef922f; stroke-width: 4; }
.chart-dot { fill: #fff; stroke: #238fa5; stroke-width: 3; }
.chart-bar { fill: #43a9bb; }
.chart-bar:hover, .chart-dot:hover { filter: brightness(.9); }
.chart-legend { display: flex; justify-content: center; gap: 18px; margin: -3px 0 0; color: #5d7479; font-size: .75rem; }
.chart-legend span::before { display: inline-block; width: 20px; height: 4px; margin-right: 6px; border-radius: 5px; vertical-align: middle; content: ""; }
.legend-main::before { background: #238fa5; }
.legend-average::before { background: #ef922f; }
.chart-empty { display: grid; min-height: 220px; place-items: center; margin: 0; color: #71858a; }

.csv-card { margin-bottom: 14px; padding: 18px; border: 1px solid #d0e2e5; border-radius: 15px; background: #fff; }
.csv-card h3 { margin: 0 0 6px; color: #215d69; }
.csv-card p { margin: 0 0 13px; color: #60787e; font-size: .84rem; line-height: 1.7; }
.csv-button, .file-picker span { display: inline-block; padding: 9px 15px; border: 1px solid #a9cbd1; border-radius: 9px; color: #176273; background: #effcfe; font-weight: 800; cursor: pointer; }
.csv-button--primary { color: #fff; border-color: #19869a; background: #19869a; }
.csv-button--danger { color: #a22c37; border-color: #d7a3a9; background: #fff1f2; }
.csv-button:disabled { cursor: not-allowed; opacity: .45; }
.file-picker input { position: absolute; width: 1px; height: 1px; overflow: hidden; opacity: 0; }
.import-summary { display: grid; gap: 4px; margin: 14px 0; padding: 13px; border: 1px dashed #b9d0d5; border-radius: 11px; color: #657b80; background: #f8fbfc; font-size: .82rem; }
.import-summary.is-valid { border-style: solid; border-color: #a8d9c9; color: #286451; background: #f0fbf7; }
.import-summary.is-error { border-style: solid; border-color: #e0aeb4; color: #8f2934; background: #fff3f4; }
.import-actions { display: flex; flex-wrap: wrap; gap: 9px; }

/* 14桁+カンマ+Enterでも中央枠に収める */
.number-row { box-sizing: border-box; padding: 0 10px; white-space: nowrap; font-size: clamp(1.45rem, 5.2vw, 3.4rem); letter-spacing: clamp(.01em, .15vw, .04em); }
.number-row[data-length="10"], .number-row[data-length="11"] { font-size: clamp(1.35rem, 4.1vw, 2.65rem); }
.number-row[data-length="12"], .number-row[data-length="13"], .number-row[data-length="14"] { font-size: clamp(1.12rem, 3.45vw, 2.15rem); letter-spacing: 0; }
.number-row .question-enter { flex: 0 0 auto; }

@media (max-width: 680px) {
  .records-screen { padding: 8px; }
  .records-window { min-height: calc(100vh - 16px); padding: 14px; border-radius: 15px; }
  .result__scores { gap: 5px; }
  .result__scores p { flex-basis: 118px; padding: 9px 5px; }
  .result__scores strong { font-size: clamp(1.15rem, 5.4vw, 2.2rem); }
  #final-speed { font-size: clamp(.9rem, 3.8vw, 1.45rem); }
  .title-line { align-items: flex-start; flex-direction: column; gap: 7px; }
  .record-links { margin-bottom: 8px; }
  .records-tabs { flex-wrap: wrap; }
  .records-tabs button { flex: 1 1 120px; }
  .chart-toolbar { align-items: flex-start; flex-direction: column; }
  .chart-frame { min-height: 150px; overflow-x: auto; }
  .data-chart { min-width: 610px; }
}

.numpad-hand {
  display: flex;
  align-items: flex-end;
  gap: 4px;
  padding: 6px 10px 0;
  border-bottom: 7px solid #b8c9ce;
  border-radius: 0 0 24px 24px;
}
.numpad-finger {
  width: 34px;
}
.numpad-finger[data-finger="thumb"] {
  height: 42px;
}
.numpad-finger[data-finger="index"] {
  height: 57px;
}
.numpad-finger[data-finger="middle"] {
  height: 63px;
}
.numpad-finger[data-finger="ring"] {
  height: 57px;
}
.numpad-finger[data-finger="little"] {
  height: 46px;
}
.laser {
  position: fixed;
  z-index: 2;
  height: 9px;
  border-radius: 99px;
  background: linear-gradient(
    #0877bd 0 20%,
    #69d8ff 20% 40%,
    #fff 40% 60%,
    #69d8ff 60% 80%,
    #0877bd 80%
  );
  box-shadow:
    0 0 5px #0877bd,
    0 0 12px #39c8ff,
    0 0 24px #1e9de2cc;
  transform-origin: 0 50%;
  pointer-events: none;
  animation: laser-shot 0.2s ease-out forwards;
}
.wrong-laser {
  position: fixed;
  z-index: 20;
  height: 5px;
  border-radius: 99px;
  background: linear-gradient(#665978, #93869e, #554b61);
  box-shadow: 0 0 5px #5c4c6dbf;
  transform-origin: 0 50%;
  pointer-events: none;
  animation: wrong-laser-shot 0.11s ease-out forwards;
}
.spark {
  position: absolute;
  width: 8px;
  height: 8px;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 0 12px var(--glow);
  animation: spark-burst 0.28s ease-out forwards;
}
.message {
  min-height: 1.5em;
  margin: 10px 0 0;
  color: var(--muted);
  text-align: center;
  font-size: 0.92rem;
}
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap;
  border: 0;
}
[hidden] {
  display: none !important;
}
@keyframes wrong-shake {
  0%,
  100% {
    transform: translateX(0);
  }
  20% {
    transform: translateX(-5px);
  }
  40% {
    transform: translateX(5px);
  }
  60% {
    transform: translateX(-4px);
  }
  80% {
    transform: translateX(3px);
  }
}
@keyframes laser-shot {
  from {
    opacity: 1;
    clip-path: inset(0 100% 0 0);
  }
  45% {
    clip-path: inset(0);
  }
  to {
    opacity: 0;
    clip-path: inset(0);
  }
}
@keyframes wrong-laser-shot {
  from {
    opacity: 0.9;
    clip-path: inset(0 100% 0 0);
  }
  70% {
    opacity: 0.8;
    clip-path: inset(0);
  }
  to {
    opacity: 0;
  }
}
@keyframes effect-key-hit {
  to {
    opacity: 0;
    filter: blur(5px);
    transform: translate(var(--effect-x, 0), var(--effect-y, 0)) scale(1.65)
      rotate(12deg);
  }
}
@keyframes challenge-key-hit {
  to { opacity: 0; filter: blur(5px); transform: translate(var(--hit-x, 0), var(--hit-y, 0)) scale(1.65) rotate(12deg); }
}
@keyframes combo-break {
  0% { opacity: 0; }
  35% { opacity: 1; }
  100% { opacity: 0; }
}
@keyframes combo-milestone {
  0%,100% { transform: scale(1) translate(0); }
  25% { transform: scale(1.3) translate(-3px,2px) rotate(-2deg); }
  40% { transform: scale(1.24) translate(4px,-2px) rotate(2deg); }
  55% { transform: scale(1.18) translate(-3px,1px); }
}
@keyframes spark-burst {
  from {
    opacity: 1;
    transform: translate(-50%, -50%) scale(0.5);
  }
  to {
    opacity: 0;
    transform: translate(
        calc(-50% + var(--spark-x)),
        calc(-50% + var(--spark-y))
      )
      scale(0.1);
  }
}
@keyframes promote {
  from {
    opacity: 0.45;
    transform: translateY(58px) scale(0.65);
  }
  to {
    opacity: 1;
    transform: none;
  }
}
@keyframes shatter {
  to {
    opacity: 0;
    filter: blur(5px);
    transform: scale(1.55) rotate(6deg);
  }
}
@media (max-width: 720px) {
  .app {
    width: min(100% - 20px, 1100px);
    padding-top: 16px;
  }
  .app__header {
    align-items: stretch;
    flex-direction: column;
    gap: 10px;
  }
  .status {
    width: 100%;
  }
  .status__item {
    flex: 1;
    min-width: 0;
    padding: 8px 4px;
  }
  .play-area {
    height: 410px;
    border-radius: 16px;
  }
  .panel {
    padding: 16px 12px;
  }
  .mode-selector {
    grid-template-columns: repeat(2, 1fr);
  }
  .mode-option--numpad {
    grid-column: 1/3;
  }
  .mode-option span {
    min-height: 49px;
  }
  .challenge-controls { flex-direction: column; gap: 7px; }
  .level-control input { width: 180px; }
  .combo-display { top: 10px; right: 10px; min-width: 125px; }
  .combo-display strong { font-size: 1.2rem; }
  .screen-keyboard {
    --key-width: 43px;
    margin-left: 0;
  }
  .keycap {
    min-height: 43px;
  }
  .hands {
    gap: 18px;
  }
  .hand {
    gap: 2px;
    padding-inline: 4px;
  }
  .guide__header {
    justify-content: space-between;
    gap: 8px;
  }
  .exit-hint {
    position: static;
  }
  .numpad-guide {
    flex-direction: column;
    gap: 16px;
  }
  .screen-numpad {
    grid-template-columns: repeat(3, 56px);
    grid-template-rows: repeat(4, 48px);
  }
  .numpad-question {
    padding-bottom: 0;
  }
  .question-enter {
    display: block;
  }
  .next-question {
    margin-top: 20px;
  }
}

@keyframes promote {
  from {
    transform: translate(var(--promote-x), var(--promote-y))
      scale(var(--promote-scale));
    transform-origin: center;
  }
  to {
    transform: none;
    transform-origin: center;
  }
}

/* テンキー:視線を固定したまま一段ずつ送る数字リール */
.numpad-question {
  padding-bottom: 0;
}
.number-reel {
  --row-step: 88px;
  position: relative;
  width: min(620px, calc(100% - 32px));
  height: 286px;
  overflow: hidden;
}
.number-reel__frame {
  position: absolute;
  z-index: 2;
  top: 50%;
  left: 50%;
  width: min(520px, calc(100% - 24px));
  height: 76px;
  border: 3px solid #55d8e8;
  border-radius: 16px;
  background: #dffbff47;
  box-shadow:
    0 0 0 5px #fff9,
    0 8px 28px #1c92a92e,
    0 0 24px #40e6ff45;
  transform: translate(-50%, -50%);
  pointer-events: none;
}
.number-track {
  position: absolute;
  inset: 0;
}
.number-row {
  position: absolute;
  z-index: 3;
  top: 50%;
  left: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  width: min(500px, calc(100% - 44px));
  height: 70px;
  color: #526477;
  font:
    850 clamp(2rem, 6vw, 3.4rem) / 1 Arial,
    sans-serif;
  letter-spacing: 0.04em;
  opacity: 0.58;
  transform: translate(-50%, calc(-50% + var(--row-offset) * var(--row-step)))
    scale(0.82);
  transition:
    transform 0.28s cubic-bezier(0.22, 0.75, 0.25, 1),
    opacity 0.28s,
    color 0.28s;
  will-change: transform, opacity;
}
.number-row[data-offset="-1"] {
  --row-offset: -1;
  opacity: 0.18;
}
.number-row[data-offset="0"] {
  --row-offset: 0;
  z-index: 4;
  color: #253044;
  opacity: 1;
  transform: translate(-50%, calc(-50% + var(--row-offset) * var(--row-step)))
    scale(1);
}
.number-row[data-offset="1"] {
  --row-offset: 1;
}
.number-row[data-offset="2"] {
  --row-offset: 2;
  opacity: 0.3;
}
.number-row.is-new {
  opacity: 0;
}
.number-row span {
  transition:
    color 0.08s,
    text-shadow 0.08s;
}
.number-row span.is-entered {
  color: #1cb9cf;
  text-shadow: 0 0 14px #40e6ff99;
}
.number-row span.is-current {
  text-decoration: underline;
  text-decoration-color: #ef9f24;
  text-underline-offset: 8px;
}
.number-row .comma {
  margin-right: 0.05em;
  color: #738595;
  font-style: normal;
}
.number-row .question-enter {
  margin-left: 0.55em;
  font-size: 0.3em;
  letter-spacing: 0;
  text-shadow: none;
}
.comma-note {
  position: absolute;
  z-index: 5;
  bottom: 12px;
  margin: 0;
}
@media (max-width: 720px) {
  .number-reel {
    --row-step: 82px;
    width: 100%;
    height: 280px;
  }
  .number-reel__frame {
    width: calc(100% - 20px);
    height: 72px;
  }
  .number-row {
    width: calc(100% - 30px);
    font-size: clamp(1.8rem, 9vw, 3rem);
  }
  .number-row .question-enter {
    margin-left: 0.35em;
  }
}

/* 実物のテンキー配置:Enterは3とドットの右側で縦2段 */
.screen-numpad {
  grid-template-columns: repeat(4, 62px);
}
.numpad-key[data-numpad-key="7"] {
  grid-area: 1/1;
}
.numpad-key[data-numpad-key="8"] {
  grid-area: 1/2;
}
.numpad-key[data-numpad-key="9"] {
  grid-area: 1/3;
}
.numpad-key[data-numpad-key="4"] {
  grid-area: 2/1;
}
.numpad-key[data-numpad-key="5"] {
  grid-area: 2/2;
}
.numpad-key[data-numpad-key="6"] {
  grid-area: 2/3;
}
.numpad-key[data-numpad-key="1"] {
  grid-area: 3/1;
}
.numpad-key[data-numpad-key="2"] {
  grid-area: 3/2;
}
.numpad-key[data-numpad-key="3"] {
  grid-area: 3/3;
}
.numpad-key[data-numpad-key="0"] {
  grid-area: 4/1/5/3;
}
.numpad-key[data-numpad-key="."] {
  grid-area: 4/3;
}
.numpad-key[data-numpad-key="Enter"] {
  grid-area: 3/4/5/5;
}
@media (max-width: 720px) {
  .screen-numpad {
    grid-template-columns: repeat(4, 56px);
  }
}
@media (prefers-reduced-motion: reduce) {
  *,
  *:before,
  *:after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
  }
}
script.js のソースコードを表示
"use strict";

const KEYBOARD_ROWS = [
  {
    id: "number",
    label: "数字段",
    keys: [
      ["1", "1"],
      ["2", "2"],
      ["3", "3"],
      ["4", "4"],
      ["5", "5"],
      ["6", "6"],
      ["7", "7"],
      ["8", "8"],
      ["9", "9"],
      ["0", "0"],
    ],
  },
  {
    id: "top",
    label: "上段",
    keys: [
      ["q", "Q"],
      ["w", "W"],
      ["e", "E"],
      ["r", "R"],
      ["t", "T"],
      ["y", "Y"],
      ["u", "U"],
      ["i", "I"],
      ["o", "O"],
      ["p", "P"],
    ],
  },
  {
    id: "home",
    label: "ホームポジション段",
    keys: [
      ["a", "A"],
      ["s", "S"],
      ["d", "D"],
      ["f", "F"],
      ["g", "G"],
      ["h", "H"],
      ["j", "J"],
      ["k", "K"],
      ["l", "L"],
      [";", ";"],
    ],
  },
  {
    id: "bottom",
    label: "下段",
    keys: [
      ["z", "Z"],
      ["x", "X"],
      ["c", "C"],
      ["v", "V"],
      ["b", "B"],
      ["n", "N"],
      ["m", "M"],
      [",", ","],
      [".", "."],
      ["/", "/"],
    ],
  },
];
const FINGERS = [
  { hand: "left", finger: "little", label: "小指" },
  { hand: "left", finger: "ring", label: "薬指" },
  { hand: "left", finger: "middle", label: "中指" },
  { hand: "left", finger: "index", label: "人差" },
  { hand: "left", finger: "index", label: "人差" },
  { hand: "right", finger: "index", label: "人差" },
  { hand: "right", finger: "index", label: "人差" },
  { hand: "right", finger: "middle", label: "中指" },
  { hand: "right", finger: "ring", label: "薬指" },
  { hand: "right", finger: "little", label: "小指" },
];
const KEY_GUIDE = KEYBOARD_ROWS.flatMap((row) =>
  row.keys.map(([key, label], column) => ({
    key,
    label,
    row: row.id,
    ...FINGERS[column],
  })),
);
const MODE_LABELS = {
  number: "数字段",
  top: "上段",
  home: "ホームポジション段",
  bottom: "下段",
  numpad: "テンキー",
};
const NUMPAD_FINGERS = {
  1: "index",
  4: "index",
  7: "index",
  2: "middle",
  5: "middle",
  8: "middle",
  3: "ring",
  6: "ring",
  9: "ring",
  0: "thumb",
  Enter: "little",
};
const PRACTICE_TIME = 60,
  FALL_SPEED = 16;
const APP_VERSION = "0.9",
  STORAGE_KEY = "typingLabRecords",
  SCHEMA_VERSION = 1,
  STAT_IDS = ["number", "top", "home", "bottom", "numpad"];

function sortSelectedRows(rows) {
  const selected = new Set(rows);
  return STAT_IDS.filter((id) => selected.has(id));
}

const $ = (id) => document.getElementById(id);
const playArea = $("play-area"),
  fallingKey = $("falling-key"),
  effectLayer = $("effect-layer"),
  startPanel = $("start-panel"),
  countdown = $("countdown"),
  resultPanel = $("result-panel");
const timeDisplay = $("time"),
  scoreDisplay = $("score"),
  hitsDisplay = $("hits"),
  missesDisplay = $("misses"),
  finalScore = $("final-score"),
  finalHits = $("final-hits"),
  finalMisses = $("final-misses"),
  finalMaxCombo = $("final-max-combo"),
  message = $("message");
const screenKeyboard = $("screen-keyboard"),
  hands = $("hands"),
  standardGuide = $("standard-guide"),
  numpadGuide = $("numpad-guide"),
  screenNumpad = $("screen-numpad"),
  numpadHand = $("numpad-hand");
const numpadQuestion = $("numpad-question"),
  numberTrack = $("number-track"),
  soundToggle = $("sound-toggle"),
  exitPanel = $("exit-panel"),
  exitHint = $("exit-hint");
const modeInputs = [
  ...document.querySelectorAll('input[name="practice-mode"]'),
];
let state = "waiting",
  selectedMode = "standard",
  selectedRows = new Set(["home"]),
  practiceKeys = [],
  currentKey = "",
  score = 0,
  hits = 0,
  misses = 0,
  keyX = 0,
  keyY = 0,
  previousFrameTime = 0,
  finishTime = 0;
let animationId = 0,
  countdownTimerId = 0,
  nextKeyTimerId = 0,
  soundEnabled = true,
  audioContext = null,
  pausedAt = 0,
  pausedState = "playing";
let currentNumber = "",
  nextNumber = "",
  numberQueue = [],
  inputIndex = 0,
  transitionSerial = 0;
let challengeEnabled = false,
  challengeLevel = 1,
  combo = 0,
  maxCombo = 0,
  challengeLetters = [],
  placementTimerId = 0,
  placementIndex = 0;
const challengeToggle = $("challenge-toggle"),
  levelControl = $("level-control"),
  levelSlider = $("level-slider"),
  challengeKeys = $("challenge-keys"),
  comboDisplay = $("combo-display"),
  placementLine = $("placement-line");
const recordsScreen = $("records-screen"),
  recordsContent = $("records-content"),
  rankingContent = $("ranking-content"),
  chartsContent = $("charts-content"),
  csvContent = $("csv-content"),
  rankingList = $("ranking-list");
let playStartedAt = 0,
  questionStartedAt = 0,
  playStats = createEmptyStats(),
  lastSavedPlay = null,
  pendingImport = null;

buildKeyboard();
buildHands();
buildNumpad();
buildNumpadHand();
updateModeSelection();
startPlacementGuide();
document.addEventListener("keydown", handleKeydown);
modeInputs.forEach((input) =>
  input.addEventListener("change", () => handleModeChange(input)),
);
$("change-mode").addEventListener("click", returnToSelection);
soundToggle.addEventListener("click", toggleSound);
challengeToggle.addEventListener("click", toggleChallenge);
levelSlider.addEventListener("input", () => {
  challengeLevel = Number(levelSlider.value);
  $("level-value").textContent = challengeLevel;
  updateModeSelection();
});
$("exit-practice").addEventListener("click", () => finishPractice(true));
$("continue-practice").addEventListener("click", resumePractice);
$("open-records").addEventListener("click", () => openRecords("records"));
$("open-ranking").addEventListener("click", () => openRecords("ranking"));
$("open-charts").addEventListener("click", () => openRecords("charts"));
$("open-csv").addEventListener("click", () => openRecords("csv"));
$("result-record-link").addEventListener("click", () => openRecords("records"));
$("result-ranking-link").addEventListener("click", () => openRecords("ranking"));
$("close-records").addEventListener("click", closeRecords);
$("records-tab").addEventListener("click", () => switchRecordsTab("records"));
$("ranking-tab").addEventListener("click", () => switchRecordsTab("ranking"));
$("charts-tab").addEventListener("click", () => switchRecordsTab("charts"));
$("csv-tab").addEventListener("click", () => switchRecordsTab("csv"));
$("ranking-mode").addEventListener("change", renderRanking);
$("ranking-level").addEventListener("change", renderRanking);
$("chart-range").addEventListener("change", renderCharts);
$("export-csv").addEventListener("click", () => exportRecordsCsv());
$("import-file").addEventListener("change", handleImportFile);
$("import-add").addEventListener("click", importAdditionalRecords);
$("import-replace").addEventListener("click", replaceAllRecords);
$("delete-records").addEventListener("click", deleteAllRecords);
for (let level = 1; level <= 10; level++) {
  const option = document.createElement("option");
  option.value = String(level);
  option.textContent = `Level ${level}`;
  $("ranking-level").appendChild(option);
}

function buildKeyboard() {
  KEYBOARD_ROWS.forEach((rowData) => {
    const row = document.createElement("div");
    row.className = "keyboard-row";
    row.dataset.row = rowData.id;
    row.setAttribute("aria-label", rowData.label);
    rowData.keys.forEach(([keyName, label], column) => {
      const guide = KEY_GUIDE.find((item) => item.key === keyName),
        key = document.createElement("button");
      key.type = "button";
      key.className = "keycap";
      key.dataset.key = keyName;
      key.dataset.zone = `${guide.hand}-${guide.finger}`;
      key.dataset.bump = String(keyName === "f" || keyName === "j");
      key.textContent = label;
      key.tabIndex = -1;
      row.appendChild(key);
    });
    screenKeyboard.appendChild(row);
  });
}
function buildHands() {
  const order = {
      left: ["little", "ring", "middle", "index"],
      right: ["index", "middle", "ring", "little"],
    },
    labels = { little: "小", ring: "薬", middle: "中", index: "人差" },
    heights = { little: "43px", ring: "55px", middle: "62px", index: "57px" };
  ["left", "right"].forEach((side) => {
    const hand = document.createElement("div");
    hand.className = "hand";
    order[side].forEach((name) => {
      const finger = document.createElement("span");
      finger.className = "finger";
      finger.dataset.hand = side;
      finger.dataset.finger = name;
      finger.style.setProperty("--finger-height", heights[name]);
      finger.textContent = labels[name];
      hand.appendChild(finger);
    });
    hands.appendChild(hand);
  });
}
function buildNumpad() {
  [
    ["7", "7"],
    ["8", "8"],
    ["9", "9"],
    ["4", "4"],
    ["5", "5"],
    ["6", "6"],
    ["1", "1"],
    ["2", "2"],
    ["3", "3"],
    ["0", "0"],
    [".", "."],
    ["Enter", "Enter"],
  ].forEach(([name, label]) => {
    const key = document.createElement("button");
    key.type = "button";
    key.tabIndex = -1;
    key.className = "numpad-key";
    key.dataset.numpadKey = name;
    key.dataset.finger = NUMPAD_FINGERS[name] || "none";
    key.dataset.bump = String(name === "5");
    key.textContent = label;
    if (name === ".") key.classList.add("is-disabled");
    screenNumpad.appendChild(key);
  });
}
function buildNumpadHand() {
  [
    ["thumb", "親"],
    ["index", "人差"],
    ["middle", "中"],
    ["ring", "薬"],
    ["little", "小"],
  ].forEach(([name, label]) => {
    const finger = document.createElement("span");
    finger.className = "numpad-finger";
    finger.dataset.finger = name;
    finger.textContent = label;
    numpadHand.appendChild(finger);
  });
}
function handleModeChange(input) {
  if (input.value === "numpad") {
    selectedMode = "numpad";
    selectedRows.clear();
    modeInputs.forEach((item) => (item.checked = item === input));
  } else {
    if (selectedMode === "numpad") {
      selectedMode = "standard";
      selectedRows = new Set([input.value]);
    } else if (input.checked) selectedRows.add(input.value);
    else selectedRows.delete(input.value);
    modeInputs.forEach(
      (item) =>
        (item.checked =
          item.value !== "numpad" && selectedRows.has(item.value)),
    );
  }
  updateModeSelection();
  startPlacementGuide();
}
function updateModeSelection() {
  const isNumpad = selectedMode === "numpad";
  practiceKeys = isNumpad
    ? []
    : KEY_GUIDE.filter((item) => selectedRows.has(item.row)).map(
        (item) => item.key,
      );
  standardGuide.hidden = isNumpad;
  numpadGuide.hidden = !isNumpad;
  updateGuideTitle();
  document.querySelectorAll(".keyboard-row").forEach((row) => {
    const active = selectedRows.has(row.dataset.row);
    row.classList.toggle("is-active-row", active);
    row.classList.toggle("is-inactive-row", !active);
  });
  $("start-description").textContent = isNumpad
    ? `NumLockをONにして、数字の後にEnterを押しましょう${challengeEnabled ? `(3~${challengeLevel + 4}桁)` : ""}`
    : challengeEnabled
      ? `60秒チャレンジ:画面内に最大${challengeLevel}文字`
      : "60秒間、表示された文字を入力しましょう";
  document.body.classList.toggle("is-challenge", challengeEnabled);
  $("app-title").textContent = challengeEnabled
    ? "段別・テンキー チャレンジモード"
    : "タイピング練習";
  $("score-label").textContent = challengeEnabled ? "スコア" : "打数";
  $("final-score-label").textContent = challengeEnabled ? "スコア" : "打数";
  $("hits-status").hidden = !challengeEnabled;
  $("final-hits-item").hidden = !challengeEnabled;
  $("final-max-combo-item").hidden = !challengeEnabled;
  document.querySelectorAll('.mode-option input:not([value="numpad"]) + span').forEach((span) => {
    const base = span.dataset.baseLabel || span.firstChild.textContent.trim();
    span.dataset.baseLabel = base;
    span.firstChild.textContent = challengeEnabled ? `${base}(チャレンジモード)` : base;
  });
  levelControl.hidden = !challengeEnabled;
  message.textContent = selectedRows.size || isNumpad
    ? `${getModeLabel()}${challengeEnabled ? "チャレンジ" : ""}。Spaceキーを押してください`
    : "練習する段を選んでください";
}
function updateGuideTitle() {
  $("guide-title").textContent = state === "waiting"
    ? "光った指を、光ったキーに置いてみましょう"
    : "光っているキーを、光っている指で押しましょう";
}
function getModeLabel() {
  return selectedMode === "numpad"
    ? MODE_LABELS.numpad
    : KEYBOARD_ROWS.filter((row) => selectedRows.has(row.id))
        .map((row) => MODE_LABELS[row.id])
        .join("+");
}
function toggleChallenge() {
  challengeEnabled = !challengeEnabled;
  challengeToggle.setAttribute("aria-pressed", String(challengeEnabled));
  challengeToggle.classList.toggle("is-on", challengeEnabled);
  challengeToggle.textContent = `${challengeEnabled ? "✓ " : ""}チャレンジモード`;
  updateModeSelection();
}
function handleKeydown(event) {
  if (!recordsScreen.hidden) {
    if (event.code === "Escape") closeRecords();
    return;
  }
  if (event.code === "Escape" && (state === "playing" || state === "hit")) {
    event.preventDefault();
    pausePractice();
    return;
  }
  if (event.code === "Escape" && state === "paused") {
    event.preventDefault();
    resumePractice();
    return;
  }
  if (event.code === "Space" && (state === "waiting" || state === "finished")) {
    event.preventDefault();
    if (selectedMode !== "numpad" && selectedRows.size === 0) {
      $("selection-warning").hidden = false;
      message.textContent = "練習する段を1つ以上選択してください";
      return;
    }
    startCountdown();
    return;
  }
  if (
    state !== "playing" ||
    event.repeat ||
    event.ctrlKey ||
    event.altKey ||
    event.metaKey
  )
    return;
  if (selectedMode === "numpad") handleNumpadInput(event);
  else handleStandardInput(event);
}
function handleStandardInput(event) {
  const typed = event.key.toLowerCase();
  if (challengeEnabled) {
    const candidates = challengeLetters.filter((item) => item.key === typed && !item.hit);
    if (candidates.length) {
      event.preventDefault();
      hitChallengeLetter(candidates[Math.floor(Math.random() * candidates.length)]);
    } else if (event.key.length === 1) registerMiss(typed, getStandardKey(typed));
    return;
  }
  if (typed === currentKey) {
    event.preventDefault();
    handleCorrectStandard();
  } else if (event.key.length === 1) {
    registerMiss(typed, getStandardKey(typed) || getStandardKey(currentKey));
  }
}
function handleNumpadInput(event) {
  const typed = event.code.startsWith("Numpad")
    ? event.code === "NumpadEnter"
      ? "Enter"
      : event.code.replace("Numpad", "")
    : "";
  const expected =
    inputIndex < currentNumber.length ? currentNumber[inputIndex] : "Enter";
  if (typed === expected) {
    event.preventDefault();
    if (expected === "Enter") completeNumpadQuestion();
    else acceptNumpadDigit(typed);
  } else if (typed || event.key.length === 1 || event.key === "Enter")
    registerMiss(
      typed || event.key,
      getNumpadKey(typed) || getNumpadKey(expected),
    );
}
function startCountdown() {
  if (soundEnabled) getAudioContext();
  clearTimeout(countdownTimerId);
  clearTimeout(nextKeyTimerId);
  cancelAnimationFrame(animationId);
  transitionSerial++;
  stopPlacementGuide();
  state = "countdown";
  updateGuideTitle();
  score = hits = misses = 0;
  combo = 0;
  maxCombo = 0;
  playStats = createEmptyStats();
  lastSavedPlay = null;
  challengeLetters = [];
  challengeKeys.replaceChildren();
  updateComboDisplay();
  $("selection-warning").hidden = true;
  timeDisplay.textContent = PRACTICE_TIME;
  scoreDisplay.textContent = hitsDisplay.textContent = missesDisplay.textContent = "0";
  startPanel.hidden = true;
  resultPanel.hidden = true;
  fallingKey.hidden = true;
  numpadQuestion.hidden = true;
  effectLayer.replaceChildren();
  clearGuide();
  exitPanel.hidden = true;
  exitHint.hidden = true;
  countdown.hidden = false;
  message.textContent = `${getModeLabel()}のカウントダウン中です`;
  modeInputs.forEach((i) => (i.disabled = true));
  const labels = ["3", "2", "1", "START"];
  let index = 0;
  const show = () => {
    countdown.textContent = labels[index++];
    countdownTimerId = setTimeout(
      index < labels.length ? show : beginPractice,
      index < labels.length ? 1000 : 700,
    );
  };
  show();
}
function beginPractice() {
  state = "playing";
  countdown.hidden = true;
  exitHint.hidden = false;
  finishTime = performance.now() + PRACTICE_TIME * 1000;
  playStartedAt = performance.now();
  questionStartedAt = playStartedAt;
  previousFrameTime = performance.now();
  if (selectedMode === "numpad") {
    numberQueue = Array.from({ length: 4 }, randomNumber);
    currentNumber = numberQueue[0];
    nextNumber = numberQueue[1];
    inputIndex = 0;
    numpadQuestion.hidden = false;
    renderNumberReel();
    message.textContent =
      "数字を順番に入力し、最後にテンキーのEnterを押してください";
  } else {
    message.textContent = "表示された文字を、光っている指で押してください";
    if (challengeEnabled) fillChallengeLetters();
    else showNextStandardKey();
  }
  animationId = requestAnimationFrame(update);
}
function showNextStandardKey() {
  if (state !== "playing") return;
  fallingKey.classList.remove("is-hit");
  currentKey = practiceKeys[Math.floor(Math.random() * practiceKeys.length)];
  questionStartedAt = performance.now();
  fallingKey.textContent = currentKey.toUpperCase();
  fallingKey.hidden = false;
  const margin = 24,
    max = Math.max(
      margin,
      playArea.clientWidth - fallingKey.offsetWidth - margin,
    );
  keyX = margin + Math.random() * (max - margin);
  keyY = 54;
  drawKey();
  highlightStandard(currentKey);
}
function handleCorrectStandard() {
  recordCorrect(getRowForKey(currentKey));
  registerHit();
  score++;
  scoreDisplay.textContent = score;
  const key = getStandardKey(currentKey),
    oldKey = document.createElement("span");
  oldKey.className = "effect-key";
  oldKey.textContent = fallingKey.textContent;
  oldKey.style.setProperty("--effect-x", `${keyX}px`);
  oldKey.style.setProperty("--effect-y", `${keyY}px`);
  oldKey.style.transform = `translate(var(--effect-x),var(--effect-y))`;
  effectLayer.appendChild(oldKey);
  key?.classList.add("is-correct");
  shootLaser(key, oldKey);
  createSparks(oldKey);
  requestAnimationFrame(() => oldKey.classList.add("is-hit"));
  setTimeout(() => playSound("break"), 75);
  setTimeout(() => oldKey.remove(), 240);
  setTimeout(() => key?.classList.remove("is-correct"), 100);
  if (performance.now() >= finishTime) return finishPractice();
  previousFrameTime = performance.now();
  showNextStandardKey();
}
function randomNumber() {
  const maxLength = challengeEnabled ? challengeLevel + 4 : 5;
  const length = 3 + Math.floor(Math.random() * (maxLength - 2));
  let value = String(1 + Math.floor(Math.random() * 9));
  while (value.length < length) value += Math.floor(Math.random() * 10);
  return value;
}
function formatNumber(value) {
  return Number(value).toLocaleString("ja-JP");
}
function createNumberRow(value, offset) {
  const row = document.createElement("div");
  row.className = "number-row";
  row.dataset.offset = String(offset);
  row.dataset.value = value;
  row.dataset.length = String(value.length);
  row.setAttribute(
    "aria-label",
    offset === 0
      ? `現在の問題 ${formatNumber(value)}`
      : `次の問題 ${formatNumber(value)}`,
  );
  fillNumberRow(row, value, offset === 0);
  return row;
}
function fillNumberRow(row, value, isCurrent) {
  row.replaceChildren();
  [...value].forEach((digit, index) => {
    const span = document.createElement("span");
    span.textContent = digit;
    if (isCurrent)
      span.className =
        index < inputIndex
          ? "is-entered"
          : index === inputIndex
            ? "is-current"
            : "";
    row.appendChild(span);
    const remaining = value.length - index - 1;
    if (remaining > 0 && remaining % 3 === 0) {
      const comma = document.createElement("i");
      comma.textContent = ",";
      comma.className = "comma";
      row.appendChild(comma);
    }
  });
  const enter = document.createElement("kbd");
  enter.textContent = "Enter";
  enter.className = "question-enter";
  enter.classList.toggle("is-ready", isCurrent && inputIndex === value.length);
  row.appendChild(enter);
}
function renderNumberReel() {
  numberTrack.replaceChildren(
    ...numberQueue
      .slice(0, 3)
      .map((value, index) => createNumberRow(value, index)),
  );
  highlightNumpad(
    inputIndex < currentNumber.length ? currentNumber[inputIndex] : "Enter",
  );
}
function refreshCurrentRow() {
  const row = numberTrack.querySelector('.number-row[data-offset="0"]');
  if (row) fillNumberRow(row, currentNumber, true);
  highlightNumpad(
    inputIndex < currentNumber.length ? currentNumber[inputIndex] : "Enter",
  );
}
function acceptNumpadDigit(digit) {
  if (!challengeEnabled) recordCorrect("numpad");
  if (challengeEnabled) registerChallengeCorrect();
  else addScore(1);
  const row = numberTrack.querySelector('.number-row[data-offset="0"]'),
    source = getNumpadKey(digit),
    target = [...row.querySelectorAll("span")][inputIndex];
  source?.classList.add("is-correct");
  shootLaser(source, target);
  playSound("key10");
  setTimeout(() => source?.classList.remove("is-correct"), 100);
  inputIndex++;
  questionStartedAt = performance.now();
  refreshCurrentRow();
}
function completeNumpadQuestion() {
  if (!challengeEnabled) recordCorrect("numpad");
  if (challengeEnabled) registerChallengeCorrect();
  else addScore(1);
  playSound("shift7");
  const serial = ++transitionSerial,
    rows = [...numberTrack.querySelectorAll(".number-row")];
  numberQueue.shift();
  numberQueue.push(randomNumber());
  currentNumber = numberQueue[0];
  nextNumber = numberQueue[1];
  inputIndex = 0;
  questionStartedAt = performance.now();
  rows.forEach((row) => {
    const offset = Number(row.dataset.offset) - 1;
    if (offset < -1) {
      row.remove();
      return;
    }
    row.dataset.offset = String(offset);
    row.setAttribute(
      "aria-label",
      offset === 0
        ? `現在の問題 ${formatNumber(row.dataset.value)}`
        : `次の問題 ${formatNumber(row.dataset.value)}`,
    );
  });
  const incoming = createNumberRow(numberQueue[2], 2);
  incoming.classList.add("is-new");
  numberTrack.appendChild(incoming);
  requestAnimationFrame(() => incoming.classList.remove("is-new"));
  refreshCurrentRow();
  setTimeout(() => {
    if (serial !== transitionSerial) return;
    numberTrack.querySelector('.number-row[data-offset="-1"]')?.remove();
  }, 310);
}
function update(now) {
  if (["finished", "waiting", "countdown", "paused"].includes(state)) return;
  const remaining = finishTime - now;
  if (remaining <= 0) return finishPractice();
  timeDisplay.textContent = Math.ceil(remaining / 1000);
  if (selectedMode !== "numpad" && state === "playing" && challengeEnabled) {
    const dt = Math.min((now - previousFrameTime) / 1000, 0.1);
    challengeLetters.forEach((item) => {
      if (item.hit || item.stopped) return;
      item.y = Math.min(item.y + FALL_SPEED * dt, playArea.clientHeight - item.el.offsetHeight - 20);
      item.stopped = item.y >= playArea.clientHeight - item.el.offsetHeight - 20;
      item.el.style.transform = `translate(${item.x}px,${item.y}px)`;
    });
    previousFrameTime = now;
  } else if (selectedMode !== "numpad" && state === "playing") {
    keyY = Math.min(
      keyY + FALL_SPEED * Math.min((now - previousFrameTime) / 1000, 0.1),
      playArea.clientHeight - fallingKey.offsetHeight - 20,
    );
    previousFrameTime = now;
    drawKey();
  }
  animationId = requestAnimationFrame(update);
}
function drawKey() {
  fallingKey.style.transform = `translate(${keyX}px,${keyY}px)`;
}
function highlightStandard(name) {
  clearGuide();
  const guide = KEY_GUIDE.find((item) => item.key === name);
  getStandardKey(name)?.classList.add("is-target");
  if (!guide) return;
  document
    .querySelector(
      `.finger[data-hand="${guide.hand}"][data-finger="${guide.finger}"]`,
    )
    ?.classList.add("is-target");
  document
    .querySelectorAll(`.keycap[data-zone="${guide.hand}-${guide.finger}"]`)
    .forEach((key) => key.classList.add("is-zone-target"));
}
function highlightNumpad(name) {
  clearGuide();
  const key = getNumpadKey(name),
    finger = NUMPAD_FINGERS[name];
  key?.classList.add("is-target");
  if (finger) {
    document
      .querySelector(`.numpad-finger[data-finger="${finger}"]`)
      ?.classList.add("is-target");
    document
      .querySelectorAll(`.numpad-key[data-finger="${finger}"]`)
      .forEach((k) => k.classList.add("is-zone-target"));
  }
}
function clearGuide() {
  document
    .querySelectorAll(".keycap,.finger,.numpad-key,.numpad-finger")
    .forEach((el) =>
      el.classList.remove(
        "is-target",
        "is-zone-target",
        "is-correct",
        "is-wrong",
      ),
    );
}
function registerMiss(name, source) {
  misses++;
  if (!challengeEnabled) {
    const statId = selectedMode === "numpad" ? "numpad" : getRowForKey(currentKey);
    if (playStats[statId]) playStats[statId].misses++;
  }
  missesDisplay.textContent = misses;
  if (challengeEnabled) {
    addScore(-20);
    breakCombo();
  }
  if (source) {
    source.classList.remove("is-wrong");
    void source.offsetWidth;
    source.classList.add("is-wrong");
    setTimeout(() => source.classList.remove("is-wrong"), 260);
  }
  playSound("wrong");
  shootWrongLaser(source);
}
function getStandardKey(name) {
  return document.querySelector(`.keycap[data-key="${CSS.escape(name)}"]`);
}
function getNumpadKey(name) {
  return document.querySelector(
    `.numpad-key[data-numpad-key="${CSS.escape(name)}"]`,
  );
}
function shootLaser(source, target) {
  if (!source || !target) return;
  const a = source.getBoundingClientRect(),
    b = target.getBoundingClientRect(),
    sx = a.left + a.width / 2,
    sy = a.top + 3,
    ex = b.left + b.width / 2,
    ey = b.top + b.height / 2,
    d = Math.hypot(ex - sx, ey - sy),
    angle = (Math.atan2(ey - sy, ex - sx) * 180) / Math.PI,
    laser = document.createElement("span");
  laser.className = "laser";
  laser.style.cssText = `left:${sx}px;top:${sy}px;width:${d}px;transform:rotate(${angle}deg)`;
  document.body.appendChild(laser);
  setTimeout(() => laser.remove(), 220);
}
function shootWrongLaser(source) {
  if (!source) return;
  const r = source.getBoundingClientRect(),
    sx = r.left + r.width / 2,
    sy = r.top + 3,
    angle = -155 + Math.random() * 130,
    distance = 75 + Math.random() * 85,
    laser = document.createElement("span");
  laser.className = "wrong-laser";
  laser.style.cssText = `left:${sx}px;top:${sy}px;width:${distance}px;transform:rotate(${angle}deg)`;
  document.body.appendChild(laser);
  setTimeout(() => {
    laser.remove();
    playSound("fizzle");
  }, 105);
}
function createSparks(target) {
  if (!target) return;
  const area = playArea.getBoundingClientRect(),
    rect = target.getBoundingClientRect(),
    x = rect.left + rect.width / 2 - area.left,
    y = rect.top + rect.height / 2 - area.top;
  for (let i = 0; i < 8; i++) {
    const angle = (Math.PI * 2 * i) / 8,
      s = document.createElement("span");
    s.className = "spark";
    s.style.left = `${x}px`;
    s.style.top = `${y}px`;
    s.style.setProperty("--spark-x", `${Math.cos(angle) * 45}px`);
    s.style.setProperty("--spark-y", `${Math.sin(angle) * 45}px`);
    effectLayer.appendChild(s);
    setTimeout(() => s.remove(), 330);
  }
}
function pausePractice() {
  pausedAt = performance.now();
  pausedState = state;
  state = "paused";
  cancelAnimationFrame(animationId);
  exitPanel.hidden = false;
  $("continue-practice").focus();
  message.textContent = "練習を一時停止しています";
}
function resumePractice() {
  if (state !== "paused") return;
  const pausedDuration = performance.now() - pausedAt;
  finishTime += pausedDuration;
  questionStartedAt += pausedDuration;
  exitPanel.hidden = true;
  state = pausedState;
  previousFrameTime = performance.now();
  message.textContent =
    selectedMode === "numpad"
      ? "数字を順番に入力し、最後にテンキーのEnterを押してください"
      : "表示された文字を、光っている指で押してください";
  animationId = requestAnimationFrame(update);
}
function finishPractice(early = false) {
  state = "finished";
  updateGuideTitle();
  transitionSerial++;
  clearTimeout(nextKeyTimerId);
  cancelAnimationFrame(animationId);
  timeDisplay.textContent = early
    ? Math.max(0, Math.ceil((finishTime - pausedAt) / 1000))
    : "0";
  fallingKey.hidden = true;
  numpadQuestion.hidden = true;
  effectLayer.replaceChildren();
  challengeKeys.replaceChildren();
  challengeLetters = [];
  comboDisplay.hidden = true;
  clearGuide();
  exitPanel.hidden = true;
  exitHint.hidden = true;
  finalScore.textContent = score;
  finalHits.textContent = hits;
  finalMisses.textContent = misses;
  finalMaxCombo.textContent = maxCombo;
  lastSavedPlay = early ? null : saveCompletedPlay();
  renderCurrentResult(early, lastSavedPlay);
  resultPanel.hidden = false;
  message.textContent = `${early ? "途中終了しました。" : ""}${getModeLabel()}をもう一度練習するときはSpaceキーを押してください`;
}
function returnToSelection() {
  state = "waiting";
  updateGuideTitle();
  resultPanel.hidden = true;
  startPanel.hidden = false;
  exitHint.hidden = true;
  modeInputs.forEach((i) => (i.disabled = false));
  timeDisplay.textContent = PRACTICE_TIME;
  scoreDisplay.textContent = hitsDisplay.textContent = missesDisplay.textContent = "0";
  updateModeSelection();
  startPlacementGuide();
}
function createEmptyStats() {
  return Object.fromEntries(STAT_IDS.map((id) => [id, { hits: 0, misses: 0, inputMs: 0 }]));
}
function getRowForKey(key) {
  return KEY_GUIDE.find((item) => item.key === key)?.row || "home";
}
function recordCorrect(statId) {
  const stat = playStats[statId];
  if (!stat) return;
  stat.hits++;
  stat.inputMs += Math.max(0, performance.now() - questionStartedAt);
}
function accuracy(hitCount, missCount) {
  const total = hitCount + missCount;
  return total ? (hitCount / total) * 100 : 0;
}
function speedData(hitCount, inputMs) {
  const seconds = inputMs / 1000;
  const perSecond = seconds ? hitCount / seconds : 0;
  return { perSecond, perMinute: perSecond * 60 };
}
function speedText(hitCount, inputMs) {
  const speed = speedData(hitCount, inputMs);
  return `${speed.perSecond.toFixed(2)}打/秒(${Math.round(speed.perMinute)}打/分)`;
}
function createPlayId() {
  return `play-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
function loadRecords() {
  try {
    const data = JSON.parse(localStorage.getItem(STORAGE_KEY));
    if (data && data.schemaVersion === SCHEMA_VERSION && Array.isArray(data.plays)) return data;
  } catch (error) {
    console.warn("成績データを読み込めませんでした。", error);
  }
  return { schemaVersion: SCHEMA_VERSION, plays: [] };
}
function writeRecords(data) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
    return true;
  } catch (error) {
    console.warn("成績データを保存できませんでした。", error);
    message.textContent = "成績を保存できませんでした。ブラウザの保存設定を確認してください";
    return false;
  }
}
function saveCompletedPlay() {
  const playedAt = new Date().toISOString();
  let play;
  if (challengeEnabled) {
    play = {
      schemaVersion: SCHEMA_VERSION,
      playId: createPlayId(),
      appVersion: APP_VERSION,
      playedAt,
      category: "challenge",
      challengeType: selectedMode === "numpad" ? "numpad" : "standard",
      selectedRows: selectedMode === "numpad" ? ["numpad"] : sortSelectedRows(selectedRows),
      level: challengeLevel,
      durationSeconds: PRACTICE_TIME,
      score,
      hits,
      misses,
      maxCombo,
      inputMs: PRACTICE_TIME * 1000,
      future: null,
    };
  } else {
    const rows = Object.fromEntries(
      STAT_IDS.map((id) => [id, { ...playStats[id] }]),
    );
    const totals = Object.values(rows).reduce(
      (sum, stat) => ({ hits: sum.hits + stat.hits, misses: sum.misses + stat.misses, inputMs: sum.inputMs + stat.inputMs }),
      { hits: 0, misses: 0, inputMs: 0 },
    );
    play = {
      schemaVersion: SCHEMA_VERSION,
      playId: createPlayId(),
      appVersion: APP_VERSION,
      playedAt,
      category: "practice",
      practiceType: selectedMode,
      selectedRows: selectedMode === "numpad" ? ["numpad"] : sortSelectedRows(selectedRows),
      durationSeconds: PRACTICE_TIME,
      hits: totals.hits,
      misses: totals.misses,
      inputMs: totals.inputMs,
      rows,
      future: null,
    };
  }
  const records = loadRecords();
  records.plays.push(play);
  return writeRecords(records) ? play : null;
}
function renderCurrentResult(early, play) {
  const details = $("result-details");
  details.replaceChildren();
  $("final-accuracy-item").hidden = true;
  $("final-speed-item").hidden = true;
  $("ranking-result").hidden = true;
  $("result-ranking-link").hidden = !challengeEnabled;
  $("result-record-link").hidden = challengeEnabled;
  if (early || !play) {
    const note = document.createElement("p");
    note.className = "result-card";
    note.textContent = early ? "途中終了のため、この成績は保存されません。" : "成績を保存できませんでした。";
    details.appendChild(note);
    return;
  }
  if (play.category === "challenge") {
    setResultSpeed(play.hits, play.inputMs);
    const allRank = getChallengeRank(play, "all"), levelRank = getChallengeRank(play, play.level);
    const rank = $("ranking-result");
    rank.textContent = `今回 全Levelランキング ${allRank}位! Level ${play.level}ランキング ${levelRank}位`;
    rank.hidden = false;
    return;
  }
  $("final-accuracy").textContent = `${accuracy(play.hits, play.misses).toFixed(1)}%`;
  $("final-accuracy-item").hidden = false;
  setResultSpeed(play.hits, play.inputMs);
}
function setResultSpeed(hitCount, inputMs) {
  const seconds = inputMs > 0 ? hitCount / (inputMs / 1000) : 0;
  $("final-speed").textContent = `${seconds.toFixed(2)}打/秒`;
  $("final-speed-minute").textContent = `${Math.round(seconds * 60)}打/分`;
  $("final-speed-item").hidden = false;
}
function makeResultCard(title, lines) {
  const card = document.createElement("section"), heading = document.createElement("h3");
  card.className = "result-card";
  heading.textContent = title;
  card.appendChild(heading);
  lines.forEach((line) => { const p = document.createElement("p"); p.textContent = line; card.appendChild(p); });
  return card;
}
function makeStatCard(title, hitCount, missCount, inputMs) {
  return makeResultCard(title, [
    `打数:${hitCount} ミス:${missCount}`,
    `正確率:${accuracy(hitCount, missCount).toFixed(1)}%`,
    `速度:${speedText(hitCount, inputMs)}`,
  ]);
}
function compareChallenge(a, b) {
  return b.score - a.score || a.misses - b.misses || b.maxCombo - a.maxCombo || b.hits - a.hits || new Date(b.playedAt) - new Date(a.playedAt);
}
function matchingChallenges(type, level = "all") {
  return loadRecords().plays.filter((play) => play.category === "challenge" && play.challengeType === type && (level === "all" || play.level === Number(level))).sort(compareChallenge);
}
function getChallengeRank(play, level = "all") {
  return matchingChallenges(play.challengeType, level).findIndex((item) => item.playId === play.playId) + 1;
}
function openRecords(tab) {
  recordsScreen.hidden = false;
  switchRecordsTab(tab);
  $("close-records").focus();
}
function closeRecords() { recordsScreen.hidden = true; }
function switchRecordsTab(tab) {
  const tabs = ["records", "ranking", "charts", "csv"];
  const selected = tabs.includes(tab) ? tab : "records";
  recordsContent.hidden = selected !== "records";
  rankingContent.hidden = selected !== "ranking";
  chartsContent.hidden = selected !== "charts";
  csvContent.hidden = selected !== "csv";
  tabs.forEach((id) => $(`${id}-tab`).classList.toggle("is-active", id === selected));
  if (selected === "records") renderRecords();
  if (selected === "ranking") renderRanking();
  if (selected === "charts") renderCharts();
}
function aggregatePractice(plays) {
  return plays.reduce((sum, play) => {
    sum.hits += play.hits || 0; sum.misses += play.misses || 0; sum.inputMs += play.inputMs || 0;
    STAT_IDS.forEach((id) => { const stat = play.rows?.[id]; if (!stat) return; sum.rows[id].hits += stat.hits; sum.rows[id].misses += stat.misses; sum.rows[id].inputMs += stat.inputMs; });
    return sum;
  }, { hits: 0, misses: 0, inputMs: 0, rows: createEmptyStats() });
}
function renderRecords() {
  const plays = loadRecords().plays.filter((play) => play.category === "practice").sort((a, b) => new Date(b.playedAt) - new Date(a.playedAt));
  recordsContent.replaceChildren();
  if (!plays.length) { const empty = document.createElement("p"); empty.className = "empty-records"; empty.textContent = "まだ練習成績はありません。正常終了したプレイがここに保存されます。"; recordsContent.appendChild(empty); return; }
  const totals = aggregatePractice(plays), grid = document.createElement("div");
  grid.className = "summary-grid";
  grid.appendChild(makeSummaryCard("全体", totals.hits, totals.misses, totals.inputMs, `${plays.length}回`));
  STAT_IDS.forEach((id) => { const stat = totals.rows[id]; if (stat.hits || stat.misses) grid.appendChild(makeSummaryCard(MODE_LABELS[id], stat.hits, stat.misses, stat.inputMs)); });
  recordsContent.appendChild(grid);
  const title = document.createElement("h3"); title.className = "history-title"; title.textContent = "練習履歴"; recordsContent.appendChild(title);
  const list = document.createElement("div"); list.className = "history-list";
  plays.slice(0, 100).forEach((play) => { const item = document.createElement("article"); item.className = "history-item"; const h = document.createElement("h4"); h.textContent = `${formatDate(play.playedAt)} ${sortSelectedRows(play.selectedRows || []).map((id) => MODE_LABELS[id]).join("+")}`; const p = document.createElement("p"); p.textContent = `打数 ${play.hits} ミス ${play.misses} 正確率 ${accuracy(play.hits, play.misses).toFixed(1)}% 速度 ${speedText(play.hits, play.inputMs)}`; item.append(h, p); list.appendChild(item); });
  recordsContent.appendChild(list);
}
function makeSummaryCard(title, hitCount, missCount, inputMs, extra = "") {
  const card = document.createElement("section"); card.className = "summary-card";
  const h = document.createElement("h3"); h.textContent = title;
  const strong = document.createElement("strong"); strong.textContent = `${hitCount.toLocaleString()}打`;
  const small = document.createElement("small"); small.textContent = `ミス ${missCount.toLocaleString()}|正確率 ${accuracy(hitCount, missCount).toFixed(1)}%|${speedText(hitCount, inputMs)}${extra ? `|${extra}` : ""}`;
  card.append(h, strong, small); return card;
}
function renderRanking() {
  const type = $("ranking-mode").value, level = $("ranking-level").value, plays = matchingChallenges(type, level).slice(0, 10);
  rankingList.replaceChildren();
  if (!plays.length) { const empty = document.createElement("p"); empty.className = "empty-records"; empty.textContent = "この条件のランキング記録はまだありません。"; rankingList.appendChild(empty); return; }
  plays.forEach((play, index) => { const item = document.createElement("article"); item.className = "ranking-item"; const h = document.createElement("h3"); h.textContent = `${index + 1}位 ${play.score.toLocaleString()}点`; const p1 = document.createElement("p"); p1.textContent = `速度:${speedText(play.hits, play.inputMs)} 打数 ${play.hits} ミス ${play.misses} 最大コンボ ${play.maxCombo}`; const p2 = document.createElement("p"); p2.textContent = `Level ${play.level}${type === "standard" ? ` 選択段:${sortSelectedRows(play.selectedRows || []).map((id) => MODE_LABELS[id]).join("・")}` : ""} ${formatDate(play.playedAt)}`; item.append(h, p1, p2); rankingList.appendChild(item); });
}
function formatDate(value) { return new Intl.DateTimeFormat("ja-JP", { dateStyle: "short", timeStyle: "short" }).format(new Date(value)); }

function selectedChartPlays() {
  const all = loadRecords().plays
    .filter((play) => play.category === "practice")
    .sort((a, b) => new Date(a.playedAt) - new Date(b.playedAt));
  const range = $("chart-range").value;
  return range === "all" ? all : all.slice(-Number(range));
}
function movingAverage(values, size) {
  return values.map((_, index) => {
    if (index < size - 1) return null;
    const sample = values.slice(index - size + 1, index + 1);
    return sample.reduce((sum, value) => sum + value, 0) / size;
  });
}
function svgElement(name, attributes = {}) {
  const element = document.createElementNS("http://www.w3.org/2000/svg", name);
  Object.entries(attributes).forEach(([key, value]) => element.setAttribute(key, String(value)));
  return element;
}
function addSvgText(svg, text, x, y, attributes = {}) {
  const node = svgElement("text", { x, y, ...attributes });
  node.textContent = text;
  svg.appendChild(node);
  return node;
}
function showChartEmpty(container, text = "表示できる練習成績がまだありません。") {
  container.replaceChildren();
  const empty = document.createElement("p");
  empty.className = "chart-empty";
  empty.textContent = text;
  container.appendChild(empty);
}
function renderLineChart(container, values, averageValues, options) {
  container.replaceChildren();
  if (!values.length) { showChartEmpty(container); return; }
  const width = 760, height = 280, left = 54, right = 18, top = 20, bottom = 42;
  const plotWidth = width - left - right, plotHeight = height - top - bottom;
  const maximum = options.maximum || Math.max(1, ...values, ...averageValues.filter((value) => value !== null));
  const yMax = options.fixedMaximum || Math.max(options.minimumMaximum || 10, Math.ceil(maximum / 10) * 10);
  const svg = svgElement("svg", { viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": options.label });
  svg.classList.add("data-chart");
  for (let step = 0; step <= 4; step++) {
    const y = top + (plotHeight * step) / 4;
    svg.appendChild(svgElement("line", { x1: left, y1: y, x2: width - right, y2: y, class: "chart-grid-line" }));
    addSvgText(svg, `${Math.round(yMax * (1 - step / 4))}${options.unit}`, left - 8, y + 4, { class: "chart-axis-label", "text-anchor": "end" });
  }
  const pointX = (index) => left + (values.length === 1 ? plotWidth / 2 : (plotWidth * index) / (values.length - 1));
  const pointY = (value) => top + plotHeight - (Math.max(0, Math.min(yMax, value)) / yMax) * plotHeight;
  const makePolyline = (series, className) => {
    const points = series.map((value, index) => value === null ? null : `${pointX(index)},${pointY(value)}`).filter(Boolean).join(" ");
    if (points) svg.appendChild(svgElement("polyline", { points, class: className }));
  };
  makePolyline(values, "chart-line chart-line--main");
  makePolyline(averageValues, "chart-line chart-line--average");
  values.forEach((value, index) => {
    const dot = svgElement("circle", { cx: pointX(index), cy: pointY(value), r: 4, class: "chart-dot" });
    const title = svgElement("title");
    title.textContent = `${index + 1}回目 ${options.tooltips[index]} ${options.valueText(value)}`;
    dot.appendChild(title);
    svg.appendChild(dot);
  });
  const labelStep = Math.max(1, Math.ceil(values.length / 10));
  values.forEach((_, index) => {
    if (index % labelStep !== 0 && index !== values.length - 1) return;
    addSvgText(svg, String(index + 1), pointX(index), height - 17, { class: "chart-axis-label", "text-anchor": "middle" });
  });
  container.appendChild(svg);
  if (averageValues.some((value) => value !== null)) {
    const legend = document.createElement("p"); legend.className = "chart-legend";
    legend.innerHTML = '<span class="legend-main">各プレイ</span><span class="legend-average">直近5回平均</span>';
    container.appendChild(legend);
  }
}
function renderRowChart(container, plays) {
  container.replaceChildren();
  if (!plays.length) { showChartEmpty(container); return; }
  const totals = aggregatePractice(plays);
  const rows = STAT_IDS.map((id) => ({ id, value: speedData(totals.rows[id].hits, totals.rows[id].inputMs).perMinute, hits: totals.rows[id].hits }));
  if (!rows.some((row) => row.hits)) { showChartEmpty(container); return; }
  const width = 760, height = 300, left = 54, right = 18, top = 22, bottom = 56;
  const plotWidth = width - left - right, plotHeight = height - top - bottom;
  const maximum = Math.max(10, Math.ceil(Math.max(...rows.map((row) => row.value)) / 10) * 10);
  const svg = svgElement("svg", { viewBox: `0 0 ${width} ${height}`, role: "img", "aria-label": "段別速度の棒グラフ" });
  svg.classList.add("data-chart");
  for (let step = 0; step <= 4; step++) {
    const y = top + (plotHeight * step) / 4;
    svg.appendChild(svgElement("line", { x1: left, y1: y, x2: width - right, y2: y, class: "chart-grid-line" }));
    addSvgText(svg, `${Math.round(maximum * (1 - step / 4))}`, left - 8, y + 4, { class: "chart-axis-label", "text-anchor": "end" });
  }
  const slot = plotWidth / rows.length, barWidth = Math.min(76, slot * 0.58);
  rows.forEach((row, index) => {
    const barHeight = row.hits ? (row.value / maximum) * plotHeight : 0;
    const x = left + slot * index + (slot - barWidth) / 2, y = top + plotHeight - barHeight;
    const bar = svgElement("rect", { x, y, width: barWidth, height: barHeight, rx: 7, class: "chart-bar" });
    const title = svgElement("title"); title.textContent = `${MODE_LABELS[row.id]} ${Math.round(row.value)}打/分`;
    bar.appendChild(title); svg.appendChild(bar);
    addSvgText(svg, MODE_LABELS[row.id].replace("ポジション", ""), x + barWidth / 2, height - 24, { class: "chart-axis-label", "text-anchor": "middle" });
    if (row.hits) addSvgText(svg, `${Math.round(row.value)}`, x + barWidth / 2, Math.max(top + 13, y - 7), { class: "chart-value-label", "text-anchor": "middle" });
  });
  addSvgText(svg, "打/分", 8, 15, { class: "chart-axis-label" });
  container.appendChild(svg);
}
function renderAnalysisComment(plays) {
  const box = $("analysis-comment"); box.replaceChildren();
  const heading = document.createElement("h3"); heading.textContent = "今回の分析メモ"; box.appendChild(heading);
  const list = document.createElement("ul");
  if (plays.length < 5) {
    const item = document.createElement("li"); item.textContent = `あと${5 - plays.length}回練習すると、直近5回の平均を使った分析を表示できます。`; list.appendChild(item);
  } else {
    const speeds = plays.map((play) => speedData(play.hits, play.inputMs).perMinute);
    const recent = speeds.slice(-5).reduce((sum, value) => sum + value, 0) / 5;
    if (speeds.length >= 10) {
      const previous = speeds.slice(-10, -5).reduce((sum, value) => sum + value, 0) / 5;
      const item = document.createElement("li");
      item.textContent = recent > previous * 1.02 ? "直近5回の平均速度が、その前の5回より上がっています。" : recent < previous * .98 ? "直近5回は少しゆっくりめです。正確さを保ちながら続けてみましょう。" : "直近5回の平均速度は安定しています。";
      list.appendChild(item);
    } else {
      const item = document.createElement("li"); item.textContent = `直近5回の平均速度は${Math.round(recent)}打/分です。`; list.appendChild(item);
    }
    const totals = aggregatePractice(plays);
    const rowValues = STAT_IDS.map((id) => ({ id, hits: totals.rows[id].hits, speed: speedData(totals.rows[id].hits, totals.rows[id].inputMs).perMinute })).filter((row) => row.hits > 0);
    if (rowValues.length >= 2) {
      const fastest = [...rowValues].sort((a, b) => b.speed - a.speed)[0], slower = [...rowValues].sort((a, b) => a.speed - b.speed)[0];
      const best = document.createElement("li"); best.textContent = `${MODE_LABELS[fastest.id]}が最も速く入力できています。`; list.appendChild(best);
      if (slower.speed < fastest.speed * .9) { const note = document.createElement("li"); note.textContent = `最近は${MODE_LABELS[slower.id]}の速度が少し低めです。`; list.appendChild(note); }
    }
    const recentAccuracy = plays.slice(-5).map((play) => accuracy(play.hits, play.misses));
    if (Math.max(...recentAccuracy) - Math.min(...recentAccuracy) <= 3) { const item = document.createElement("li"); item.textContent = "直近5回の正確率が安定しています。"; list.appendChild(item); }
  }
  box.appendChild(list);
}
function renderCharts() {
  const plays = selectedChartPlays();
  $("chart-count").textContent = `表示中:${plays.length}回`;
  renderAnalysisComment(plays);
  const speedValues = plays.map((play) => speedData(play.hits, play.inputMs).perMinute);
  const dateLabels = plays.map((play) => formatDate(play.playedAt));
  renderLineChart($("speed-chart"), speedValues, movingAverage(speedValues, 5), { label: "速度と直近5回平均の折れ線グラフ", unit: "", minimumMaximum: 60, tooltips: dateLabels, valueText: (value) => `${Math.round(value)}打/分` });
  renderRowChart($("row-chart"), plays);
  const accuracyValues = plays.map((play) => accuracy(play.hits, play.misses));
  renderLineChart($("accuracy-chart"), accuracyValues, [], { label: "正確率の折れ線グラフ", unit: "%", fixedMaximum: 100, tooltips: dateLabels, valueText: (value) => `${value.toFixed(1)}%` });
}

const CSV_HEADERS = [
  "schemaVersion", "playId", "appVersion", "playedAt", "category", "practiceType", "challengeType", "selectedRows", "level", "durationSeconds", "hits", "misses", "inputMs", "score", "maxCombo", "futureJson",
  ...STAT_IDS.flatMap((id) => [`${id}_hits`, `${id}_misses`, `${id}_inputMs`]),
];
function csvEscape(value) {
  const text = value === null || value === undefined ? "" : String(value);
  return /[",\r\n]/.test(text) ? `"${text.replace(/"/g, '""')}"` : text;
}
function recordsToCsv(plays) {
  const lines = [CSV_HEADERS.join(",")];
  plays.forEach((play) => {
    const values = [play.schemaVersion, play.playId, play.appVersion, play.playedAt, play.category, play.practiceType, play.challengeType, (play.selectedRows || []).join("|"), play.level, play.durationSeconds, play.hits, play.misses, play.inputMs, play.score, play.maxCombo, JSON.stringify(play.future ?? null)];
    STAT_IDS.forEach((id) => { const stat = play.rows?.[id]; values.push(stat?.hits ?? "", stat?.misses ?? "", stat?.inputMs ?? ""); });
    lines.push(values.map(csvEscape).join(","));
  });
  return `\ufeff${lines.join("\r\n")}`;
}
function backupFilename(prefix = "typing-lab-backup") {
  const now = new Date(), pad = (value) => String(value).padStart(2, "0");
  return `${prefix}-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${pad(now.getHours())}${pad(now.getMinutes())}.csv`;
}
function downloadCsv(plays, prefix) {
  const blob = new Blob([recordsToCsv(plays)], { type: "text/csv;charset=utf-8" });
  const url = URL.createObjectURL(blob), link = document.createElement("a");
  link.href = url; link.download = backupFilename(prefix); document.body.appendChild(link); link.click(); link.remove();
  setTimeout(() => URL.revokeObjectURL(url), 1000);
}
function exportRecordsCsv(prefix = "typing-lab-backup") { downloadCsv(loadRecords().plays, prefix); }
function parseCsv(text) {
  const source = text.replace(/^\ufeff/, ""), rows = [];
  let row = [], field = "", quoted = false;
  for (let index = 0; index < source.length; index++) {
    const char = source[index];
    if (quoted) {
      if (char === '"' && source[index + 1] === '"') { field += '"'; index++; }
      else if (char === '"') quoted = false;
      else field += char;
    } else if (char === '"' && field === "") quoted = true;
    else if (char === ",") { row.push(field); field = ""; }
    else if (char === "\n") { row.push(field.replace(/\r$/, "")); rows.push(row); row = []; field = ""; }
    else field += char;
  }
  if (quoted) throw new Error("引用符が閉じられていません");
  if (field !== "" || row.length) { row.push(field.replace(/\r$/, "")); rows.push(row); }
  return rows.filter((values) => values.some((value) => value !== ""));
}
function requiredNumber(value, name, rowNumber, options = {}) {
  if (value === "" && options.optional) return null;
  const number = Number(value);
  if (!Number.isFinite(number) || number < (options.minimum ?? 0) || (options.integer && !Number.isInteger(number))) throw new Error(`${rowNumber}行目:${name}の形式が正しくありません`);
  return number;
}
function validateImportedCsv(text) {
  const rows = parseCsv(text);
  if (rows.length < 1) throw new Error("CSVに見出し行がありません");
  const headers = rows[0];
  CSV_HEADERS.forEach((header) => { if (!headers.includes(header)) throw new Error(`必須列「${header}」がありません`); });
  const index = Object.fromEntries(headers.map((header, position) => [header, position]));
  const plays = rows.slice(1).map((values, offset) => {
    const rowNumber = offset + 2, get = (name) => values[index[name]] ?? "";
    const schemaVersion = requiredNumber(get("schemaVersion"), "schemaVersion", rowNumber, { integer: true });
    if (schemaVersion !== SCHEMA_VERSION) throw new Error(`${rowNumber}行目:対応していないschemaVersionです`);
    const playId = get("playId").trim(), appVersion = get("appVersion").trim(), playedAt = get("playedAt").trim(), category = get("category").trim();
    if (!playId) throw new Error(`${rowNumber}行目:playIdがありません`);
    if (!appVersion) throw new Error(`${rowNumber}行目:appVersionがありません`);
    if (!playedAt || Number.isNaN(Date.parse(playedAt))) throw new Error(`${rowNumber}行目:プレイ日時の形式が正しくありません`);
    if (!['practice', 'challenge'].includes(category)) throw new Error(`${rowNumber}行目:モードの形式が正しくありません`);
    const selectedRows = get("selectedRows").split("|").filter(Boolean);
    if (!selectedRows.length || selectedRows.some((id) => !STAT_IDS.includes(id))) throw new Error(`${rowNumber}行目:選択段の形式が正しくありません`);
    const base = { schemaVersion, playId, appVersion, playedAt: new Date(playedAt).toISOString(), category, selectedRows, durationSeconds: requiredNumber(get("durationSeconds"), "練習時間", rowNumber), hits: requiredNumber(get("hits"), "打数", rowNumber, { integer: true }), misses: requiredNumber(get("misses"), "ミス", rowNumber, { integer: true }), inputMs: requiredNumber(get("inputMs"), "実入力時間", rowNumber) };
    try { base.future = JSON.parse(get("futureJson") || "null"); } catch (error) { throw new Error(`${rowNumber}行目:futureJsonの形式が正しくありません`); }
    if (category === "practice") {
      const practiceType = get("practiceType");
      if (!['standard', 'numpad'].includes(practiceType)) throw new Error(`${rowNumber}行目:通常練習種別の形式が正しくありません`);
      base.practiceType = practiceType; base.rows = {};
      STAT_IDS.forEach((id) => { base.rows[id] = { hits: requiredNumber(get(`${id}_hits`), `${MODE_LABELS[id]}の打数`, rowNumber, { integer: true }), misses: requiredNumber(get(`${id}_misses`), `${MODE_LABELS[id]}のミス`, rowNumber, { integer: true }), inputMs: requiredNumber(get(`${id}_inputMs`), `${MODE_LABELS[id]}の実入力時間`, rowNumber) }; });
    } else {
      const challengeType = get("challengeType");
      if (!['standard', 'numpad'].includes(challengeType)) throw new Error(`${rowNumber}行目:チャレンジ種別の形式が正しくありません`);
      base.challengeType = challengeType;
      base.level = requiredNumber(get("level"), "Level", rowNumber, { integer: true, minimum: 1 });
      if (base.level > 10) throw new Error(`${rowNumber}行目:Levelが範囲外です`);
      base.score = requiredNumber(get("score"), "スコア", rowNumber, { integer: true });
      base.maxCombo = requiredNumber(get("maxCombo"), "最大コンボ", rowNumber, { integer: true });
    }
    return base;
  });
  if (!plays.length) throw new Error("インポートできる成績がありません");
  return plays;
}
function prepareImport(plays, fileName) {
  const existingIds = new Set(loadRecords().plays.map((play) => play.playId)), seen = new Set(), unique = [], newPlays = [];
  let duplicates = 0;
  plays.forEach((play) => {
    if (seen.has(play.playId)) { duplicates++; return; }
    seen.add(play.playId); unique.push(play);
    if (existingIds.has(play.playId)) duplicates++; else newPlays.push(play);
  });
  pendingImport = { fileName, total: plays.length, unique, newPlays, duplicates };
  const summary = $("import-summary"); summary.className = "import-summary is-valid"; summary.replaceChildren();
  [fileName, `読み込み対象:${plays.length}件`, `新規追加:${newPlays.length}件`, `重複:${duplicates}件`, "エラー:0件"].forEach((text, index) => {
    const node = document.createElement(index === 0 ? "strong" : "span"); node.textContent = text; summary.appendChild(node);
  });
  $("import-add").disabled = newPlays.length === 0;
  $("import-replace").disabled = false;
}
async function handleImportFile(event) {
  pendingImport = null; $("import-add").disabled = true; $("import-replace").disabled = true;
  const file = event.target.files[0]; if (!file) return;
  try { prepareImport(validateImportedCsv(await file.text()), file.name); }
  catch (error) {
    $("import-summary").className = "import-summary is-error";
    $("import-summary").textContent = `CSVデータに読み込めない項目が見つかりました(${error.message})。安全のためインポートを中止しました。現在の成績データは変更されていません。`;
  }
}
function finishImportMessage(text) {
  $("import-summary").className = "import-summary is-valid"; $("import-summary").textContent = text;
  $("import-add").disabled = true; $("import-replace").disabled = true; $("import-file").value = ""; pendingImport = null;
}
function importAdditionalRecords() {
  if (!pendingImport || !pendingImport.newPlays.length) return;
  if (!confirm(`${pendingImport.newPlays.length}件の成績を現在の履歴へ追加します。`)) return;
  const records = loadRecords(); records.plays.push(...pendingImport.newPlays);
  if (writeRecords(records)) finishImportMessage(`${pendingImport.newPlays.length}件の成績を追加しました。重複した成績は追加していません。`);
}
function replaceAllRecords() {
  if (!pendingImport) return;
  if (!confirm("現在保存されている練習成績・チャレンジ成績をすべて削除して、CSVの内容へ置き換えます。")) return;
  if (!confirm("本当に全消去してインポートしますか? この操作は元に戻せません。")) return;
  const current = loadRecords().plays;
  if (current.length) downloadCsv(current, "typing-lab-auto-backup");
  if (writeRecords({ schemaVersion: SCHEMA_VERSION, plays: pendingImport.unique })) finishImportMessage(`${pendingImport.unique.length}件の成績へ置き換えました。置き換え前の成績は自動バックアップしました。`);
}

function deleteAllRecords() {
  if (!confirm("練習履歴・チャレンジ履歴・ランキングをすべて削除します。よろしいですか?")) return;
  if (!confirm("削除した成績は元に戻せません。本当にすべて削除しますか?")) return;
  localStorage.removeItem(STORAGE_KEY);
  renderRecords(); renderRanking();
}
function addScore(points) {
  score += points;
  scoreDisplay.textContent = score;
}
function comboMultiplier() {
  return combo < 5 ? 1 : 1 + combo / 10;
}
function registerChallengeCorrect() {
  registerHit();
  combo++;
  maxCombo = Math.max(maxCombo, combo);
  addScore(combo < 5 ? 10 : 10 + combo);
  updateComboDisplay(combo % 10 === 0);
}
function registerHit() {
  hits++;
  hitsDisplay.textContent = hits;
}
function updateComboDisplay(milestone = false) {
  comboDisplay.hidden = !challengeEnabled || combo < 5;
  $("combo-count").textContent = combo;
  $("combo-rate").textContent = comboMultiplier().toFixed(1);
  if (milestone) {
    comboDisplay.classList.remove("is-milestone");
    void comboDisplay.offsetWidth;
    comboDisplay.classList.add("is-milestone");
  }
}
function breakCombo() {
  const wasVisible = combo >= 5;
  combo = 0;
  updateComboDisplay();
  if (!wasVisible) return;
  const flash = $("combo-flash");
  flash.classList.remove("is-active");
  void flash.offsetWidth;
  flash.classList.add("is-active");
}
function fillChallengeLetters() {
  while (state === "playing" && challengeLetters.length < challengeLevel) {
    const el = document.createElement("span"),
      slot = Array.from({ length: challengeLevel }, (_, i) => i).find((i) => !challengeLetters.some((item) => item.lane === i)) ?? 0,
      key = practiceKeys[Math.floor(Math.random() * practiceKeys.length)],
      laneWidth = playArea.clientWidth / challengeLevel;
    el.className = "challenge-key";
    el.textContent = key.toUpperCase();
    challengeKeys.appendChild(el);
    const width = el.offsetWidth || 70,
      x = Math.max(8, slot * laneWidth + (laneWidth - width) / 2),
      item = { id: `${performance.now()}-${Math.random()}`, key, el, lane: slot, x, y: 48, stopped: false, hit: false };
    el.style.transform = `translate(${x}px,${item.y}px)`;
    challengeLetters.push(item);
  }
  highlightChallengeChoices();
}
function highlightChallengeChoices() {
  clearGuide();
  const keys = new Set(challengeLetters.filter((item) => !item.hit).map((item) => item.key));
  keys.forEach((key) => {
    const guide = KEY_GUIDE.find((item) => item.key === key);
    getStandardKey(key)?.classList.add("is-target");
    if (guide)
      document.querySelector(`.finger[data-hand="${guide.hand}"][data-finger="${guide.finger}"]`)?.classList.add("is-target");
  });
}
function hitChallengeLetter(item) {
  item.hit = true;
  registerChallengeCorrect();
  const source = getStandardKey(item.key);
  source?.classList.add("is-correct");
  shootLaser(source, item.el);
  createSparks(item.el);
  item.el.style.setProperty("--hit-x", `${item.x}px`);
  item.el.style.setProperty("--hit-y", `${item.y}px`);
  item.el.classList.add("is-hit");
  setTimeout(() => playSound("break"), 75);
  setTimeout(() => source?.classList.remove("is-correct"), 100);
  setTimeout(() => {
    item.el.remove();
    challengeLetters = challengeLetters.filter((letter) => letter !== item);
    fillChallengeLetters();
  }, 210);
}
function startPlacementGuide() {
  stopPlacementGuide();
  if (state !== "waiting") return;
  placementIndex = 0;
  const step = () => {
    if (state !== "waiting") return;
    clearGuide();
    const sequence = selectedMode === "numpad"
      ? ["4", "5", "6", "0", "Enter"]
      : ["f", "d", "s", "a", "j", "k", "l", ";"];
    const name = sequence[placementIndex++ % sequence.length];
    if (selectedMode === "numpad") highlightNumpadPlacement(name);
    else highlightStandard(name);
    drawPlacementLine(selectedMode === "numpad" ? getNumpadKey(name) : getStandardKey(name), getPlacementFinger(name));
  };
  step();
  placementTimerId = setInterval(step, 500);
}
function highlightNumpadPlacement(name) {
  clearGuide();
  getNumpadKey(name)?.classList.add("is-target");
  const finger = NUMPAD_FINGERS[name];
  if (finger) {
    document
      .querySelector(`.numpad-finger[data-finger="${finger}"]`)
      ?.classList.add("is-target");
  }
}
function getPlacementFinger(name) {
  if (selectedMode === "numpad")
    return document.querySelector(`.numpad-finger[data-finger="${NUMPAD_FINGERS[name]}"]`);
  const guide = KEY_GUIDE.find((item) => item.key === name);
  return guide && document.querySelector(`.finger[data-hand="${guide.hand}"][data-finger="${guide.finger}"]`);
}
function drawPlacementLine(key, finger) {
  if (!key || !finger) return;
  const a = key.getBoundingClientRect(), b = finger.getBoundingClientRect(), line = placementLine.querySelector("line");
  line.setAttribute("x1", a.left + a.width / 2);
  line.setAttribute("y1", a.bottom);
  line.setAttribute("x2", b.left + b.width / 2);
  line.setAttribute("y2", b.top);
  placementLine.classList.add("is-visible");
}
function stopPlacementGuide() {
  clearInterval(placementTimerId);
  placementLine.classList.remove("is-visible");
  clearGuide();
}
let noiseBuffer = null;
function getAudioContext() {
  if (!audioContext) {
    audioContext = new (window.AudioContext || window.webkitAudioContext)();
    noiseBuffer = audioContext.createBuffer(
      1,
      audioContext.sampleRate * 0.25,
      audioContext.sampleRate,
    );
    const data = noiseBuffer.getChannelData(0);
    for (let i = 0; i < data.length; i++) data[i] = Math.random() * 2 - 1;
  }
  if (audioContext.state === "suspended") audioContext.resume();
  return audioContext;
}
function tone({
  frequency,
  endFrequency = frequency,
  duration = 0.05,
  volume = 0.035,
  type = "square",
  delay = 0,
}) {
  if (!soundEnabled) return;
  const c = getAudioContext(),
    start = c.currentTime + delay,
    o = c.createOscillator(),
    g = c.createGain();
  o.type = type;
  o.frequency.setValueAtTime(frequency, start);
  o.frequency.exponentialRampToValueAtTime(
    Math.max(20, endFrequency),
    start + duration,
  );
  g.gain.setValueAtTime(0.0001, start);
  g.gain.exponentialRampToValueAtTime(volume, start + 0.004);
  g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
  o.connect(g).connect(c.destination);
  o.start(start);
  o.stop(start + duration + 0.01);
}
function noise({
  duration = 0.025,
  volume = 0.035,
  delay = 0,
  highpass = 680,
  lowpass = 2310,
}) {
  if (!soundEnabled) return;
  const c = getAudioContext(),
    start = c.currentTime + delay,
    s = c.createBufferSource(),
    hp = c.createBiquadFilter(),
    lp = c.createBiquadFilter(),
    g = c.createGain();
  s.buffer = noiseBuffer;
  hp.type = "highpass";
  hp.frequency.value = highpass;
  lp.type = "lowpass";
  lp.frequency.value = lowpass;
  g.gain.setValueAtTime(0.0001, start);
  g.gain.exponentialRampToValueAtTime(volume, start + 0.002);
  g.gain.exponentialRampToValueAtTime(0.0001, start + duration);
  s.connect(hp).connect(lp).connect(g).connect(c.destination);
  s.start(start);
  s.stop(start + duration + 0.01);
}
function playSound(name) {
  if (name === "wrong") {
    tone({
      frequency: 150,
      endFrequency: 75,
      duration: 0.105,
      volume: 0.055,
      type: "triangle",
    });
    tone({
      frequency: 95,
      endFrequency: 55,
      duration: 0.09,
      volume: 0.025,
      delay: 0.012,
    });
  }
  if (name === "break")
    [1500, 2150, 2850].forEach((f, i) =>
      tone({
        frequency: f,
        endFrequency: f * 0.65,
        duration: 0.055 + i * 0.012,
        volume: 0.018,
        type: "triangle",
        delay: i * 0.009,
      }),
    );
  if (name === "key10") {
    noise({ duration: 0.025, volume: 0.035, highpass: 680, lowpass: 2310 });
    tone({
      frequency: 1050,
      endFrequency: 756,
      duration: 0.035,
      volume: 0.0245,
      type: "triangle",
    });
  }
  if (name === "shift7") {
    noise({ duration: 0.14, volume: 0.075, highpass: 700, lowpass: 12000 });
    [1500, 1150, 850, 620, 430].forEach((f, i) =>
      tone({
        frequency: f,
        endFrequency: f * 0.72,
        duration: 0.045,
        volume: 0.04,
        type: "square",
        delay: i * 0.025,
      }),
    );
  }
  if (name === "fizzle")
    tone({
      frequency: 340,
      endFrequency: 85,
      duration: 0.075,
      volume: 0.03,
      type: "sawtooth",
    });
}
function toggleSound() {
  soundEnabled = !soundEnabled;
  soundToggle.textContent = soundEnabled ? "ON" : "OFF";
  soundToggle.setAttribute("aria-pressed", String(soundEnabled));
}

ヘッダーへ「グラフを見る」と「CSV」を追加する

第8回では、タイピング練習の横に「成績を見る」と「ランキング」を置いていました。

今回は、その右側へ「グラフを見る」と「CSV」を追加しました。

<nav class="record-links" aria-label="成績メニュー">
  <button id="open-records" type="button">成績を見る</button>
  <button id="open-ranking" type="button">ランキング</button>
  <button id="open-charts" type="button">グラフを見る</button>
  <button id="open-csv" type="button">CSV</button>
</nav>

ボタンは1段に詰め込みすぎず、画面幅に応じて折り返せるようにしています。今後さらに2~3個ほど機能ボタンが増えても、大きく崩れにくい形を意識しました。

通常練習だけを分析グラフの対象にする

成績画面には、通常練習とチャレンジの両方を保存しています。

ただし、今回の分析グラフでは通常練習だけを対象にしました。

通常練習は「どの段が速いか」「最近上達しているか」を見るための練習です。一方、チャレンジはスコアとランキングを競うモードなので、同じグラフへ混ぜない方が役割が分かりやすくなります。

チャレンジモードの成績は削除しているわけではありません。CSVバックアップには通常練習とチャレンジの両方を保存します。

速度と直近5回平均を折れ線グラフで表示する

最初のグラフは、通常練習の速度です。

横軸をプレイ順、縦軸を打/分として、1プレイごとの速度を折れ線で表示します。

さらに、5回以上の成績がある場合は、直近5回の移動平均も同じグラフへ表示します。

function movingAverage(values, size) {
  return values.map((_, index) => {
    if (index < size - 1) return null;
    const sample = values.slice(index - size + 1, index + 1);
    return sample.reduce((sum, value) => sum + value, 0) / size;
  });
}

1回だけものすごく速かった、逆にたまたま遅かった、という成績だけでは全体の上達は分かりにくいため、5回平均を使って最近の傾向を見られるようにしました。

数字段・上段・ホーム段・下段・テンキーを棒グラフで比べる

第8回では、通常練習の入力時間を数字段・上段・ホーム段・下段・テンキーへ分けて保存できるようにしました。

今回は、その段別データを棒グラフへ変換します。

  • 数字段
  • 上段
  • ホーム段
  • 下段
  • テンキー

棒の高さを比べるだけで、「ホーム段は速いけど下段は少しゆっくり」など、自分の傾向を確認できます。

ノン
ノン

……きょろぽん、ちょっと待って(笑)
テンキーだけ183打/分で、数字段45、上段52、ホーム段64、下段49って……。

これ、ほぼテンキーしかやってないよね?(笑)

きょろぽん
きょろぽん

いやー、普段は文字を打つことがあんまりないんだよね(笑)

仕事では数字を入力することが多いから、テンキーはめちゃくちゃ使う。

自分では普通に打ててるつもりだったけど、こうしてグラフにすると文字入力はかなり苦手みたいだな(笑)

ノン
ノン

仕事でよく使うテンキーだけ、見事に育ってるね(笑)

でも、こうやって段別にグラフへ出すと「普段どのキーを使っているか」まで見えてくるのが面白いね。

文字キーの方は、これから伸びる余地がたっぷりあるってことで(笑)

正確率の推移は別の折れ線グラフにする

速度だけを追いかけると、速くなった代わりにミスが増えていることもあります。

そこで、正確率も折れ線グラフで確認できるようにしました。

速度は「打/分」、正確率は「%」と単位が異なるため、同じグラフへ重ねず、別々のグラフにしています。

直近10回・30回・100回・すべてを切り替える

成績をずっと保存していくと、グラフの点もどんどん増えていきます。

そのため、表示範囲を次の4種類から選べるようにしました。

  • 直近10回
  • 直近30回
  • 直近100回
  • すべて

初期状態は直近30回です。

段別速度も、選択している表示範囲に合わせて再集計します。たとえば直近30回を選んだ場合は、その30回の中にある段別データだけで棒グラフを作ります。

数字だけでなく、やさしい分析メモも表示する

グラフを見れば分かるとはいっても、数字に慣れていないと少し読み取りにくいこともあります。

そこで、保存された数値をJavaScriptで比較し、簡単な分析メモも表示するようにしました。

  • 直近5回の平均速度が、その前の5回より上がっています。
  • 直近5回の平均速度は安定しています。
  • ホーム段が最も速く入力できています。
  • 最近は下段の速度が少し低めです。
  • 直近5回の正確率が安定しています。

「苦手です」と強く断定するのではなく、現在のデータをもとに、少しやさしい言い方にしています。

また、5回未満では傾向を決めつけず、「あと3回練習すると直近5回平均を表示できます」のような案内にしています。

通常練習とチャレンジをBOM付きCSVへまとめて保存する

グラフの次はバックアップです。

CSVへ書き出すときは、通常練習だけでなく、チャレンジ履歴とランキングの元になるデータも一緒に保存します。

「CSVへバックアップしたから、成績は全部戻せる」と考えても問題ない形を目指しました。

CSVはUTF-8 BOM付きで作成します。Excelで開いたときの日本語文字化けをできるだけ防ぎ、今後日本語入力の成績が増えても扱いやすいようにしています。

CSVファイル名へ年月日と時分を入れる

CSVのファイル名は、保存した時刻まで分かるようにしました。

typing-lab-backup-20260811-2006.csv

同じ日に何度バックアップしても区別しやすく、ファイル名で並べたときも時系列が分かりやすくなります。

追加インポートではplayIdで重複を防ぐ

CSVのインポートは、まず「追加インポート」を基本にしました。

現在の成績を残したまま、CSVの中にある新しい履歴だけを追加します。

第8回から各プレイへ付けているplayIdを使い、すでに同じ履歴が保存されている場合は重複登録しません。

読み込み前には、対象件数・新規追加件数・重複件数・エラー件数を表示して確認できます。

全消去してインポートも用意する

追加インポートだけではなく、バックアップした時点へ完全に戻したい場合もあります。

そのため、「全消去してインポート」も用意しました。

この操作では、現在保存されている通常練習・チャレンジの成績を削除し、CSVに入っている履歴だけへ置き換えます。

全消去してインポートは、現在の成績が消える操作です。誤操作を防ぐため、確認を2回行います。

全消去の前に現在の成績を自動バックアップする

さらに、全消去してインポートする直前に、現在の成績を自動でCSVへ保存します。

処理の順番は次のとおりです。

  1. 読み込むCSVを完全に検証する
  2. 確認を2回行う
  3. 現在の成績を自動CSVバックアップする
  4. 現在のデータを全消去する
  5. CSVの内容をインポートする

もし読み込むCSVがおかしかった場合は、全消去まで進みません。

CSVに致命的なエラーが1件でもあれば全部中止する

CSVバックアップは、成績を安全に戻すための機能です。

そのため、「読める行だけ取り込む」という処理にはしませんでした。

CSVの中に1件でも致命的な形式エラーが見つかった場合は、インポート全体を中止し、現在の成績には何も変更を加えません。

「CSVデータに読み込めない項目が見つかりました。安全のためインポートを中止しました。現在の成績データは変更されていません。」と表示して処理を止めます。

schemaVersionで保存形式を確認する

タイピングラボのバージョンと、保存データの形式は別々に管理しています。

今回の保存形式はschemaVersion 1です。

将来、保存形式を変更した場合でも、CSV内のschemaVersionを確認すれば、現在のタイピングラボで読み込める形式かどうかを判断できます。

CSV管理も成績画面の中で切り替える

「成績を見る」「ランキング」「グラフを見る」「CSV」の4つのボタンは、それぞれ別の小さなウィンドウを作るのではなく、同じ成績画面のタブを切り替えて表示します。

  • 練習成績
  • ランキング
  • 分析グラフ
  • CSV管理

画面全体を縦に長くしすぎず、必要な機能だけを切り替えて使えるようにしています。

これまでの通常練習とチャレンジはそのまま使える

今回追加したのは、分析とバックアップです。

これまで作ってきた、複数段練習、テンキー、チャレンジモード、コンボ、ランキング、音、途中終了などの機能はそのまま使えます。

Escで途中終了した場合は、第8回と同じく成績へ保存しません。

今回の完成形

第9回までで、タイピングラボはかなり「練習を続けるための道具」らしくなりました。

  • 練習する
  • 成績を保存する
  • 段別の得意・不得意を見る
  • 最近の上達を見る
  • ランキングでチャレンジする
  • CSVで成績をバックアップする
  • 別PCや別ブラウザへ成績を移す

第8回で「保存する」、第9回で「分析する・バックアップする」まで来ました。

次はいよいよ、第10回 Ver.1.0です。

完成したタイピング練習を使ってみる

今回完成したタイピングラボ Ver.0.9を、下の画面から試せます。

記事内の埋め込み表示では画面サイズの影響を受ける場合があります。しっかり練習するときは、別画面で開いて使うのがおすすめです。

タイピングラボ Ver.0.9を別画面で開く

まとめ

今回は、第8回で保存できるようになった成績を使い、速度・段別速度・正確率をグラフで確認できるようにしました。

さらに、通常練習とチャレンジの履歴をまとめてBOM付きCSVへ保存し、追加インポートと全消去インポートの両方へ対応しました。

バックアップは便利ですが、成績データを扱う機能なので、安全側に倒しています。CSVに致命的なエラーが1件でもあれば全部中止し、全消去インポートでは二重確認と自動バックアップを行います。

これで、練習データを「保存する」「見る」「分析する」「持ち出す」まで一通りできるようになりました。

次回はいよいよ第10回。日本語をローマ字で入力できるようにして、タイピングラボ Ver.1.0を目指します。