💗 気持ちの温度計(先生の設定)

発問と選択肢を自由に決められます。できあがった共有URLを児童生徒に配ると、選んだ答えに応じてハートの色が変わる画面が開きます。

選択肢の数
選択肢のことば(自由に書きかえてOK)
プレビュー(タップしてためせます)

このURLを児童生徒に共有してください(Google Classroomなど)

🤔 リンクが正しくありません

URLが正しくコピーされていないようです。先生にもう一度リンクを送ってもらってください。

気持ちの温度計

Google Apps Script デプロイ用ファイル一式
クラス共有版(児童の回答をGoogleスプレッドシートに集約する機能つき)を Google Apps Scriptにデプロイするための3つのファイルです。 それぞれの「コピー」ボタンで個別にコピーし、 ③ README.txt の手順に沿って script.google.com に貼り付けてください。
① index.html ② Code.gs ③ README.txt(手順)
1
index.html
児童が触る画面本体(左の「🎮 実際の画面」で動作を確認できます)
<!doctype html>
<html lang="ja">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>気持ちの温度計</title>
<style>
  :root { color-scheme: light; }
  * { box-sizing: border-box; }
  button:focus-visible, input:focus-visible, [tabindex]:focus-visible {
    outline: 2px solid #2563eb;
    outline-offset: 2px;
  }
  html, body { margin: 0; padding: 0; }
  body {
    font-family: "Hiragino Sans", "Yu Gothic", system-ui, sans-serif;
    background: #f3f6fb;
    color: #1f2937;
    padding: 24px 16px 40px;
  }
  [hidden] { display: none !important; }

  .setup-card, .answer-wrap {
    background: #fff;
    border-radius: 20px;
    box-shadow: 0 10px 30px rgba(15, 23, 42, 0.08);
    padding: 26px 22px;
    max-width: 620px;
    margin: 0 auto;
  }
  h1 { font-size: 19px; margin: 0 0 8px; text-align: center; }
  .lead { font-size: 12.5px; color: #64748b; line-height: 1.7; text-align: center; margin: 0 0 18px; }
  .field-label { font-size: 12.5px; font-weight: 700; color: #475569; display: block; margin-bottom: 6px; }

  #questionInput {
    width: 100%; padding: 12px 14px; font-size: 15px; border-radius: 12px; border: 2px solid #cbd5e1;
    font-family: inherit; margin-bottom: 16px;
  }
  #questionInput:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px #dbeafe; }

  .setting-block { margin-bottom: 16px; }
  .btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
  .opt-btn {
    font-size: 13px; font-weight: 700; padding: 8px 16px; border-radius: 999px; border: 2px solid #cbd5e1;
    background: #fff; color: #1f2937; cursor: pointer; transition: all 0.15s ease-out;
  }
  .opt-btn.active { border-color: #2563eb; background: #eff6ff; color: #1d4ed8; }
  .opt-btn:hover { border-color: #2563eb; }

  #choiceInputs { display: flex; flex-direction: column; gap: 8px; margin-bottom: 18px; }
  .choice-input-row { display: flex; align-items: center; gap: 8px; }
  .color-swatch { flex-shrink: 0; width: 20px; height: 20px; border-radius: 50%; }
  .choice-input {
    flex: 1 1 auto; padding: 9px 12px; font-size: 13.5px; border-radius: 10px; border: 2px solid #cbd5e1;
    font-family: inherit;
  }
  .choice-input:focus { outline: none; border-color: #2563eb; }

  .preview-block { margin-bottom: 18px; }
  #previewArea { background: #f8fafc; border-radius: 14px; padding: 12px 4px 4px; }

  #makeUrlBtn {
    width: 100%; padding: 14px; font-size: 16px; font-weight: 700; border: none; border-radius: 12px;
    background: #2563eb; color: #fff; cursor: pointer; transition: all 0.15s ease-out;
  }
  #makeUrlBtn:hover { background: #1d4ed8; }

  #urlResult { margin-top: 16px; text-align: center; }
  #urlResult p { font-size: 12.5px; color: #475569; margin: 0 0 8px; }
  #shareUrl {
    word-break: break-all; font-family: monospace; font-size: 11.5px; font-weight: 600;
    background: #f1f5f9; border: 2px dashed #cbd5e1; border-radius: 10px; padding: 12px; color: #1d4ed8;
    margin-bottom: 10px;
  }
  .url-actions { display: flex; gap: 8px; }
  .url-actions button {
    flex: 1; padding: 10px; font-size: 12.5px; font-weight: 700; border: none; border-radius: 10px;
    cursor: pointer; transition: all 0.15s ease-out;
  }
  #copyUrlBtn { background: #e0e7ff; color: #3730a3; }
  #copyUrlBtn:hover { background: #c7d2fe; }
  #previewOpenBtn { background: #dcfce7; color: #15803d; }
  #previewOpenBtn:hover { background: #bbf7d0; }

  #backToSetupBtn {
    font-size: 12px; font-weight: 700; padding: 7px 14px; border: none; border-radius: 999px;
    background: #e2e8f0; color: #334155; cursor: pointer; transition: all 0.15s ease-out;
    margin-bottom: 14px;
  }
  #backToSetupBtn:hover { background: #cbd5e1; }

  .question-text { font-size: 18px; font-weight: 700; text-align: center; margin: 4px 0 18px; line-height: 1.6; }
  .heart-wrap { display: flex; justify-content: center; margin-bottom: 22px; }
  .heart-wrap svg { width: min(50vw, 220px); height: auto; }
  .heart-path { fill: #e2e8f0; transition: fill 0.3s ease-out; }
  @keyframes heartPulse {
    0% { transform: scale(1); }
    30% { transform: scale(1.18); }
    60% { transform: scale(0.94); }
    100% { transform: scale(1); }
  }
  .heart-wrap.pulse svg { animation: heartPulse 0.5s ease-out; transform-origin: center; }

  .choice-row { display: flex; flex-wrap: wrap; gap: 10px; justify-content: center; margin-bottom: 20px; }
  .choice-btn {
    padding: 12px 18px; font-size: 14px; font-weight: 700; border-radius: 999px; border: 3px solid #cbd5e1;
    background: #fff; cursor: pointer; transition: all 0.15s ease-out;
  }

  .send-block { max-width: 380px; margin: 0 auto; text-align: center; }
  .send-row { display: flex; gap: 8px; margin-bottom: 8px; }
  #nameInput {
    flex: 1 1 auto; padding: 10px 12px; font-size: 14px; border-radius: 10px; border: 2px solid #cbd5e1;
    font-family: inherit;
  }
  #nameInput:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px #dbeafe; }
  #sendBtn {
    flex-shrink: 0; padding: 10px 18px; font-size: 13.5px; font-weight: 700; border: none; border-radius: 10px;
    background: #16a34a; color: #fff; cursor: pointer; transition: all 0.15s ease-out;
  }
  #sendBtn:hover:not(:disabled) { background: #15803d; }
  #sendBtn:disabled { background: #cbd5e1; cursor: default; }
  #sendStatus { font-size: 12.5px; font-weight: 700; min-height: 16px; }
