ロボットアームのテレメトリダッシュボードを作る Log#5 | 比較用プロトタイプを本番に載せ替え、実データで見えた表示の穴をふさぐ
LOG247R2R2R2026/6/20

ロボットアームのテレメトリダッシュボードを作る Log#5 | 比較用プロトタイプを本番に載せ替え、実データで見えた表示の穴をふさぐ

テレメトリダッシュボードは、走っている最中の状態を見る道具として作ってきた。だが実機の試行が増えると、別の要求が出る。今見えている値だけでは足りない。前の試行でどの stage を通り、どこで止まり、最終的にどんな判定だったのかを、後から読み返したくなる。

そこで、比較用に作っていたサイドバー版を本番の index.html に載せ替えた。左にはパネルの表示トグルと過去 TRIAL のレビューを置く。右のメインは全幅にして、STAGE HISTORY、GRIPPER、JOINTS、TRIAL、DRILL-IN を読みやすく並べる。

プロトタイプとして横に並べて見比べていたときは気づかなかった粗が、本番データを流した瞬間に出た。履歴が内部スクロールで上が読めない。JOINTS の数値が桁の途中で折れる。RAW JSON が枠内で切れる。DRILL-IN がアコーディオンで隠れる。作ったものは、実データを流して使って初めて穴が見える。この回は、その穴をふさいでダッシュボードを試行レビューの道具へ寄せた記録になる。

サイドバー版を本番に載せ替える

これまでの observe は、上に CONNECTION バー、その下に STAGE HISTORY / GRIPPER / JOINTS / TRIAL のグランス、最下段に DRILL-IN という縦積みだった。情報は足りていたが、画面を使うほど「いま映っているのは何番目の試行か」「特定のパネルだけ消したい」という要求が出てくる。そこで左に縦メニューを置き、操作を集約した。画面から消したいパネルを間引く表示/非表示トグルと、走り終えた試行を選んで最終状態を固定する履歴ナビだ。右のメインは全幅にして、PC は上下40・左右80、タブレットは40・40、SP は40・20のパディングにした。

載せ替えは、元の index.html を退避してから上書きする手順で行った。生成物を消さない原則と同じで、戻せる状態を必ず残す。

cd ~/r2r2r-scripts/observe/static    # 実際の observe/static のパスに合わせる

# 1) 今動いている index.html を退避(他に保存)
cp index.html index_pre_sidebar_20260620.html

# 2) 新デザインに入れ替え
cp ~/Downloads/index.html ./index.html

TRIAL の id は run フォルダ名である

run が始まっていない待機状態だと、TRIAL パネルは id — / task — と出る。これが何から来ているのかを押さえておかないと、表示の正誤を判断できない。表示側はパネルを描く純関数 paintFromDatagram の中で、datagram の trial フィールドをそのまま読んでいる。

    // TRIAL(小)
    if(dg.trial && typeof dg.trial==="object"){
      els.trialId.textContent   = (dg.trial.id!=null)   ? String(dg.trial.id)   : "—";
      els.trialTask.textContent = (dg.trial.task!=null) ? String(dg.trial.task) : "—";
    } else {
      els.trialId.textContent   = "—";
      els.trialTask.textContent = "—";
    }

その dg.trial を組んでいるのは制御ループ側の emit だ。_OBSERVE_STATE["run_stem"] が立っていれば {id, task} を作り、なければ None(=待機中は —)になる。

    trial=({"id": _OBSERVE_STATE["run_stem"],
            "task": _OBSERVE_STATE["task"]}
           if _OBSERVE_STATE["run_stem"] else None),

run_stem は run() の開始時に入る。

    _OBSERVE_STATE["run_stem"] = run_stem

この run_stem はログファイルの名前(log_path.stem)であり、KIOXIA 上の保存ルールでは「1試行=1サブフォルダ、フォルダ名=run_stem、中のファイルも run_stem プリフィックス」と決めている。つまり TRIAL の id に出る文字列は、その試行の run フォルダ名そのものだ。task のほうは固定文字列で、走らせたスクリプトが _OBSERVE_STATE["task"] に入れた値が出る。実機を流したときの画面では id = pick_then_place_auto_20260620_183016、task = grasp_lift_pick_then_place と出ていた。run フォルダの並びを見ると、本線の run は run_0001_YYYYMMDD_HHMMSS、派生スクリプトは pick_then_place_auto_* と、走らせたスクリプトごとに run_stem の付け方が違う。id がそのまま命名規約を映すので、どの経路で出た試行かは TRIAL の id だけで見分けがつく。

過去 TRIAL を凍結レビューする

