<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<title>推論の記録の判定</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600&family=Noto+Sans+JP:wght@400;600;700&display=swap" rel="stylesheet">
<style>
  /* 見た目は配膳ロボット ブラウザ版 (pixelstomotors.com/sim/serving-robot-3d) に合わせる (依頼 186) */
  :root {
    --ink: #1d1d1b; --sub: #6b6b66; --line: #d8d6d0; --bg: #f2f1ee; --stop: #d64545; --ok: #1761e6;
    --s1: #2a78d6; --s2: #eb6834; --s3: #1baf7a; --s4: #eda100; --s5: #e87ba4; --s6: #008300;
    --grip: #6b6b66;
  }
  * { box-sizing: border-box; }
  body { margin: 0; font-family: "Noto Sans JP", "Inter", system-ui, sans-serif; font-size: 13px; line-height: 1.5; color: var(--ink);
         background: var(--bg); height: 100vh; display: grid; grid-template-columns: 280px 1fr 320px; }
  /* 左の一覧 */
  #side { min-height: 0; background: #fff; border-right: 1px solid var(--line); display: flex; flex-direction: column; }
  #side header { padding: 16px; border-bottom: 1px solid var(--line); }
  #side h1 { margin: 0 0 8px; font-size: 15px; }
  #filter { width: 100%; font: inherit; border: 1px solid var(--line); border-radius: 6px; padding: 5px 40px 5px 6px; color: var(--ink); cursor: pointer;
    appearance: none; -webkit-appearance: none; height: 33px;  /* ブラウザの矢印の代わりに太い山形. 高さは元の 33px のまま (依頼 198) */
    background: #fff url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1.5l5 5 5-5' fill='none' stroke='%231d1d1b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") no-repeat right 14px center / 12px 8px; }
  #count { color: var(--sub); margin-top: 6px; font-size: 11px; }
  #list { overflow-y: auto; flex: 1; }
  .item { padding: 6px 16px; border-bottom: 1px solid var(--line); cursor: pointer; display: flex; justify-content: space-between; gap: 6px; }
  .item:hover { background: var(--bg); }
  .item.sel { background: var(--ink); color: #fff; }
  .item .stem { font: 600 13px/1.3 "Inter", "Noto Sans JP", system-ui, sans-serif; }
  .item .sub { color: var(--sub); font-size: 11px; }
  .item.sel .sub { color: rgba(255,255,255,.7); }
  /* 判定の札: 形は手本の .tag. 成功は青 (手本のスタッフ)・失敗は赤 (手本の止まっている)・ほかは色なし.
     人が確定 (と記録時の入力・決め) は塗り, AI だけ (未確定) は白地に同じ色の 1px の枠と字 (依頼 189).
     枠は高さを変えないよう内側の線で引く */
  .tag { flex: none; min-width: 24px; text-align: center; font: 600 11px/18px "Inter", "Noto Sans JP", system-ui, sans-serif; border-radius: 6px;
         padding: 0 7px; background: #fff; color: var(--ink); box-shadow: inset 0 0 0 1px var(--line); white-space: nowrap; align-self: center; display: inline-block; }
  .tag.success { background: var(--ok); color: #fff; box-shadow: inset 0 0 0 1px var(--ok); }
  .tag.failure { background: var(--stop); color: #fff; box-shadow: inset 0 0 0 1px var(--stop); }
  .tag.ai.success { background: #fff; color: var(--ok); }
  .tag.ai.failure { background: #fff; color: var(--stop); }
  /* 真ん中 */
  #main { min-width: 0; overflow-y: auto; padding: 14px 16px; }
  #hud { display: inline-block; background: rgba(255,255,255,.92); padding: 10px 14px; border-radius: 8px; margin-bottom: 12px; }
  #title { font: 600 28px/1 "Inter", "Noto Sans JP", system-ui, sans-serif; }
  #meta { margin: 6px 0 0; font-size: 12px; color: var(--sub); }
  #media { width: 640px; }
  #media video, #media img { width: 640px; background: #000; display: block; border-radius: 8px; }
  #framebar { display: flex; gap: 10px; align-items: center; margin-top: 8px; background: rgba(255,255,255,.92); padding: 8px 12px; border-radius: 8px; }
  #fr { flex: 1; -webkit-appearance: none; appearance: none; height: 6px; border-radius: 3px;
        background: linear-gradient(90deg, #1d1d1b var(--done, 0%), #d8d6d0 var(--done, 0%)); }
  #fr::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: #fff; border: 2px solid #1d1d1b; cursor: pointer; }
  #fr::-moz-range-thumb { width: 14px; height: 14px; border-radius: 50%; background: #fff; border: 2px solid #1d1d1b; }
  #fl { font-size: 11px; color: var(--sub); white-space: nowrap; }
  #media .muted { margin-top: 4px; }
  /* 右の操作パネル */
  #panel { min-height: 0; background: #fff; border-left: 1px solid var(--line); padding: 16px; overflow: auto; font-size: 13px; }
  h2 { font-size: 12px; color: var(--sub); font-weight: 600; margin: 16px 0 6px; }
  #panel > h2:first-child { margin-top: 0; }
  .nums { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 12px; margin-top: 4px; }
  .num { line-height: 1.25; }
  .num b { display: block; font: 600 24px/1.1 "Inter", "Noto Sans JP", system-ui, sans-serif; }
  .num small { font-size: 12px; font-weight: 400; margin-left: 2px; }
  .num > span { display: block; margin-top: 1px; line-height: 1.3; font-size: 11px; color: var(--sub); }
  .note { margin: 6px 0 0; font-size: 11px; line-height: 1.6; color: var(--sub); }
  .seg { display: flex; flex-wrap: wrap; gap: 4px; }
  .row { display: flex; gap: 6px; align-items: center; margin-top: 8px; flex-wrap: wrap; }
  .row .lbl { width: 40px; color: var(--sub); font-size: 12px; font-weight: 600; }
  button { font: inherit; border: 1px solid var(--line); background: #fff; border-radius: 6px; padding: 5px 9px; cursor: pointer; color: var(--ink); }
  button.on { background: var(--ink); color: #fff; border-color: var(--ink); }
  button.key { flex: 1 1 calc(50% - 3px); text-align: left; }
  /* 判定するのボタン: 人の確定と押した判定を塗る. 成功は青・失敗は赤・ほかは --ink (依頼 189) */
  button.key.on[data-oc="success"] { background: var(--ok); border-color: var(--ok); }
  button.key.on[data-oc="failure"] { background: var(--stop); border-color: var(--stop); }
  /* 確定のボタン: 手本の #cta a の強さ. y/n/u/x を選ぶまでは薄く押せない (依頼 192) */
  #confirm { display: block; width: 100%; margin-top: 10px; text-align: center; background: var(--ink); color: #fff; border-color: var(--ink);
             font-weight: 600; font-size: 14px; padding: 11px 12px; border-radius: 6px; }
  #confirm:hover:not(:disabled) { background: #000; }
  #confirm:disabled { opacity: .3; cursor: default; }
  #confirm kbd { margin-left: 6px; }
  kbd { font: 600 11px/1 "Inter", system-ui, sans-serif; border: 1px solid var(--line); border-radius: 6px; padding: 2px 5px; background: var(--bg); color: var(--ink); }
  #note { width: 100%; font: inherit; padding: 5px 6px; border: 1px solid var(--line); border-radius: 6px; color: var(--ink); }
  #status { min-height: 1em; margin: 6px 0 0; font-size: 12px; color: var(--sub); }
  #status.err { color: var(--stop); font-weight: 600; }
  #status.ok { color: var(--ink); }
  .dark { background: rgba(60,62,66,.86); color: #fff; border-radius: 8px; padding: 10px 12px; font-size: 12px; }
  .dark .muted { color: #b9bcc1; }
  .big { font-size: 16px; font-weight: 600; }
  /* グラフ */
  #chart { margin-top: 12px; position: relative; background: #fff; border: 1px solid var(--line); border-radius: 8px; padding: 10px 12px; }
  #chart:empty { display: none; }
  #chart svg text { font: 400 11px "Inter", "Noto Sans JP", system-ui, sans-serif; fill: var(--sub); }
  #legend { display: flex; gap: 12px; flex-wrap: wrap; font-size: 12px; color: var(--sub); margin: 0 0 4px; }
  #legend span::before { content: ""; display: inline-block; width: 14px; height: 2px; vertical-align: middle; margin-right: 4px; background: var(--c); }
  #tip { position: absolute; pointer-events: none; background: rgba(255,255,255,.92); border: 1px solid var(--line); border-radius: 6px;
         padding: 4px 6px; font-size: 11px; display: none; white-space: nowrap; }
  #tip i { display: inline-block; width: 10px; height: 2px; vertical-align: middle; margin-right: 4px; }
  table.hist { border-collapse: collapse; font-size: 11px; width: 100%; }
  table.hist td, table.hist th { border-bottom: 1px solid var(--line); padding: 4px 4px 4px 0; text-align: left; vertical-align: top; }
  table.hist th { color: var(--sub); font-weight: 600; }
  table.hist td:not(:first-child), table.hist th { white-space: nowrap; }
  .muted { color: var(--sub); }
  /* 自分が確定した回 (依頼 187). 形は button と #hud の白い札だけ */
  #recent button { display: flex; width: 100%; justify-content: space-between; gap: 6px; margin-top: 4px; text-align: left; }
  #recent .stem { font: 600 12px/1.3 "Inter", "Noto Sans JP", system-ui, sans-serif; }
  #recent button .tag { margin-right: 4px; }
  #toast { position: fixed; bottom: 20px; left: 50%; transform: translateX(-50%); background: rgba(255,255,255,.92); padding: 10px 14px;
           border-radius: 8px; font-weight: 600; display: none; z-index: 10; }
  #toast .tag { margin-left: 4px; }
  #cur .tag { vertical-align: 2px; }
</style>
</head>
<body>
<div id="side">
  <header>
    <h1>推論の記録の判定</h1>
    <select id="filter">
      <option value="todo">まだ確定していない回</option>
      <option value="all">全部</option>
      <option value="failure">失敗だけ (今の判定)</option>
      <option value="diff">AI と人が違う回</option>
      <option value="mine">自分が確定した回 (新しい順)</option>
    </select>
    <div id="count"></div>
  </header>
  <div id="list"></div>
</div>
<div id="main">
  <div id="hud">
    <div id="title">—</div>
    <div id="meta"></div>
  </div>
  <div id="media"></div>
  <div id="chart"></div>
</div>
<div id="toast"></div>
<aside id="panel">
  <h2>判定する (キー)</h2>
  <div class="row">
    <button class="key" data-oc="success"><kbd>y</kbd> 成功</button>
    <button class="key" data-oc="failure"><kbd>n</kbd> 失敗</button>
    <button class="key" data-oc="unknown"><kbd>u</kbd> 分からない</button>
    <button class="key" data-oc="not_judgeable"><kbd>x</kbd> 判定できない</button>
  </div>
  <div class="row"><span class="lbl">pick</span>
    <div class="seg"><button data-pp="pick" data-v="yes">つかめた</button><button data-pp="pick" data-v="no">つかめなかった</button><button data-pp="pick" data-v="unknown">分からない</button></div></div>
  <div class="row"><span class="lbl">place</span>
    <div class="seg"><button data-pp="place" data-v="yes">置けた</button><button data-pp="place" data-v="no">置けなかった</button><button data-pp="place" data-v="not_applicable">そこまで行かなかった</button><button data-pp="place" data-v="unknown">分からない</button></div></div>
  <p class="note">選ばなければ 成功→つかめた・置けた / ほか→分からない</p>
  <button id="confirm" disabled>確定<kbd>Enter</kbd></button>
  <h2>いま確定した回 (新しい 5 回)</h2><div id="recent"></div>
  <h2>ひとこと</h2>
  <input id="note" placeholder="ひとこと (空でよい・入力中は y/n/u/x は効かない・Enter で確定)">
  <div id="status"></div>
  <p class="note"><kbd>←</kbd> <kbd>→</kbd> 前後の回 ・ <kbd>Enter</kbd> 確定 ・ <kbd>,</kbd> <kbd>.</kbd> コマ送り / 動画は <kbd>space</kbd> で再生</p>
  <h2>AI の判定 (未確定)</h2><div id="ai" class="dark"></div>
  <h2>今の判定 (いちばん新しい行)</h2><div id="cur"></div>
  <h2>成功率 (確定した回だけ)</h2>
  <div id="rate"></div>
  <h2>この回の行 (古い順)</h2><div id="hist"></div>
</aside>
<script>
const OC_JA = {success: "成功", failure: "失敗", unknown: "分からない", not_judgeable: "判定できない", not_applicable: "試行でない", "": "なし"};
// pick / place の値は yes / no / unknown (place だけ not_applicable も) のまま. 画面の字だけ日本語 (依頼 193)
const PICK_JA = {yes: "つかめた", no: "つかめなかった", unknown: "分からない"};
const PLACE_JA = {yes: "置けた", no: "置けなかった", not_applicable: "そこまで行かなかった", unknown: "分からない"};
const ppJa = r => `pick ${esc(PICK_JA[r.pick] ?? r.pick)} / place ${esc(PLACE_JA[r.place] ?? r.place)}`;
const LB_JA = {run_input: "記録時の入力", ai: "AI", human: "人 (AI と同じ)", human_fixed_ai: "人 (AI を直した)", human_only: "人 (AI なし)", rule: "決め (依頼185)", "": ""};
const COLORS = ["--s1", "--s2", "--s3", "--s4", "--s5", "--s6"].map(v => getComputedStyle(document.documentElement).getPropertyValue(v).trim());
const pendingOps = {};  // run_stem → 書けたか分からない操作 (回ごとに残す. codex 6 回目 R1)
let selSeq = 0;  // 応答が届かなかった操作. 同じ中身で押し直したら同じ op_id で送る (codex 2 回目 R2)
let runs = [], view = [], cur = null, detail = null, pp = {pick: null, place: null}, frameIdx = 0, busy = false, loading = false;
let chosen = null;  // y/n/u/x で選んだ判定. 確定するまで書かない・回を移ったら捨てる (依頼 192)

const $ = s => document.querySelector(s);
const esc = s => String(s ?? "").replace(/[&<>"]/g, c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;"}[c]));
function status(msg, kind) { const el = $("#status"); el.textContent = msg; el.className = kind || ""; }
const HUMAN = ["human", "human_fixed_ai", "human_only"];  // 人が確定した行 (依頼 187)
const hm = iso => String(iso || "").slice(11, 16);  // 時:分 (記録した側の時刻のまま)
const byHumanAt = (a, b) => Date.parse(b.human_at) - Date.parse(a.human_at);
let toastTimer = null;
function toast(html) { const el = $("#toast"); el.innerHTML = html; el.style.display = "block"; clearTimeout(toastTimer); toastTimer = setTimeout(() => el.style.display = "none", 3000); }
function tag(oc, ai) { return oc ? `<span class="tag ${oc} ${ai ? "ai" : ""}">${ai ? "AI " : ""}${OC_JA[oc]}</span>` : `<span class="tag">印なし</span>`; }

function applyFilter() {
  const f = $("#filter").value;
  view = runs.filter(r => f === "all" ? true : f === "todo" ? !r.confirmed : f === "failure" ? r.outcome === "failure"
                          : f === "mine" ? !!r.human_at
                          : (r.ai_outcome && r.human_outcome && r.ai_outcome !== r.human_outcome));
  if (f === "mine") view.sort(byHumanAt);  // 確定した時刻が新しい順 (依頼 187)
  $("#count").textContent = `${view.length} 回 / 全 ${runs.length} 回 (確定済み ${runs.filter(r => r.confirmed).length})`;
  $("#list").innerHTML = view.map(r => `<div class="item ${cur === r.run_stem ? "sel" : ""}" data-stem="${r.run_stem}">
      <div><div class="stem">${r.run_stem.replace("pi05_", "")}</div>
      <div class="sub">${f === "mine" ? `人が確定 ${hm(r.human_at)} ・ ${LB_JA[r.labeler] || "—"}`
        : `${r.has_mp4 ? "top.mp4" : r.n_frames ? "frames " + r.n_frames : "素材なし"} ・ ${LB_JA[r.labeler] || "—"}`}</div></div>
      ${f === "mine" ? tag(r.human_outcome, false) : tag(r.outcome, r.labeler === "ai")}</div>`).join("");
  document.querySelectorAll(".item").forEach(el => el.onclick = () => select(el.dataset.stem));
  renderRecent();
}

// いま確定した回: 人の行がある回を確定した時刻の新しい順に 5 回. 押すと開く (絞り込みはそのまま. 依頼 187)
function renderRecent() {
  const rec = runs.filter(r => r.human_at).sort(byHumanAt).slice(0, 5);
  $("#recent").innerHTML = rec.length ? rec.map(r => `<button class="${cur === r.run_stem ? "on" : ""}" data-recent="${r.run_stem}">
      <span class="stem">${r.run_stem.replace("pi05_", "")}</span><span>${tag(r.human_outcome, false)}${hm(r.human_at)}</span></button>`).join("")
    : `<span class="muted">まだありません</span>`;
  document.querySelectorAll("[data-recent]").forEach(b => b.onclick = () => { b.blur(); status(""); select(b.dataset.recent); });
}

// 成功率は確定した回だけ. 分母は 成功 + 失敗 (試行でない・判定できない・分からない は入れない. 依頼 185)
function renderRate(st) {
  if (!st) { $("#rate").textContent = ""; return; }
  const b = st.by_outcome || {};
  const num = (v, unit, label) => `<div class="num"><b>${v}${unit ? `<small>${unit}</small>` : ""}</b><span>${label}</span></div>`;
  $("#rate").innerHTML = `<div class="nums">`
    + num(st.rate === null ? "—" : (st.rate * 100).toFixed(1), st.rate === null ? "" : "%", "成功率 (成功 / (成功 + 失敗))")
    + num(st.confirmed, "回", "確定した回") + num(runs.filter(r => !r.confirmed).length, "回", "まだの回")
    + num(st.success, "回", "成功") + num(st.failure, "回", "失敗") + `</div>`
    + `<p class="note">分母に入れない: 試行でない ${b.not_applicable || 0} ・ 判定できない ${b.not_judgeable || 0} ・ 分からない ${b.unknown || 0} (確定 ${st.confirmed} 回のうち)</p>`;
}

async function loadRuns() {
  const res = await fetch("/api/runs"); const j = await res.json();
  if (!res.ok || !Array.isArray(j.runs)) throw new Error(j.error || "一覧を読めません");  // 今の一覧は残す (codex 4 回目 R1)
  runs = j.runs;
  renderRate(j.stats);  // まだの回は runs から数えるので runs を入れてから
  applyFilter();
}

async function select(stem) {
  const seq = ++selSeq;  // いちばん新しい選択の応答だけ使う (codex 5 回目 R2・6 回目 R3)
  if (!stem) {  // 空の一覧: 前の回の表示と状態を残さない (codex 7 回目 R1)
    cur = null; detail = null; chartGeom = null; loading = false; pp = {pick: null, place: null}; chosen = null; markPP(); markOC(null); $("#note").value = "";
    $("#title").textContent = "この絞り込みに残っている回はありません"; $("#meta").textContent = "";
    $("#media").innerHTML = $("#chart").innerHTML = $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = "";
    return true;
  }
  // 読み込みが終わるまでは判定を受け付けない (前の回を見ながら次の回に書かない. codex R1)
  cur = stem; detail = null; loading = true; pp = {pick: null, place: null}; chosen = null; frameIdx = 0; $("#note").value = "";
  const po = pendingOps[stem];
  if (po) {
    // 書けたか分からない操作の回に戻ったら, そのときの入力を戻す (同じ確定で同じ op_id になる. codex 5 回目 R1)
    pp = {pick: po.payload.pick, place: po.payload.place}; $("#note").value = po.payload.note; chosen = po.payload.outcome;
  }
  $("#title").textContent = stem + " (読み込み中)"; $("#media").innerHTML = ""; $("#chart").innerHTML = "";
  $("#ai").innerHTML = $("#cur").innerHTML = $("#hist").innerHTML = "";
  applyFilter(); markPP(); markOC(null);
  document.querySelector(`.item[data-stem="${stem}"]`)?.scrollIntoView({block: "nearest"});
  let d;
  try { const res = await fetch("/api/run/" + stem); d = await res.json(); if (!res.ok) throw new Error(d.error); }
  catch (e) { if (seq === selSeq) { $("#title").textContent = stem; status("この回を読めませんでした: " + e.message + " ・ 一覧でもう一度選んでください", "err"); } return false; }
  if (seq !== selSeq || d.run_stem !== cur) return false;
  detail = d; loading = false; markConfirm();
  if (!po) {  // 記録した pick / place を塗る (人の確定があればそれ, なければ確定の行. 依頼 190)
    const r = recordedRow();
    if (r) { pp = {pick: r.pick || null, place: r.place || null}; markPP(); }
  }
  $("#title").textContent = stem;
  $("#meta").textContent = `送ったチャンク ${detail.executed_chunks} ・ 終わり方 ${detail.end_reason || "記録なし"} ・ ${detail.model || ""}`;
  renderMedia(); renderInfo(); renderChart();
  return true;
}

function renderMedia() {
  const m = $("#media");
  if (detail.has_mp4) {
    m.innerHTML = `<video id="vid" controls muted preload="metadata" src="/media/${detail.run_stem}/top.mp4"></video><div class="muted">recording/top.mp4</div>`;
    const v = $("#vid");
    v.onerror = () => { if (detail.frames.length) { status("top.mp4 を再生できないので frames/ を出します", "err"); detail.has_mp4 = false; renderMedia(); } else status("top.mp4 を再生できません (frames/ もなし)", "err"); };
    v.ontimeupdate = () => cursor(v.currentTime);
  } else if (detail.frames.length) {
    const n = detail.frames.length;
    m.innerHTML = `<img id="frm"><div id="framebar"><button id="fp">‹</button><input id="fr" type="range" min="0" max="${n - 1}" value="${frameIdx}"><button id="fn">›</button><span id="fl"></span></div><div class="muted">frames/ (各チャンクの前に撮った入力画像)</div>`;
    $("#fp").onclick = () => stepFrame(-1); $("#fn").onclick = () => stepFrame(1);
    $("#fr").oninput = e => { frameIdx = +e.target.value; showFrame(); };
    showFrame();
  } else {
    m.innerHTML = `<div class="muted">映像がありません (top.mp4 も frames/ もない)</div>`;
  }
}
function showFrame() {
  const n = detail.frames.length; frameIdx = Math.max(0, Math.min(n - 1, frameIdx));
  $("#frm").src = `/media/${detail.run_stem}/frames/${detail.frames[frameIdx]}`;
  $("#fr").value = frameIdx; $("#fr").style.setProperty("--done", (n > 1 ? frameIdx / (n - 1) * 100 : 100) + "%"); $("#fl").textContent = `${frameIdx + 1} / ${n}`;
}
function stepFrame(d) { if (detail && !detail.has_mp4 && detail.frames.length && $("#frm")) { frameIdx += d; showFrame(); } }

function renderInfo() {
  const a = detail.ai, l = detail.latest;
  $("#ai").innerHTML = a ? `<div class="big">${OC_JA[a.outcome]}</div><div>${ppJa(a)} ・ 素材 ${a.evidence || "なし"}</div><div class="muted">${esc(a.ai_reason)}</div>` : `<span class="muted">AI の判定はありません</span>`;
  const h = detail.history.filter(r => HUMAN.includes(r.labeler)).pop();  // 人が確定した行 (依頼 187)
  const c = h || l;  // 札は 1 回だけ. 札・人が確定・時刻・pick / place を 1 行目に (依頼 190)
  $("#cur").innerHTML = c ? `<div>${tag(c.outcome, c.labeler === "ai")} ${h ? "<b>人が確定済み</b>" : `<span class="muted">${LB_JA[c.labeler]}</span>`} ・ ${esc(c.labeled_at.slice(0, 10))} ${hm(c.labeled_at)} ・ ${ppJa(c)}</div>${c.note ? `<div class="muted">${esc(c.note)}</div>` : ""}` : `<span class="muted">まだ判定がありません</span>`;
  markOC();
  $("#hist").innerHTML = detail.history.length ? `<table class="hist"><tr><th>時刻</th><th>判定</th><th>pick/place</th><th>付けた</th></tr>${detail.history.map(r => `<tr><td>${esc(r.labeled_at)}</td><td>${tag(r.outcome, r.labeler === "ai")}</td><td>${esc(PICK_JA[r.pick] ?? r.pick)}/${esc(PLACE_JA[r.place] ?? r.place)}</td><td>${LB_JA[r.labeler]}</td></tr>`).join("")}</table>` : `<span class="muted">なし</span>`;
}

// ---- 関節と爪のグラフ (関節は 1 本の軸・爪は下に別の帯. 2 つの縦軸は使わない) ----
let chartGeom = null;
function renderChart() {
  // 幅は #main から (空のときは #chart を隠すので. 余白 32・枠と内側 26. 依頼 186)
  const s = detail.states, el = $("#chart");
  if (!s.t.length) { el.innerHTML = `<div class="muted">関節の記録がありません (recording/states.csv も log.jsonl の state もない)</div>`; chartGeom = null; return; }
  const W = Math.max(640, ($("#main").clientWidth || 900) - 58), H = 240, GH = 44, L = 44, R = 12, T = 8, gap = 22;
  const tmax = Math.max(s.t[s.t.length - 1], ...(s.grip.map(g => g[0]))) || 1;
  let lo = Infinity, hi = -Infinity;
  s.joints.forEach(js => js.forEach(v => { lo = Math.min(lo, v); hi = Math.max(hi, v); }));
  const pad = (hi - lo) * 0.06 || 5; lo -= pad; hi += pad;
  const x = t => L + (W - L - R) * t / tmax, y = v => T + (H - T - 18) * (hi - v) / (hi - lo);
  const gy0 = H + gap, gy = v => gy0 + GH - 6 - (GH - 12) * Math.max(0, Math.min(1, v));
  const ticks = []; const step = niceStep((hi - lo) / 5);
  for (let v = Math.ceil(lo / step) * step; v <= hi; v += step) ticks.push(v);
  const tstep = niceStep(tmax / 8); const tt = []; for (let v = 0; v <= tmax; v += tstep) tt.push(v);
  let svg = `<svg width="${W}" height="${gy0 + GH + 20}" id="csvg">`;
  ticks.forEach(v => svg += `<line x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}" stroke="#eeeeea"/><text x="${L - 6}" y="${y(v) + 4}" text-anchor="end">${+v.toFixed(1)}°</text>`);
  tt.forEach(v => svg += `<text x="${x(v)}" y="${gy0 + GH + 14}" text-anchor="middle">${+v.toFixed(0)}s</text>`);
  s.stages.forEach(([t, st]) => svg += `<line x1="${x(t)}" x2="${x(t)}" y1="${T}" y2="${gy0 + GH}" stroke="#d8d6d0" stroke-dasharray="2,3"/>`);
  for (let j = 0; j < 6; j++) {
    const d = s.t.map((t, i) => `${i ? "L" : "M"}${x(t).toFixed(1)},${y(s.joints[i][j]).toFixed(1)}`).join("");
    svg += `<path d="${d}" fill="none" stroke="${COLORS[j]}" stroke-width="2" stroke-linejoin="round"/>`;
  }
  svg += `<rect x="${L}" y="${gy0}" width="${W - L - R}" height="${GH}" fill="none" stroke="#d8d6d0"/>`;
  svg += `<text x="${L - 6}" y="${gy(1) + 4}" text-anchor="end">閉</text><text x="${L - 6}" y="${gy(0) + 4}" text-anchor="end">開</text>`;
  if (s.grip.length) {
    let d = `M${x(0)},${gy(s.grip[0][1])}`, pv = s.grip[0][1];
    s.grip.forEach(([t, v]) => { d += `L${x(t).toFixed(1)},${gy(pv)}L${x(t).toFixed(1)},${gy(v)}`; pv = v; });
    d += `L${x(tmax)},${gy(pv)}`;
    svg += `<path d="${d}" fill="none" stroke="var(--grip)" stroke-width="2"/>`;
  }
  svg += `<line id="cx" x1="0" x2="0" y1="${T}" y2="${gy0 + GH}" stroke="#1d1d1b" stroke-width="1" style="display:none"/>`;
  svg += `<line id="vx" x1="0" x2="0" y1="${T}" y2="${gy0 + GH}" stroke="var(--s1)" stroke-width="1" style="display:none"/>`;
  svg += `<rect id="hit" x="${L}" y="0" width="${W - L - R}" height="${gy0 + GH}" fill="transparent"/></svg>`;
  el.innerHTML = `<div id="legend">${COLORS.map((c, j) => `<span style="--c:${c}">j${j}</span>`).join("")}<span style="--c:var(--grip)">爪 (下の帯・${esc(s.grip_label)})</span><span class="muted">${esc(s.source)}</span></div>${svg}<div id="tip"></div>`;
  chartGeom = {x, tmax, L, W, R};
  const hit = $("#hit"), tip = $("#tip"), cx = $("#cx");
  hit.onmousemove = e => {
    const r = hit.getBoundingClientRect(), t = (e.clientX - r.left) / r.width * tmax;
    let i = 0; for (let k = 0; k < s.t.length; k++) { if (s.t[k] <= t) i = k; else break; }
    let g = null; s.grip.forEach(([gt, v]) => { if (gt <= t) g = v; });
    cx.setAttribute("x1", x(s.t[i])); cx.setAttribute("x2", x(s.t[i])); cx.style.display = "";
    tip.innerHTML = `<b>${s.t[i].toFixed(1)} 秒</b><br>` + s.joints[i].map((v, j) => `<i style="background:${COLORS[j]}"></i>j${j} ${v.toFixed(1)}°`).join("<br>") + (g === null ? "" : `<br>爪 ${g}`);
    tip.style.display = "block";
    const left = e.clientX - el.getBoundingClientRect().left + 14;
    tip.style.left = Math.min(left, W - 120) + "px"; tip.style.top = "30px";
  };
  hit.onmouseleave = () => { tip.style.display = "none"; cx.style.display = "none"; };
}
function niceStep(raw) { const p = Math.pow(10, Math.floor(Math.log10(raw || 1))); const n = raw / p; return (n < 1.5 ? 1 : n < 3 ? 2 : n < 7 ? 5 : 10) * p; }
function cursor(vt) {
  if (!chartGeom || !detail.video_t || !detail.video_t.length) return;
  const v = $("#vid"); const n = detail.video_t.length;
  const i = Math.max(0, Math.min(n - 1, Math.round(vt / (v.duration || 1) * (n - 1))));
  const xx = chartGeom.x(detail.video_t[i]); const l = $("#vx"); if (!l) return;
  l.setAttribute("x1", xx); l.setAttribute("x2", xx); l.style.display = "";
}

// 判定するのボタン: 指定がなければ開いている回の人の確定を塗る (依頼 189)
// 開いた回の記録した行: 人の確定があればそれ, なければ確定の行 (AI だけの回は null. 依頼 190)
function recordedRow() {
  if (!detail) return null;
  const h = detail.history.filter(r => HUMAN.includes(r.labeler)).pop();
  const l = detail.latest;
  return h || (l && l.labeler !== "ai" ? l : null);
}
function humanOc() { const h = detail ? detail.history.filter(r => HUMAN.includes(r.labeler)).pop() : null; return h ? h.outcome : null; }
// 選んだ判定があればそれ, なければ人の確定を塗る (依頼 192)
function markOC(oc = chosen || humanOc()) { document.querySelectorAll("[data-oc]").forEach(b => b.classList.toggle("on", b.dataset.oc === oc)); markConfirm(); }
function markConfirm() { $("#confirm").disabled = !chosen || !cur || loading || !detail; }
function markPP() {
  document.querySelectorAll("[data-pp]").forEach(b => b.classList.toggle("on", pp[b.dataset.pp] === b.dataset.v));
}
document.querySelectorAll("[data-pp]").forEach(b => b.onclick = () => {
  const k = b.dataset.pp, prev = pp[k]; pp[k] = prev === b.dataset.v ? null : b.dataset.v;
  // つかめなかった → 置くは自動でそこまで行かなかった (選び直せる). つかめなかったからつかめたに戻したら置くは選んでいない状態に (依頼 193)
  if (k === "pick" && pp.pick === "no") pp.place = "not_applicable";
  else if (k === "pick" && pp.pick === "yes" && prev === "no") pp.place = null;
  markPP(); b.blur();
});
document.querySelectorAll("[data-oc]").forEach(b => b.onclick = () => { b.blur(); choose(b.dataset.oc); });
$("#confirm").onclick = () => { $("#confirm").blur(); confirmChosen(); };

// y/n/u/x は選ぶだけ (書かない. 選び直せる). 書くのは確定のボタンか Enter (依頼 192)
function choose(oc) {
  if (!cur || busy || loading || !detail || detail.run_stem !== cur) return;
  chosen = oc; markOC();
}
function confirmChosen() { if (chosen) save(chosen); }

async function save(outcome) {
  if (!cur || busy || loading || !detail || detail.run_stem !== cur) return;
  busy = true; const stem = cur;
  markOC(outcome);  // 確定したら 0.4 秒見せてから次の回へ (その間も busy なので 2 回目は書かない. 依頼 189・192)
  const shown = new Promise(r => setTimeout(r, 400));
  const payload = {run_stem: stem, outcome, pick: pp.pick, place: pp.place, note: $("#note").value};
  const key = JSON.stringify(payload);
  const prev = pendingOps[stem];
  const opId = prev && prev.key === key ? prev.opId
             : stem + ":" + Date.now() + ":" + Math.random().toString(36).slice(2);
  pendingOps[stem] = {key, opId, payload};
  const seqAtSave = selSeq;
  let saved = false;
  const posBefore = view.findIndex(r => r.run_stem === stem);
  try {
    const res = await fetch("/api/verdict", {method: "POST", headers: {"Content-Type": "application/json"},
      body: JSON.stringify(Object.assign({op_id: opId}, payload))});
    const j = await res.json();
    if (!res.ok) { delete pendingOps[stem]; markOC(); status("書けませんでした: " + j.error, "err"); return; }
    saved = true;
    toast(`確定しました：${esc(stem)}${tag(outcome, false)}`);
    status(`${stem} を「${OC_JA[outcome]}」で追記しました (${LB_JA[j.row.labeler]}・${ppJa(j.row)})`, "ok");
    await loadRuns();
    // 同じ位置 (確定で一覧から消えたらそこに来た次の回) へ進む
    let next;
    const still = view.findIndex(r => r.run_stem === stem);
    if (still >= 0) next = view[still + 1] || view[still];
    else next = view[Math.min(posBefore, view.length - 1)];
    // 画面を進め終えてから消す (読み直しや次の回の読み込みに失敗して押し直しても同じ op_id. codex 3 回目 R1・4 回目 R2)
    await shown;
    if (selSeq !== seqAtSave) { delete pendingOps[stem]; return; }  // 保存中に人が別の回を選んでいたら動かさない (codex 6 回目 R2)
    if (await select(next ? next.run_stem : null)) delete pendingOps[stem];
  } catch (e) {
    markOC();
    if (saved) { status(`${stem} は書けています. 一覧の読み直しに失敗しました: ${e} ・ 押し直しても 2 回は書きません`, "err"); return; }
    // 書けたのに応答が届かなかったこともある. 同じ回・同じ判定で押し直すと同じ op_id で送るので,
    // サーバーは 2 回書かない (codex R7. 別の回を判定してから戻っても pendingOps に残る)
    status("書けたか確かめられませんでした: " + e + " ・ そのまま確定し直すと 2 回は書きません", "err");
  }
  finally { busy = false; }
}

function move(d) {
  const i = view.findIndex(r => r.run_stem === cur);
  const n = view[i < 0 ? 0 : Math.max(0, Math.min(view.length - 1, i + d))];
  if (n) { status(""); select(n.run_stem); }
}

document.addEventListener("keydown", e => {
  // Enter は確定. ひとこと欄に入力中でも効く (変換中の Enter は確定にしない. 依頼 192)
  if (e.key === "Enter" && e.target.tagName !== "SELECT") {
    if (e.isComposing || e.keyCode === 229 || e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
    e.preventDefault();
    if (!e.repeat) confirmChosen();  // 押しっぱなしで次の回まで書かない (codex R5)
    return;
  }
  if (e.target.tagName === "INPUT" && e.target.type !== "range") { if (e.key === "Escape") e.target.blur(); return; }
  if (e.metaKey || e.ctrlKey || e.altKey) return;
  if (e.repeat && "ynux".includes(e.key)) return;
  const k = e.key;
  if (k === "y") choose("success"); else if (k === "n") choose("failure");
  else if (k === "u") choose("unknown"); else if (k === "x") choose("not_judgeable");
  else if (k === "ArrowRight") { e.preventDefault(); move(1); } else if (k === "ArrowLeft") { e.preventDefault(); move(-1); }
  else if (k === ".") stepFrame(1); else if (k === ",") stepFrame(-1);
  else if (k === " " && $("#vid")) { e.preventDefault(); const v = $("#vid"); v.paused ? v.play() : v.pause(); }
});
$("#filter").onchange = () => { applyFilter(); if (!view.find(r => r.run_stem === cur)) select(view[0]?.run_stem); };
window.onresize = () => detail && renderChart();
loadRuns().then(() => select(view[0]?.run_stem)).catch(e => status("一覧を読めませんでした: " + e.message + " ・ KIOXIA をつないで読み直してください", "err"));
</script>
</body>
</html>