</style>
</head>
<body>
<div id="app">

  <section id="setup-screen" hidden>
    <div class="setup-card">
      <h1>💗 気持ちの温度計(先生の設定)</h1>
      <p class="lead">発問と選択肢を自由に決められます。できあがった共有URLを児童生徒に配ると、選んだ答えに応じてハートの色が変わる画面が開きます。</p>

      <label class="field-label" for="questionInput">発問</label>
      <input type="text" id="questionInput" placeholder="例:今日の学習はどのくらい分かりましたか?" maxlength="80">

      <div class="setting-block">
        <span class="field-label">選択肢の数</span>
        <div class="btn-row" id="choiceCountRow">
          <button class="opt-btn" data-count="3" type="button">3個</button>
          <button class="opt-btn" data-count="4" type="button">4個</button>
          <button class="opt-btn active" data-count="5" type="button">5個</button>
          <button class="opt-btn" data-count="6" type="button">6個</button>
          <button class="opt-btn" data-count="7" type="button">7個</button>
        </div>
      </div>

      <div class="setting-block">
        <span class="field-label">選択肢のことば(自由に書きかえてOK)</span>
        <div id="choiceInputs"></div>
      </div>

      <div class="preview-block">
        <span class="field-label">プレビュー(タップしてためせます)</span>
        <div id="previewArea"></div>
      </div>

      <button id="makeUrlBtn" type="button">🔗 共有URLを作る</button>

      <div id="urlResult" hidden>
        <p>このURLを児童生徒に共有してください(Google Classroomなど)</p>
        <div id="shareUrl"></div>
        <div class="url-actions">
          <button id="copyUrlBtn" type="button">📋 コピーする</button>
          <button id="previewOpenBtn" type="button">👀 この内容で試す</button>
        </div>
      </div>
    </div>
  </section>

  <section id="answer-screen" hidden>
    <div class="answer-wrap">
      <button id="backToSetupBtn" type="button" hidden>⚙️ 設定を作り直す</button>
      <div id="answerContent"></div>
      <div class="send-block" id="sendBlock" hidden>
        <div class="send-row">
          <input type="text" id="nameInput" placeholder="なまえを入力してね">
          <button id="sendBtn" type="button">📤 送る</button>
        </div>
        <div id="sendStatus"></div>
      </div>
    </div>
  </section>

  <section id="link-error-screen" hidden>
    <div class="setup-card">
      <h1>🤔 リンクが正しくありません</h1>
      <p class="lead">URLが正しくコピーされていないようです。先生にもう一度リンクを送ってもらってください。</p>
    </div>
  </section>