左メニューで組んだのは、過去 TRIAL のレビューと、パネルを消したときの再レイアウト。コードを全量で残す。

過去 TRIAL をためる

従来は STAGE HISTORY を「現在の run のぶんだけ」持ち、trial.id が変わるとリセットしていた。過去を遡れるようにするため、trial.id が変わるたびにレコードを確定して積む方式に変えた。ライブ表示用の state.stageHistory は、現レコードの stages への参照に張り替える。これで既存の描画ロジックはそのまま使える。

  // 新 run(trial.id 変化)で過去 trial レコードを確定し、新レコードを開始する.
  function _startNewTrialRecord(trialId, dg){
    var rec = {
      id: trialId,
      task: (dg && dg.trial && dg.trial.task!=null) ? String(dg.trial.task) : null,
      stages: [],
      lastDg: null,
      inBox: null,
      startedAt: Date.now(),
      lastAt: Date.now()
    };
    state.currentTrialId = trialId;
    state.currentRecord = (trialId !== null) ? rec : null;
    if(trialId !== null){
      state.trials.push(rec);
      state.trialsById[trialId] = rec;
      if(state.trials.length > TRIALS_MAX){
        var removed = state.trials.splice(0, state.trials.length - TRIALS_MAX);
        removed.forEach(function(r){ if(r.id!=null) delete state.trialsById[r.id]; });
      }
    }
    // ライブ表示用 stageHistory を新レコードの stages に張り替える(参照).
    state.stageHistory = rec.stages;
    state.historyLastPhase = null;
    renderTrialNav();
  }
  function appendStageHistory(dg){
    // 新 run(trial.id 変化)で過去 trial を確定し、新レコードへ切替
    var trialId = (dg && dg.trial && dg.trial.id != null) ? String(dg.trial.id) : null;
    if(trialId !== state.historyLastTrialId){
      state.historyLastTrialId = trialId;
      _startNewTrialRecord(trialId, dg);
      // in_box の last known も新 run でリセット(次の run の stage9b で再確定)
      state.lastInBox = null;
      _renderStageHistory();
    }
    var phaseRaw = (dg && dg.phase != null) ? String(dg.phase) : null;
    if(phaseRaw === null) return;  // phase 無しはスキップ
    if(phaseRaw === state.historyLastPhase) return;  // 同じ phase は append しない
    state.historyLastPhase = phaseRaw;
    var angles = (dg && Array.isArray(dg.angles)) ? dg.angles.slice(0, 6) : null;
    state.stageHistory.push({
      t: (typeof dg.t === "number") ? dg.t : Date.now(),
      phaseRaw: phaseRaw,
      gripperRaw: dg.gripper,
      angles: angles
    });
    // 上限超過は古い行を捨てる
    if(state.stageHistory.length > STAGE_HISTORY_MAX){
      state.stageHistory.splice(0, state.stageHistory.length - STAGE_HISTORY_MAX);
    }
    renderTrialNav();   // stage 数表示を更新
    _renderStageHistory();
  }

ためた trial をサイドバーのリストに描く。新しい trial を上に、現在ライブ中のものには ● を付ける。レビュー中は選択中の trial をハイライトする。

  // ---- サイドバー: 過去 TRIAL ナビ ---------------------------------------
  function renderTrialNav(){
    var trials = state.trials;
    if(!trials || trials.length === 0){
      els.trialList.innerHTML = "";
      els.trialEmpty.style.display = "";
    } else {
      els.trialEmpty.style.display = "none";
      // 新しい trial を上に
      var html = "";
      for(var i = trials.length - 1; i >= 0; i--){
        var r = trials[i];
        var isActive = state.reviewing && (r.id === state.reviewId);
        var isLive = (!state.reviewing) && (r.id === state.currentTrialId);
        var cls = "trial-item" + (isActive ? " active" : "");
        var idLabel = (r.id!=null ? "#"+r.id : "#—") + (isLive ? " ●" : "");
        var taskLabel = r.task ? r.task : "—";
        var n = r.stages.length;
        html += '<li><button type="button" class="'+cls+'" data-trial-id="'+_escapeHtml(r.id)+'">' +
                  '<span class="ti-id">'+_escapeHtml(idLabel)+'</span>' +
                  '<span class="ti-task">'+_escapeHtml(taskLabel)+'</span>' +
                  '<span class="ti-meta">'+n+' stage'+(n===1?'':'s')+'</span>' +
                '</button></li>';
      }
      els.trialList.innerHTML = html;
    }
    els.trialLiveBtn.classList.toggle("dim", state.reviewing);
  }