</div>

<script>
(function(){
  const setupScreen = document.getElementById('setup-screen');
  const answerScreen = document.getElementById('answer-screen');
  const linkErrorScreen = document.getElementById('link-error-screen');

  const questionInput = document.getElementById('questionInput');
  const choiceCountRow = document.getElementById('choiceCountRow');
  const choiceInputsEl = document.getElementById('choiceInputs');
  const previewArea = document.getElementById('previewArea');
  const makeUrlBtn = document.getElementById('makeUrlBtn');
  const urlResult = document.getElementById('urlResult');
  const shareUrlEl = document.getElementById('shareUrl');
  const copyUrlBtn = document.getElementById('copyUrlBtn');
  const previewOpenBtn = document.getElementById('previewOpenBtn');

  const backToSetupBtn = document.getElementById('backToSetupBtn');
  const answerContent = document.getElementById('answerContent');
  const sendBlock = document.getElementById('sendBlock');
  const nameInput = document.getElementById('nameInput');
  const sendBtn = document.getElementById('sendBtn');
  const sendStatus = document.getElementById('sendStatus');

  const HEART_PATH = "M16 29C16 29 0 18.5 0 8.7C0 3.9 3.9 0 8.7 0C11.6 0 14.2 1.4 16 3.6C17.8 1.4 20.4 0 23.3 0C28.1 0 32 3.9 32 8.7C32 18.5 16 29 16 29Z";

  const DEFAULT_LABELS = {
    3: ["そう思わない", "どちらでもない", "そう思う"],
    4: ["全くそう思わない", "そう思わない", "そう思う", "とてもそう思う"],
    5: ["全くそう思わない", "あまりそう思わない", "どちらでもない", "少しそう思う", "とてもそう思う"],
    6: ["全くそう思わない", "そう思わない", "あまりそう思わない", "少しそう思う", "そう思う", "とてもそう思う"],
    7: ["全くそう思わない", "そう思わない", "あまりそう思わない", "どちらでもない", "少しそう思う", "そう思う", "とてもそう思う"],
  };

  function colorForIndex(i, total){
    const hue = total <= 1 ? 210 : 210 - (210 * i / (total - 1));
    return 'hsl(' + Math.round(hue) + ', 70%, 50%)';
  }

  function encodeConfig(question, choices){
    const payload = { v: 1, q: question, c: choices };
    try {
      return btoa(unescape(encodeURIComponent(JSON.stringify(payload))));
    } catch(e){
      return null;
    }
  }

  function decodeConfig(code){
    try {
      const json = decodeURIComponent(escape(atob(code)));
      const payload = JSON.parse(json);
      if(!payload || typeof payload.q !== 'string' || !payload.q.trim()) return null;
      if(!Array.isArray(payload.c) || payload.c.length < 2) return null;
      if(!payload.c.every((c) => typeof c === 'string' && c.trim().length > 0)) return null;
      return payload;
    } catch(e){
      return null;
    }
  }

  // 質問+ハート+選択肢のUIを組み立てる。プレビューと回答画面の両方で使う共通部品。
  function buildHeartUI(question, choices, opts){
    opts = opts || {};
    const wrap = document.createElement('div');

    const q = document.createElement('div');
    q.className = 'question-text';
    q.textContent = question;
    wrap.appendChild(q);

    const heartWrap = document.createElement('div');
    heartWrap.className = 'heart-wrap';
    heartWrap.innerHTML = '<svg viewBox="0 0 32 29"><path class="heart-path" d="' + HEART_PATH + '"></path></svg>';
    const heartPath = heartWrap.querySelector('.heart-path');
    wrap.appendChild(heartWrap);

    const choiceRow = document.createElement('div');
    choiceRow.className = 'choice-row';
    const buttons = [];
    choices.forEach((label, i) => {
      const color = colorForIndex(i, choices.length);
      const btn = document.createElement('button');
      btn.type = 'button';
      btn.className = 'choice-btn';
      btn.textContent = label;
      btn.style.borderColor = color;
      btn.style.color = color;
      btn.addEventListener('click', () => {
        heartPath.setAttribute('fill', color);
        heartWrap.classList.remove('pulse');
        void heartWrap.offsetWidth;
        heartWrap.classList.add('pulse');
        buttons.forEach((b, bi) => {
          const bColor = colorForIndex(bi, choices.length);
          if(bi === i){
            b.style.background = bColor;
            b.style.color = '#fff';
          } else {
            b.style.background = '#fff';
            b.style.color = bColor;
          }
        });
        if(opts.onSelect) opts.onSelect(i, label, color);
      });
      buttons.push(btn);
      choiceRow.appendChild(btn);
    });
    wrap.appendChild(choiceRow);

    return wrap;
  }

  // ---------------- 設定画面(先生) ----------------
  let choiceCount = 5;

  function currentChoiceLabels(){
    return Array.from(choiceInputsEl.querySelectorAll('.choice-input')).map((el) => el.value.trim());
  }

  function renderChoiceInputs(){
    choiceInputsEl.innerHTML = '';
    const defaults = DEFAULT_LABELS[choiceCount];
    for(let i = 0; i < choiceCount; i++){
      const row = document.createElement('div');
      row.className = 'choice-input-row';
      const swatch = document.createElement('span');
      swatch.className = 'color-swatch';
      swatch.style.background = colorForIndex(i, choiceCount);
      const input = document.createElement('input');
      input.type = 'text';
      input.className = 'choice-input';
      input.value = defaults[i];
      input.maxLength = 20;
      input.addEventListener('input', updatePreview);
      row.appendChild(swatch);
      row.appendChild(input);
      choiceInputsEl.appendChild(row);
    }
    updatePreview();
  }

  function updatePreview(){
    const question = questionInput.value.trim() || '(ここに発問が表示されます)';
    const choices = currentChoiceLabels().map((c) => c || '(選択肢)');
    previewArea.innerHTML = '';
    previewArea.appendChild(buildHeartUI(question, choices));
    urlResult.hidden = true;
  }

  choiceCountRow.addEventListener('click', (e) => {
    const btn = e.target.closest('.opt-btn');
    if(!btn) return;
    choiceCount = Number(btn.dataset.count);
    choiceCountRow.querySelectorAll('.opt-btn').forEach((b) => b.classList.toggle('active', b === btn));
    renderChoiceInputs();
  });

  questionInput.addEventListener('input', updatePreview);

  function shareUrlFor(question, choices){
    const code = encodeConfig(question, choices);
    if(!code) return null;
    return window.location.origin + window.location.pathname + '#c=' + encodeURIComponent(code);
  }

  makeUrlBtn.addEventListener('click', () => {
    const question = questionInput.value.trim();
    const choices = currentChoiceLabels();
    if(!question){
      questionInput.focus();
      return;
    }
    if(choices.some((c) => !c)){
      choiceInputsEl.querySelectorAll('.choice-input').forEach((el) => {
        if(!el.value.trim()) el.focus();
      });
      return;
    }
    const url = shareUrlFor(question, choices);
    if(!url) return;
    shareUrlEl.textContent = url;
    urlResult.hidden = false;
  });

  copyUrlBtn.addEventListener('click', () => {
    const text = shareUrlEl.textContent;
    if(navigator.clipboard && navigator.clipboard.writeText){
      navigator.clipboard.writeText(text).then(() => {
        copyUrlBtn.textContent = '✅ コピーしました';
        setTimeout(() => { copyUrlBtn.textContent = '📋 コピーする'; }, 1500);
      }).catch(() => {});
    }
  });

  previewOpenBtn.addEventListener('click', () => {
    const question = questionInput.value.trim() || '(発問が入力されていません)';
    const choices = currentChoiceLabels().map((c) => c || '(選択肢)');
    showAnswerScreen({ q: question, c: choices }, { fromPreview: true });
  });

  backToSetupBtn.addEventListener('click', () => {
    answerScreen.hidden = true;
    setupScreen.hidden = false;
  });

  // ---------------- 回答画面(児童生徒) ----------------
  function safeGet(key){ try { return localStorage.getItem(key); } catch(e){ return null; } }
  function safeSet(key, val){ try { localStorage.setItem(key, val); } catch(e){} }
  const NAME_KEY = 'heart-thermometer-name';
  nameInput.value = safeGet(NAME_KEY) || '';

  let currentSelection = null;

  function showAnswerScreen(config, opts){
    opts = opts || {};
    setupScreen.hidden = true;
    linkErrorScreen.hidden = true;
    answerScreen.hidden = false;
    backToSetupBtn.hidden = !opts.fromPreview;

    answerContent.innerHTML = '';
    currentSelection = null;
    sendBlock.hidden = true;
    sendStatus.textContent = '';
    sendBtn.disabled = false;

    answerContent.appendChild(buildHeartUI(config.q, config.c, {
      onSelect: (idx, label, color) => {
        currentSelection = { idx: idx, label: label, color: color, question: config.q, count: config.c.length };
        sendBlock.hidden = false;
        sendStatus.textContent = '';
        sendBtn.disabled = false;
      },
    }));
  }

  sendBtn.addEventListener('click', () => {
    if(!currentSelection) return;
    const name = nameInput.value.trim();
    if(!name){
      sendStatus.textContent = 'なまえを入力してね';
      sendStatus.style.color = '#C0392B';
      nameInput.focus();
      return;
    }
    safeSet(NAME_KEY, name);

    if(typeof google === 'undefined' || !google.script || !google.script.run){
      sendStatus.textContent = 'この機能はGoogleスプレッドシート連携版として配信された環境でのみ使えます。';
      sendStatus.style.color = '#C0392B';
      return;
    }

    sendBtn.disabled = true;
    sendStatus.textContent = '送信中…';
    sendStatus.style.color = '#475569';

    google.script.run
      .withSuccessHandler(function(){
        sendStatus.textContent = '送信しました!ありがとう。';
        sendStatus.style.color = '#4F8A4B';
      })
      .withFailureHandler(function(){
        sendStatus.textContent = '送信に失敗しました。もう一度試してね';
        sendStatus.style.color = '#C0392B';
        sendBtn.disabled = false;
      })
      .sendHeartResult({
        name: name,
        question: currentSelection.question,
        choiceLabel: currentSelection.label,
        choiceIndex: currentSelection.idx,
        choiceCount: currentSelection.count,
      });
  });

  // ---------------- 起動時の分岐 ----------------
  function init(){
    const hash = window.location.hash || '';
    if(hash.indexOf('#c=') === 0){
      const config = decodeConfig(decodeURIComponent(hash.slice(3)));
      if(config){
        showAnswerScreen(config);
        return;
      }
      linkErrorScreen.hidden = false;
      return;
    }
    setupScreen.hidden = false;
    renderChoiceInputs();
  }

  init();
})();
</script>
</body>
</html>
2
Code.gs
Googleスプレッドシートへの書き込みを行うバックエンド処理
/**
 * 「気持ちの温度計」 - みんなの回答をスプレッドシートに送る機能
 *
 * 使い方(デプロイ手順)は README.txt を参照してください。
 */