止めるのは通信ではなく、描画だけ

過去 trial を選ぶと、その試行の stages を履歴パネルに描き、パネル群はレコードの最終 datagram(lastDg)から再現する。ライブへ戻すまで表示を固定する。難しいのは、レビュー中にもライブの datagram が届き続けることだ。実機は動き続けている。ここで通信を止めると現在状態を失い、レビューを解除した瞬間に復帰できない。だから、止めるのは通信ではなく描画だけにした。背景の state は最新を追い続け、画面の描画だけを止める。

  // 過去 trial を選択して凍結表示(レビュー).
  function enterReview(trialId){
    var rec = state.trialsById[trialId];
    if(!rec) return;
    state.reviewing = true;
    state.reviewId = trialId;
    els.body.classList.add("reviewing");
    els.reviewId.textContent = (rec.id!=null) ? String(rec.id) : "—";
    // 履歴リスト(凍結)
    _renderStageList(rec.stages);
    // パネル群をレコードの最終 datagram から再現
    var inBoxText = (rec.inBox === null) ? "—" : String(rec.inBox);
    paintGripperValue(rec.lastDg ? rec.lastDg.gripper : null);
    paintFromDatagram(rec.lastDg || {}, inBoxText);
    els.seq.textContent = (rec.lastDg && rec.lastDg.seq!=null) ? String(rec.lastDg.seq) : "—";
    renderTrialNav();
  }
  // ライブ表示へ戻す.
  function exitReview(){
    if(!state.reviewing) return;
    state.reviewing = false;
    state.reviewId = null;
    els.body.classList.remove("reviewing");
    // ライブ履歴を再描画
    _renderStageList(state.stageHistory);
    if(state.idle){
      clearAllLiveValues();
    } else if(state.last_dg){
      renderGripper();
      var inBoxText = (state.lastInBox === null) ? "—" : String(state.lastInBox);
      paintFromDatagram(state.last_dg, inBoxText);
      els.seq.textContent = (state.last_dg.seq!=null) ? String(state.last_dg.seq) : "—";
    }
    renderTrialNav();
  }

凍結を成立させているのは applyDatagram 側の早期 return だ。phase 履歴の積み増し・in_box の latch・現レコードへの最終 datagram 保存・gripper のデバウンス state までは常に進め、state.reviewing のときはパネル描画の手前で抜ける。これで背景は最新を追い、表示だけが止まる。

  function applyDatagram(dg){
    if(!dg) return;

    // IDLE / HELD 即時解除(100ms tick を待たない. チラ見えを防ぐ).
    if(state.idle){
      state.idle = false;
      els.body.classList.remove("idle");
    }
    if(state.held){
      state.held = false;
      els.body.classList.remove("held");
    }

    // ---- グランス: phase 履歴を積む(背景の trial レコードへも積む) ----
    // 古い行が上、最新が下. 同 phase の連続 datagram は 1 行のみ. trial.id 変化でリセット.
    var phaseRaw = (dg.phase!=null) ? String(dg.phase) : null;
    state.phase    = phaseRaw;
    state.phase_t0 = (typeof dg.phase_t0 === "number") ? dg.phase_t0 : null;
    els.phase.textContent = fmtPhase(phaseRaw);  // idle 復帰判定用(普段は display:none)
    appendStageHistory(dg);
    tickElapsed();

    // ---- in_box latch(背景状態. レビュー中でも更新し続ける) ----
    if(dg.in_box === true || dg.in_box === false){
      state.lastInBox = dg.in_box;
    }
    var inBoxText = (state.lastInBox === null) ? "—" : String(state.lastInBox);

    // ---- 現 trial レコードへ最終 datagram を保存(過去レビュー再現用) ----
    if(state.currentRecord){
      state.currentRecord.lastDg = dg;
      state.currentRecord.lastAt = Date.now();
      state.currentRecord.inBox  = state.lastInBox;
      if(state.currentRecord.task == null && dg.trial && dg.trial.task != null){
        state.currentRecord.task = String(dg.trial.task);
      }
    }

    // ---- gripper(デバウンス: 確定値だけ表示) ----
    // 状態は常に進める. 描画はレビュー中のみ抑止(凍結表示を保つ).
    processGripperRx(dg.gripper);
    tryCommitGripper();

    state.last_dg = dg;  // exitReview でライブへ戻すために保持

    // ---- レビュー中はパネル描画を止める(背景 state だけ更新済み) ----
    if(state.reviewing){
      renderTrialNav();  // stage 数等の更新は反映
      return;
    }

    renderGripper();
    paintFromDatagram(dg, inBoxText);
    // ---- seq ----
    els.seq.textContent = (dg.seq!=null) ? String(dg.seq) : "—";
  }

ライブも過去レビューも、同じ paintFromDatagram() で描く。違うのは datagram の出どころだけだ。ライブなら最新 datagram、レビューなら trial レコードに保存した最後の datagram を渡す。state を触らない純関数として切り出してあるので、どちらから呼んでも副作用がない。joints・trial・座標・tip・ファイル名・drill・ee・verdict・meta・JSON をここでまとめて描く。gripper と seq と stage 履歴と phase は呼び出し側で扱う。

  // datagram 1 個からパネル群を描く「純描画」関数(state を書き換えない).
  // ライブ・過去レビューの両方から呼ぶ. gripper・seq・stage 履歴・phase は含めない
  // (それらは呼び出し側で扱う). inBoxText は呼び出し側が確定させて渡す.
  function paintFromDatagram(dg, inBoxText){
    if(!dg) dg = {};
    var angles = Array.isArray(dg.angles) ? dg.angles : null;

    // JOINTS j0..j5
    for(var ji=0; ji<6; ji++){
      var av = (angles && angles.length>ji && typeof angles[ji]==="number" && isFinite(angles[ji]))
               ? angles[ji].toFixed(2) : "—";
      els.gJoints[ji].textContent = av;
    }

    // TRIAL(小)
    if(dg.trial && typeof dg.trial==="object"){
      els.trialId.textContent   = (dg.trial.id!=null)   ? String(dg.trial.id)   : "—";
      els.trialTask.textContent = (dg.trial.task!=null) ? String(dg.trial.task) : "—";
    } else {
      els.trialId.textContent   = "—";
      els.trialTask.textContent = "—";
    }

    // 座標(mm): DRILL と GRIPPER セル下の両方
    els.cBlockPickX.textContent  = fmtMM(dg.block_xz, 0);
    els.cBlockPickZ.textContent  = fmtMM(dg.block_xz, 1);
    els.cBlockPlaceX.textContent = fmtMM(dg.block_place_xz, 0);
    els.cBlockPlaceZ.textContent = fmtMM(dg.block_place_xz, 1);
    els.cBoxX.textContent        = fmtMM(dg.box_xz, 0);
    els.cBoxZ.textContent        = fmtMM(dg.box_xz, 1);
    els.cInBox.textContent       = inBoxText;
    els.gBlockPickX.textContent  = fmtMM(dg.block_xz, 0);
    els.gBlockPickZ.textContent  = fmtMM(dg.block_xz, 1);
    els.gBlockPlaceX.textContent = fmtMM(dg.block_place_xz, 0);
    els.gBlockPlaceZ.textContent = fmtMM(dg.block_place_xz, 1);
    els.gBoxX.textContent        = fmtMM(dg.box_xz, 0);
    els.gBoxZ.textContent        = fmtMM(dg.box_xz, 1);
    els.gInBox.textContent       = inBoxText;

    // TIP 差分・ファイル名
    var tp = dg.tip_pick;
    if(tp && typeof tp.dist_mm === "number"){
      els.gTipDist.textContent = tp.dist_mm.toFixed(1) + " mm";
      els.gTipDx.textContent   = (typeof tp.dx_mm==="number") ? tp.dx_mm.toFixed(1) : "—";
      els.gTipDz.textContent   = (typeof tp.dz_mm==="number") ? tp.dz_mm.toFixed(1) : "—";
    }else{
      els.gTipDist.textContent = "—";
      els.gTipDx.textContent   = "—";
      els.gTipDz.textContent   = "—";
    }
    els.gRunDir.textContent   = (typeof dg.run_dir==="string" && dg.run_dir) ? dg.run_dir : "—";
    els.gCapTip.textContent   = baseName(dg.cap_tip);
    els.gCapPick.textContent  = baseName(dg.cap_pick);
    els.gCapPlace.textContent = baseName(dg.cap_place);

    // DRILL: angles 6軸
    var jEls = [els.j1, els.j2, els.j3, els.j4, els.j5, els.j6];
    for(var i=0; i<6; i++){
      var v6 = (angles && angles.length>i && typeof angles[i]==="number") ? angles[i] : null;
      jEls[i].textContent = (v6==null) ? "—" : v6.toFixed(2);
    }

    // DRILL: ee
    var ee = Array.isArray(dg.ee) ? dg.ee : null;
    els.eeX.textContent = (ee && typeof ee[0]==="number") ? ee[0].toFixed(1) : "—";
    els.eeY.textContent = (ee && typeof ee[1]==="number") ? ee[1].toFixed(1) : "—";
    els.eeZ.textContent = (ee && typeof ee[2]==="number") ? ee[2].toFixed(1) : "—";

    // DRILL: verdict
    var vd = (dg.verdict && typeof dg.verdict==="object") ? dg.verdict : null;
    els.vRaw.textContent        = (vd && vd.raw_verdict!=null) ? String(vd.raw_verdict) : "—";
    els.vSuccess.textContent    = (vd && vd.success!=null)     ? String(vd.success)     : "—";
    els.vConfidence.textContent = (vd && typeof vd.confidence==="number" && isFinite(vd.confidence))
                                  ? vd.confidence.toFixed(2) : "—";
    els.vReward.textContent     = (vd && vd.reward!=null)      ? String(vd.reward)      : "—";
    els.vReason.textContent     = (vd && vd.reason!=null)      ? String(vd.reason)      : "—";

    // DRILL: datagram meta
    els.rSource.textContent       = (dg.source!=null)   ? String(dg.source)   : "—";
    els.rT.textContent            = (dg.t!=null)        ? String(dg.t)        : "—";
    els.rTTime.textContent        = (typeof dg.t==="number") ? fmtTime(dg.t)  : "—";
    els.rPhaseT0.textContent      = (dg.phase_t0!=null) ? String(dg.phase_t0) : "—";
    els.rPhaseT0Time.textContent  = (typeof dg.phase_t0==="number") ? fmtTime(dg.phase_t0) : "—";
    var tr = (dg.trial && typeof dg.trial==="object") ? dg.trial : null;
    els.rTrialId.textContent      = (tr && tr.id!=null)   ? String(tr.id)   : "—";
    els.rTrialTask.textContent    = (tr && tr.task!=null) ? String(tr.task) : "—";

    // DRILL: 全 JSON
    try{
      els.rJson.textContent = JSON.stringify(dg, null, 2);
    }catch(e){
      els.rJson.textContent = "(stringify error: " + e + ")";
    }
  }