function doGet(e) {
  return HtmlService.createTemplateFromFile('index')
    .evaluate()
    .setTitle('気持ちの温度計')
    .addMetaTag('viewport', 'width=device-width, initial-scale=1')
    .setXFrameOptionsMode(HtmlService.XFrameOptionsMode.ALLOWALL);
}

function getOrCreateSpreadsheet_() {
  const props = PropertiesService.getScriptProperties();
  const savedId = props.getProperty('SPREADSHEET_ID');

  if (savedId) {
    try {
      return SpreadsheetApp.openById(savedId);
    } catch (err) {
      // 保存されていたIDが無効な場合は作り直す
    }
  }

  const ss = SpreadsheetApp.create('気持ちの温度計 - みんなの回答');
  const sheet = ss.getSheets()[0];
  sheet.appendRow(['送信日時', 'なまえ', '発問', '選んだ選択肢', '選択肢の番号(0始まり)', '選択肢の総数']);
  sheet.setFrozenRows(1);
  props.setProperty('SPREADSHEET_ID', ss.getId());
  return ss;
}

/**
 * フロント側から google.script.run 経由で呼ばれる。
 * data = {
 *   name: string, question: string, choiceLabel: string,
 *   choiceIndex: number, choiceCount: number
 * }
 */
function sendHeartResult(data) {
  if (!data || !data.name) {
    throw new Error('なまえがありません');
  }

  const ss = getOrCreateSpreadsheet_();
  const sheet = ss.getSheets()[0];
  sheet.appendRow([
    new Date(),
    data.name.toString().slice(0, 40),
    (data.question || '').toString().slice(0, 200),
    (data.choiceLabel || '').toString().slice(0, 40),
    data.choiceIndex,
    data.choiceCount,
  ]);

  return { ok: true };
}
3
README.txt(デプロイ手順)
script.google.com への貼り付け方・公開方法
「気持ちの温度計」 スプレッドシート送信版 - デプロイ手順
====================================================