パネルを消したら穴を詰める

パネル表示トグルは、glance の4枠(STAGE HISTORY / GRIPPER / JOINTS / TRIAL)と DRILL-IN を個別に消せる。問題は、glance が固定のグリッド配置だと、真ん中のセルを消したときに穴が残ることだ。表示中のセルだけを順に詰め直す関数を書いた。半幅(phase/gripper)は2列に詰め、片方しか残らなければ全幅に伸ばす。全幅(joints/trial)は行を専有する。SP では全セルを縦1列に並べる。

  // ---- サイドバー: パネル表示/非表示 + レイアウト再構成 ---------------------
  var mqNarrow = window.matchMedia("(max-width: 640px)");
  function isNarrow(){ return mqNarrow.matches; }

  // 表示中の glance セルだけを詰めて配置(非表示で穴が空かないようにする).
  // 広い画面: 半幅(phase/gripper)を2列に詰め、全幅(joints/trial)は行を専有.
  // 狭い画面(SP): 全セルを縦1列で順に並べる.
  function relayoutGlance(){
    var order = [
      { key:"phase",   el:els.cellPhase,   type:"half" },
      { key:"gripper", el:els.cellGripper, type:"half" },
      { key:"joints",  el:els.cellJoints,  type:"full" },
      { key:"trial",   el:els.cellTrial,   type:"full" }
    ];
    var narrow = isNarrow();
    var row = 1, pending = [];
    function place(el, col, r){ el.style.gridColumn = col; el.style.gridRow = String(r); }
    function flush(){
      if(pending.length === 1){ place(pending[0].el, "1 / -1", row); row++; }
      else if(pending.length === 2){ place(pending[0].el, "1", row); place(pending[1].el, "2", row); row++; }
      pending = [];
    }
    order.forEach(function(o){
      if(!o.el) return;
      if(state.panelHidden[o.key]){ o.el.style.display = "none"; return; }
      o.el.style.display = "";   // .cell の display:flex に戻す
      if(narrow){ place(o.el, "1 / -1", row); row++; return; }
      if(o.type === "half"){ pending.push(o); if(pending.length === 2) flush(); }
      else { flush(); place(o.el, "1 / -1", row); row++; }
    });
    if(!narrow) flush();
  }
  function applyPanelVisibility(){
    relayoutGlance();
    if(els.drill) els.drill.style.display = state.panelHidden.drill ? "none" : "";
  }
  function setPanelHidden(key, hidden){
    if(!(key in state.panelHidden)) return;
    state.panelHidden[key] = !!hidden;
    var lbl = els.panelToggles.querySelector('[data-panel-label="'+key+'"]');
    if(lbl) lbl.classList.toggle("off", !!hidden);
    applyPanelVisibility();
  }

サイドバーの操作はイベント委譲でまとめてある。トグルの change、TRIAL 行のクリックでレビュー、live ボタンとバナーでライブ復帰、画面幅の変化で glance を組み直す。

  // ---- サイドバー配線 ----------------------------------------------------
  // パネル表示/非表示トグル
  els.panelToggles.addEventListener("change", function(evt){
    var cb = evt.target;
    if(!cb || cb.getAttribute("type") !== "checkbox") return;
    var key = cb.getAttribute("data-panel");
    setPanelHidden(key, !cb.checked);
  });
  // 過去 TRIAL を選んでレビュー
  els.trialList.addEventListener("click", function(evt){
    var btn = evt.target.closest ? evt.target.closest(".trial-item") : null;
    if(!btn) return;
    var id = btn.getAttribute("data-trial-id");
    if(id != null) enterReview(id);
  });
  // ライブへ戻す
  els.trialLiveBtn.addEventListener("click", exitReview);
  els.reviewExit.addEventListener("click", exitReview);
  // SP: サイドバー開閉
  els.sbToggle.addEventListener("click", function(){
    var collapsed = els.sbBody.classList.toggle("collapsed");
    els.sbToggle.setAttribute("aria-expanded", String(!collapsed));
  });
  // 画面幅の変化で glance を組み直す(SP↔広い)
  if(mqNarrow.addEventListener) mqNarrow.addEventListener("change", relayoutGlance);
  else if(mqNarrow.addListener) mqNarrow.addListener(relayoutGlance);

  // 初期描画
  renderTrialNav();
  applyPanelVisibility();

  connect();

実データで出た5つの表示の穴

ここからが、本番に載せて実際の run を流して初めて見えた粗の修正だ。五つあった。先に表で並べる。

見えた穴

原因

修正

STAGE HISTORY の上が見えない

内部スクロール+最下行への自動スクロール

高さ固定を外して下へ伸ばす

DRILL-IN が消える

<details> のアコーディオン

常時表示の <section> へ

RAW JSON が読み切れない

max-height + overflow:auto の枠内スクロール

折り返して全文表示

JOINTS が桁の途中で折れる

大きすぎるフォント+word-break

28px 上限+nowrap

左メニューが右より短い

sticky+内容分の高さ

メイン高に stretch、TRIALS だけ内部スクロール

STAGE HISTORY: 内部スクロールをやめて下に伸ばす

履歴パネルに max-height と overflow-y:auto を入れていたため、stage が増えると内部スクロールになり、しかも最下行へ自動スクロールするので上の行が隠れていた。STAGE が増えたら下へ伸びればいい。高さ固定と自動スクロールの両方を外す。

CSS は内部スクロールの指定を落とす。

/* before */
.stage-history{
  display:flex;
  flex-direction:column;
  gap:0.35em;
  margin-top:0.5em;
  max-height:46vh;
  overflow-y:auto;
}
/* after */
.stage-history{
  display:flex;
  flex-direction:column;
  gap:0.35em;
  margin-top:0.5em;
}

描画側も最下行への自動スクロールを外す。スクロール容器が無くなったので不要だ。

/* before */
    els.stageHistoryEmpty.style.display = "none";
    els.stageHistory.innerHTML = rows.map(_historyRowHTML).join("");
    // 最新を見せるため最下行へスクロール
    els.stageHistory.scrollTop = els.stageHistory.scrollHeight;
  }
/* after */
    els.stageHistoryEmpty.style.display = "none";
    els.stageHistory.innerHTML = rows.map(_historyRowHTML).join("");
  }

各行の J:[...] はもともと折り返す設定なので、横スクロールは出ない。

DRILL-IN: アコーディオンをやめて常時表示

DRILL-IN は <details>/<summary> で開閉していた。だが実機を見ながら使う画面では、クリックして開く操作そのものが邪魔になる。見たい情報は、最初から見えているべきだった。<details> を <section> に、<summary> を常時表示のヘッダーに変える。サイドバーの表示トグルで消す操作は別物なので、そちらは残す。

CSS は開閉用のマーカーと [open] 分岐を捨て、ヘッダーに常時ボーダーを引く。