このフォルダには3つのファイルがあります。
・index.html … 児童が触る画面(オフライン版に「なまえ」欄と「送る」ボタンを追加したもの)
・Code.gs     … スプレッドシートにデータを送るためのバックエンド処理
・README.txt  … このファイル

【1】Google Apps Script プロジェクトを作る
  1. Google ドライブを開く(学校アカウントでログイン)
  2. 「新規」→「その他」→「Google Apps Script」
     (見当たらない場合は https://script.google.com/ から「新しいプロジェクト」)
  3. プロジェクト名を「気持ちの温度計」などにする

【2】コードを貼り付ける
  1. 左側のファイル一覧にある「Code.gs」を開き、中身をすべて削除
  2. 渡した Code.gs の内容をすべて貼り付けて保存(Ctrl+S)
  3. 左側の「+」→「HTML」を選び、ファイル名を必ず「index」にする(拡張子は自動でつきます)
  4. 渡した index.html の内容をすべて貼り付けて保存

【3】ウェブアプリとして公開する
  1. 右上の「デプロイ」→「新しいデプロイ」
  2. 種類の選択(歯車マーク)で「ウェブアプリ」を選ぶ
  3. 「次のユーザーとして実行」→ 自分(先生のアカウント)
  4. 「アクセスできるユーザー」→ 学校のドメイン内の全員
     (例:kita9.ed.jp 内の全員、など。学校のGoogle Workspace設定に合わせてください)
  5. 「デプロイ」をクリックし、表示されたウェブアプリのURLをコピーする
  6. 初回のみ、権限の承認画面が出るので許可する

【4】児童に配布する
  ・コピーしたURLをブックマークやGoogle Classroom等で共有すれば、
    Chromebookのブラウザからそのまま使えます。

【5】スプレッドシートの確認
  ・誰かが初めて「送る」を押したときに、自動で
    「気持ちの温度計 - みんなのふりかえり」という
    スプレッドシートがドライブに作成されます。
  ・以降の送信は、そのシートに1人1行ずつ追加されていきます。
  ・共有設定(誰が見られるか)は、作成後にドライブから
    先生が調整してください(デフォルトは先生のみ閲覧可)。

【困ったときは】
  ・貼り付けたコードを直接書き換えても大丈夫です。次に相談するときは
    「Code.gsの◯◯を直したい」のように伝えてもらえれば調整できます。