/* before */
details.drill{
  border:var(--edge) solid var(--fg);
  padding:0;
}
details.drill > summary{
  cursor:pointer;
  list-style:none;
  padding:0.9em 1.4em;
  font-family:'IBM Plex Mono', ui-monospace, monospace;
  font-size:0.85rem;
  font-weight:500;
  text-transform:uppercase;
  letter-spacing:0.22em;
  display:flex;
  align-items:center;
  justify-content:space-between;
  user-select:none;
}
details.drill > summary::-webkit-details-marker{display:none}
details.drill > summary::after{
  content:"[ + ]";
  opacity:0.55;
}
details.drill[open] > summary::after{content:"[ − ]"}
details.drill[open] > summary{border-bottom:var(--edge) solid var(--fg)}
/* after */
.drill{
  border:var(--edge) solid var(--fg);
  padding:0;
}
.drill > .drill-head{
  padding:0.9em 1.4em;
  font-family:'IBM Plex Mono', ui-monospace, monospace;
  font-size:0.85rem;
  font-weight:500;
  text-transform:uppercase;
  letter-spacing:0.22em;
  display:flex;
  align-items:center;
  justify-content:space-between;
  user-select:none;
  border-bottom:var(--edge) solid var(--fg);
}

body の状態セレクタも details.drill から .drill に揃える。

/* before → after */
body.stale details.drill{opacity:0.32}      /* → body.stale .drill{opacity:0.32} */
body.idle details.drill{opacity:1}          /* → body.idle .drill{opacity:1} */
body.held details.drill{opacity:1}          /* → body.held .drill{opacity:1} */
body.reviewing details.drill{ opacity:1 !important; }  /* → body.reviewing .drill{ opacity:1 !important; } */

HTML はタグを差し替える。

<!-- before -->
<details class="drill">
  <summary><span>drill-in — joints / ee / verdict / meta / raw</span></summary>
  <div class="drill-body">
<!-- after -->
<section class="drill">
  <div class="drill-head"><span>drill-in — joints / ee / verdict / meta / raw</span></div>
  <div class="drill-body">

閉じタグ </details> は </section> に、DOM 参照も document.querySelector("details.drill") から document.querySelector(".drill") に変える。表示トグルは els.drill.style.display を切り替えるだけなので、そのまま効く。

RAW JSON: 折り返して全文を出す

RAW JSON の <pre> に高さ固定と枠内スクロールを入れていたため、下が切れて全文を一気に読めなかった。高さ固定とスクロールを外し、折り返しで縦に全部出るようにする。

/* before */
.raw-json{
  font-family:'IBM Plex Mono', ui-monospace, monospace;
  font-size:0.8rem;
  line-height:1.45;
  padding:1em 1.2em;
  border:var(--edge) solid var(--fg);
  background:transparent;
  white-space:pre-wrap;
  word-break:break-all;
  max-height:340px;
  overflow:auto;
}
/* after */
.raw-json{
  font-family:'IBM Plex Mono', ui-monospace, monospace;
  font-size:0.8rem;
  line-height:1.45;
  padding:1em 1.2em;
  border:var(--edge) solid var(--fg);
  background:transparent;
  white-space:pre-wrap;
  word-break:break-all;
  overflow-wrap:anywhere;
}

JOINTS: 数値フォントを28pxベースにする

JOINTS の大きな数値が、幅広画面で 3.4vw≈36px まで膨らみ、word-break:break-all と相まって桁の途中で折り返していた。-0.43 が -0.4 と 3 の2行に割れてレイアウトが崩れる。28px を上限(ベース)に下げ、狭い画面では vw 側で自動的に縮むようにした。word-break:break-all は桁が割れる原因なので外し、white-space:nowrap で数値を必ず1行に保つ。

/* before */
.joint-cell .joint-val{
  font-family:'IBM Plex Mono', ui-monospace, monospace;
  font-variant-numeric:tabular-nums;
  font-size:clamp(1.6rem, 3.4vw, 2.6rem);
  font-weight:700;
  line-height:1;
  letter-spacing:-0.03em;
  word-break:break-all;
}
/* after */
.joint-cell .joint-val{
  font-family:'IBM Plex Mono', ui-monospace, monospace;
  font-variant-numeric:tabular-nums;
  font-size:clamp(1.25rem, 2.6vw, 1.75rem);  /* base/上限 28px。タブレット・SPは vw で自動縮小 */
  font-weight:700;
  line-height:1;
  letter-spacing:-0.03em;
  white-space:nowrap;
}

ルートが16pxなので 1.75rem = 28px がベース兼上限になる。下限は 1.25rem=20px。これで「ベース28px、タブレット/SP は適時縮小」を1行でまかなえる。

左メニュー: 右カラムの高さに合わせる

最後は、左メニューの高さを右カラム(メイン)に合わせて可変にする変更。元は align-items:flex-start とサイドバーの position:sticky で、メニューはコンテンツ分の高さしか持たず、右が長く伸びても左は短いままだった。align-items:stretch にしてサイドバーをメインと同じ高さに追従させ、余った縦スペースを TRIALS リストが埋めて内部スクロールするようにした。高さをメインに合わせる以上、ピン留め(sticky)は噛み合わないので外した。

ひとつ補足しておく。本文側の STAGE HISTORY と RAW JSON は記録として上から下へ読ませたいので内部スクロールを外したが、左メニューの TRIALS はナビゲーションなので、メインの高さに合わせて内部スクロールさせる。役割が違えば、スクロールの扱いも変える。

shell を stretch にする。

/* before → after */
.shell{
  display:flex;
  align-items:flex-start;   /* → align-items:stretch; */
  gap:3vw;
}

サイドバーの sticky を外し、ストレッチと内部スクロールを許可する。

/* before */
.sidebar{
  flex:0 0 230px;
  width:230px;
  position:sticky;
  top:40px;
  align-self:flex-start;
  display:flex;
  flex-direction:column;
  gap:1.6em;
  border:var(--edge) solid var(--fg);
  padding:1.2em 1.2em 1.4em;
}
/* after */
.sidebar{
  flex:0 0 230px;
  width:230px;
  align-self:stretch;
  min-height:0;
  display:flex;
  flex-direction:column;
  gap:1.6em;
  border:var(--edge) solid var(--fg);
  padding:1.2em 1.2em 1.4em;
}

sb-body を高さいっぱいに伸ばし、TRIALS(最後のセクション)を伸縮枠にする。

/* before */
.sb-body{
  display:flex;
  flex-direction:column;
  gap:1.6em;
}
.sb-section{ display:flex; flex-direction:column; gap:0.7em; }
.sb-section > .label{ margin-bottom:0.1em; }
/* after */
.sb-body{
  flex:1 1 auto;
  min-height:0;
  display:flex;
  flex-direction:column;
  gap:1.6em;
}
.sb-section{ display:flex; flex-direction:column; gap:0.7em; }
/* TRIALS(最後のセクション)が残りの縦スペースを埋め、リスト内でスクロール */
.sb-body > .sb-section:last-child{ flex:1 1 auto; min-height:0; }
.sb-section > .label{ margin-bottom:0.1em; }

trial リストを固定高 42vh から伸縮へ。

/* before */
.trial-list{ list-style:none; display:flex; flex-direction:column; gap:0.4em; max-height:42vh; overflow-y:auto; }
/* after */
.trial-list{ list-style:none; display:flex; flex-direction:column; gap:0.4em; flex:1 1 auto; min-height:0; overflow-y:auto; }

sb-body → 最後の sb-section → trial-list を順に flex:1 / min-height:0 でつなぐことで、余白を TRIALS リストが埋めて内部スクロールする。SP では shell が縦積みになり、サイドバーは上部のドロワーに戻る。

リスクの種類で、レビュー手順を変える

ここまでの修正は、設計と指示文を私が書き、実ファイルの書き換えは Claude Code に渡す分担で進めた。置換は before/after を厳密に指定して、当てる場所を決め打ちにする。物理的なリスクのある関節制御や姿勢系の変更は codex / gemini の外部レビューを必ず挟むが、今回はすべて純フロントの CSS と DOM 描画なので、レビューは省いた。レイアウトが崩れても実機は動かない。リスクの種類で手順を変える。

仕上がりが手元の参照版と一致するかは、Claude Code が書いた結果と diff を取れば確認できる。表示の検証は jsdom 上で datagram を流し、過去レビューの凍結・idle 中の凍結維持・パネル非表示時の再レイアウト・narrow での縦積みまで通した。ライブの既存挙動(gripper デバウンス、idle クリア、in_box の latch、座標の非 latch 描画)は変えていない。

ダッシュボードは、試行を読み返す道具になった

ライブの数字は、その場の判断に効く。走り終えた試行のレビューは、次の設計に効く。走っている最中を見る画面から、走り終えた試行を並べて読み返す道具へ。observe はここで、監視画面からレビュー画面に変わった。

※ プロジェクト名「R2R2R」は、UC Berkeley・Toyota Research Instituteの研究「Real2Render2Real」(Yu, Fu et al., 2025)の概念に触発されています。現実空間をデジタルツイン化し、仮想空間での試行から得た知見を実世界のモーター駆動へ還元する——その設計と実装過程を毎日記録しています。技術アプローチおよび目的は独自のものです。