サービスロボット導入シミュレーション Log#2 | 配膳ロボットが止まる回数を、店で数える

配膳ロボットは、店の通路でどのくらいの頻度で止まるのでしょうか。人とすれ違うたびに止まるのか、10往復に1回なのか。ロボットを入れるかどうかを決める人にとっては大事な数字ですが、調べても公開されたデータは見つかりませんでした。メーカーの資料に載っているのは、走る速さや運べる重さです。止まる回数は、店の通路の幅や混み具合で変わるので、カタログには載りません。

前回(LOG351)は、飲食店のピーク2時間をパソコンの中で再現し、配膳ロボットを入れたら入口の待ち時間がどう変わるかを試算しました。その試算では、止まる割合を「往復の30%で3〜10秒止まり、自然に動き出す」「往復の1%で動けなくなり、スタッフが動かしに行く」と仮に置いています。皿を下げて卓を拭く時間も「90秒+歩く時間」の仮置きです。試算の結果が当たっているかは、この仮の数字が当たっているかで決まります。 そこで、店で実際に数えるための観察シートを作りました。

観察シートは、スマホで開いて片手で押すページです。ロボットが厨房を出たら「出発」、卓に着いたら「卓に着いた」、通路で止まったら「止まった」、戻ったら「戻った」を押します。押した時刻がそのまま記録になり、1往復の時間、止まった割合、止まった場所と原因が、押すたびに計算し直されます。写真は撮りません。数えるのはロボットと卓と人数だけです。

最初は、自分が客として店に行き、席から観察する前提で作りました。途中で「誰が使うことを想定しているのか」と本人に聞かれ、使う人を決めないまま作っていたことに気づきました。そこで、店のスタッフやロボットの導入・保守を担当する人も使える内容に作り直しました。止まった場所と原因を選べるようにしたのは、そのためです。たとえば「ドリンクバーの前で、人とすれ違って止まった」が何回あったかが分かれば、ロボットの走る道を直す場所が決まります。道を直す前と後の観察を並べて比べることもできます。

やったことなぜ結果
片手で押す観察シートを作る試算で仮に置いた数字(止まる割合・下げ膳の時間など)を、店で数えて確かめるため往復・止まった回数・下げ膳の時間・入口の待ちを、押した時刻から計算する
止まったら赤、戻った・終わったら緑にする押し間違えないように、ボタンの意味を色で分けるロボットのカードも、止まっている間は赤くなる
使う人を広げる客席から見る人だけでなく、運用やロボットを直す担当の人も使えるようにする止まった場所と原因、スタッフが運んだ回数、前と後の比較を足した
サイトに例として置く観察シートの見本として見てもらうためpixelstomotors.com/sim/serving-robot-observation.html に置き、シリーズのページからリンクした

サイトに置いたページは例です。押した記録は、見た人のブラウザの中にだけ残り、どこにも送られません。本人が店で使うときは、記録を保存できる別のページ(claude.ai の Artifact)を使い、記録は外付けの SSD に書き出します。

ここから下は作業ログです。観察シートを作った経緯、作り直した理由、サイトに置いた手順、使ったコードの全文を、作業した順に載せています。

作業ログ ― 観察シートは、LOG351 の「次にやること」の4項目から作った

続きを進めたい、という本人の言葉から始まりました。前回の記事の終わりには、次にやることとして「店に行って、仮の数字を観察した数字に置き換える」と書いてあり、見る項目を4つに絞っていました。

観察する項目置き換える前提
ロボットは今も料理を運ぶだけか、皿も下げているかロボットの使い方
ピークの間にロボットが何往復したかロボットの走る時間・1往復で回る卓の数
ロボットが何回止まり、そのうち何回スタッフが手を出したか一時停止と立ち往生の頻度
皿を下げて卓を拭くのに、誰が何分かけているか下げ膳と卓拭きの時間

次の一手として、観察シートを作る、Unity(3D の空間で物の動きを再現する道具)でロボットとスタッフが通路で鉢合わせる場面を作る、清掃ロボットの回に進む、の3つを出し、本人が観察シートを選びました。試算の側(SimPy)は前回で一区切りで、仮の数字を置き換えないと先に進めない、というのが選んだ理由です。

前回の試算をする中で、止まる割合は一度見直しています。最初は10%・25%で置いていましたが、本人から高すぎないかと指摘があり、調べても公開データは見つかりませんでした。そこで「自然に動き出す一時停止」と「スタッフが動かしに行く立ち往生」に分け、30%と1%を仮に置いた経緯があります。観察シートで一番数えたいのは、この2つです。

最初の版は、客席から片手で押すことを前提にした

最初の版で決めたことは次のとおりです。

決めたこと理由
縦1列で、大きなボタンを上から並べるスマホを机に置いたまま、片手で目立たずに押せるように
ロボットごとにカードを分け、今の状態(待機・配膳中・止まっている)でボタンを入れ替える次に押すボタンだけを出し、押し間違いを減らす
押した時刻だけを記録し、値はあとから計算する店では押すことだけに集中し、計算は画面に任せる
最初のボタンを押した時刻から観察を始める開始を押し忘れないように
写真の欄を作らないお客さんやスタッフが写ると問題になるため
「観察を終える」と「新しい観察を始める」は2回押しで確定する押し間違いで観察が終わらないように
観察中は画面が消えないようにする画面が消えるたびにロック解除する手間をなくす
押し間違えた記録は ✕ で取り消せる店で慌てて押しても直せるように

記録は、まずスマホのブラウザに保存し、そのあと記録の保存先(Artifact の保存先)へ送ります。店の中は電波が弱いことがあるので、送れなかった記録は一覧に橙の点を付けて残し、電波が戻れば20秒ごとに送り直します。

画面の下には「試算に戻す値」の表を置きました。左に試算で仮に置いた値、右に観察した値を並べ、押すたびに計算し直します。往復の時間は「戻った」から「出発」を引いた秒数、一時停止の割合は「自然に動いた」があった往復の数を往復の数で割った値です。

観察シートの記録を手元に残す場所も決めました。データの置き場所の決まり(MASTER の保存先テーブル)では、測定データは外付けの SSD(KIOXIA)に置くことになっています。観察の記録は測定データなので、KIOXIA の r2r2r-data/service_sim/observations/ を作り、1回の観察を1フォルダ({日付}_{店}_{ID}/)にして、meta.json・events.json・summary.json の3つを置く形にしました。観察シートの HTML(作業ファイル)は、iCloud のシリーズのフォルダの observation/ に置いています。MASTER の保存先テーブルには、この行をまだ足していません。決まりではテーブルに足すのが先ですが、今回はフォルダを先に作りました。

止まったボタンは赤、戻った・終わったボタンは緑にした

最初の版を触った本人から、画面写真付きで2つ続けて指示がありました。

止まったは赤系に変更。 戻ったはグリーン系

終わったもグリーン系

最初の版では、「止まった」は橙、「戻った」と皿を下げ終えたときの「終わった」は黒でした。橙は「一時停止」の色として使っていたので、止まったボタンにも同じ色を当てていました。黒は「次に押すことが多いボタン」の印として、出発や始まったにも使っていました。並べると、止まった・戻ったの意味の違いが色からは読めません。

直したあとは、赤は止まった・困ったこと、緑は戻った・終わったこと、と意味で分けています。止まったあとに出る2つのボタンも、「スタッフが動かした」は赤、「自然に動いた」は緑です。

誰が使うかを決めずに作っていたので、使う人を広げた

色を直したあと、本人からこう聞かれました。

これって俺が店舗に行って観察することを想定しています? それともロボットの改善を行うスタッフ(業者)が使うことを想定していますか?

答えは、客として店に行き、席から観察する前提でした。席から見えるもの(ロボットの出発・戻り・停止、卓を拭く時間、入口の待ち)だけを数える作りで、注文の数や厨房の中、ロボットの走行記録のように席から見えないものは入れていません。続けて、なぜ自分が観察する用のシートを作ったのかを聞かれ、経緯を説明しました。これまでの流れが「客として店に行く」だったので、その前提で作っていた、というのが答えです。誰が使うかは、作る前に確かめていませんでした。

本人の判断はこうでした。

オレがと限定しないで、ロボットや人の動きの観察を通して業務改善やロボットの改良を担当する人全てが使えるような内容にした方がいいね。もちろんおれも使うだろうけど。

業者や店の担当の人が観察するときに知りたいのは、止まった割合だけではありません。どこで、なぜ止まったかです。そこが分かれば、ロボットの地図を直す、ドリンクバーの前を通らない道にする、通路に椅子を置かない決まりにする、といった直し方が決まります。直したあとにもう一度観察して、止まる回数が減ったかを確かめます。そこで次のように作り直しました。

変えたところ中身
観察の条件記録する人の立場(客席から/店のスタッフ/導入・保守の担当/その他)、ロボットの機種、この観察の条件(例:地図を直す前)を書けるようにした
止まった場所と原因「止まった」を押すと、場所と原因を選ぶボタンが出る。そのあと「自然に動いた」か「スタッフが動かした」を押す
場所の呼び名初めは「厨房の出口、通路、ドリンクバー前、卓の前、入口付近」。店ごとに読点で区切って書き換えられる
原因人とすれ違った/人が立ち止まっていた/椅子・荷物/段差・床・マット/道に迷った/分からない の6つ
止まった場所と原因の表場所と原因の組み合わせごとに、回数・スタッフが動かした回数・止まっていた平均時間を、多い順に並べる
スタッフが運んだ回数スタッフが手で料理を運んだら押す。ロボットが運んだ卓の数と合わせて、運んだうちロボットの割合を出す
前と後の比較「数えた結果」の表で、ほかの観察を選ぶと横に並ぶ。新しい観察を始めると、直前の観察が比べる相手に入る
試算の仮の値主役から外し、表の右端の参考の列にした
立場や店名の引き継ぎ新しい観察を始めると、立場・店・機種・人数・場所の呼び名を前の観察から引き継ぐ。前と後を比べるときに書き直さなくて済む

止まった場所と原因は、押さなくても記録できます。ロボットが止まっている数秒の間に選び切れないこともあるからです。選ばなかった止まりは、表では「場所なし」「原因なし」として数えます。

観察シートのロボットのカード。ロボット1は止まっていて赤くなり、場所と原因を選ぶボタンが出ている。ロボット2は配膳中で、卓に着いた・止まった・戻ったのボタンが並ぶ(試しに押した記録)
止まったロボットの場所に「ドリンクバー前」、原因に「人とすれ違った」を選んだところ。下に「自然に動いた」(緑)と「スタッフが動かした」(赤)のボタンがある

観察シートはサイトに例として置き、記録はブラウザの中にだけ残す

作り直したあと、ほかの人に使ってもらうにはページの共有の設定が要る、と伝えました。本人の答えはこうでした。

今はそこまでして他の人に使ってもらいという想定をしていない。 あくまでも観察シートの例としてアップする形です。

そこで、サイト(pixelstomotors.com)に観察シートの例として置くことにしました。置き場所は前回の試算のページと並べた /sim/serving-robot-observation.html、入口はシリーズのページ(/series8)の「試算を試す」の欄の下です。

サイトに置くページは、Artifact のページをもとに、スクリプト(make_site_copy.py)で次の3つを直して作りました。

直したところ理由
暗い色の指定を外し、明るい色だけにしたサイトにダークモードがないため。前回の試算のページも明るい色だけで固定している
<!doctype>・<head>・<body> を付けたArtifact では公開するときに自動で付くが、サイトでは自分で付ける必要がある
「このページは観察シートの例です」の一文を足した押した記録がこのブラウザの中にだけ保存され、どこにも送られないことを先に伝えるため

記録を保存先へ送る部分は、サイトではそのまま残しています。サイトのページには保存先へ送る仕組み(Artifact の window.claude)がないので、送る部分は動かず、記録はブラウザの localStorage にだけ残ります。上の帯の表示は「ブラウザに保存」になります。「記録をコピー」を押すと、押した記録の全部と集計を JSON の文字として持ち帰れます。

シリーズのページの「試算を試す」の欄の下に足した「観察シートの例」のリンクと説明文
スマホの幅で開いた観察シートの例。上の帯に観察の時間と「ブラウザに保存」、その下に説明と観察の条件の欄が並ぶ
試しに押したあとの「止まった場所と原因」の表と「数えた結果」の表。通路・椅子や荷物とドリンクバー前・人とすれ違ったが1回ずつ並ぶ

記事に載せた観察シートの画面写真は、サイトに置く前に手元で作ったサイトを Playwright(ブラウザを動かす道具)で開き、ボタンを試しに押して撮ったものです。店で観察した記録ではありません。

狭い画面では、3つ並んだボタンの文字が縦に折れていた

画面写真を撮って見たところ、スマホの幅(390px)で、配膳中のロボットの「卓に着いた・止まった・戻った」の3つ並びのボタンが崩れていました。ボタンの中で「卓に着いた」と「この往復 0卓」が横に並び、どちらも1文字ずつ縦に折れていました。3つ並びのときだけ、ボタンの中を縦に積むようにして直しました。

.r3 .btn{flex-direction:column;gap:2px}

「止まった場所と原因」の表も、見出しの「回数」が「回」「数」と縦に折れていたので、表の見出しを折り返さないようにしました。

.stops th{white-space:nowrap}

実行したコマンドと出力

観察シートのサイト用のページは、Artifact のページから make_site_copy.py で作りました。

$ python3 LOG353/scripts/make_site_copy.py serving-robot-observation.html p2m-site/public/sim/serving-robot-observation.html
p2m-site/public/sim/serving-robot-observation.html 45415 bytes

サイトのビルドです(npm ci で依存を入れてから)。

$ npm run build 2>&1 | tail -3
13:40:17 [@astrojs/sitemap] `sitemap-index.xml` created at `dist`
13:40:17 [build] 618 page(s) built in 10.63s
13:40:17 [build] Complete!
$ grep -o '観察シートの例[^<]*' dist/series8.html | head -2
観察シートの例を開く" data-astro-cid-kbb4royl>観察シートの例 ↗
$ ls dist/sim/
serving-robot-observation.html
serving-robot.html

ビルドした結果を手元で開き(python3 -m http.server 8765)、画面写真を撮りながらボタンを試しに押しました。ページの中で起きたエラーの一覧は空でした。

$ python3 shots.py
errors []

最初に撮ったときは、手元のサーバーが /series8 を series8.html に読み替えないため、シリーズのページが見つからず止まりました。住所を /series8.html にして撮り直し、次は画面の外の範囲を切り抜こうとして止まったので、リンクの欄を画面の中へ動かしてから切り抜くように直しています(どちらも shots.py の全文に直したあとの形を載せています)。

AttributeError: 'NoneType' object has no attribute 'bounding_box'
playwright._impl._errors.Error: Page.screenshot: Clipped area is either empty or outside the resulting image

図解は、ボタンを最後まで押したときの値と、幅380px で横にはみ出さないかを確かめました。

$ python3 fig_check.py
配膳の1往復の時間
104秒
96−0=96 / 214−130=84 / 372−240=132 → 平均
試算の仮の値:0.5m/秒で、厨房から卓まで6〜22m
1往復で回る卓
1.33卓
1卓 + 2卓 + 1卓 ÷ 3往復
試算の仮の値:1卓(最大3卓に切り替え可)
一時停止のあった往復
33%
1 ÷ 3往復
試算の仮の値:30%
立ち往生のあった往復
33%
1 ÷ 3往復
試算の仮の値:1%
止まった場所と原因
2件
ドリンクバー前・人とすれ違った・6秒 / 通路・椅子・荷物・46秒
試算にはない。地図やルートを直す場所を決める材料
overflow 380
[]

観察シートの例とシリーズのページのリンクを、サイトに入れた commit です。

$ git show --stat --format='%h %s' HEAD
7861141 観察シートの例を /sim/serving-robot-observation.html に置き、/series8 の試算の下にリンクを足す

 public/sim/serving-robot-observation.html | 633 ++++++++++++++++++++++++++++++
 src/lib/records.ts                        |   5 +-
 src/pages/[series].astro                  |  11 +-
 3 files changed, 646 insertions(+), 3 deletions(-)

シリーズのページの変更の差分です。

diff --git a/src/lib/records.ts b/src/lib/records.ts
index 44e8a36..253ab4e 100644
--- a/src/lib/records.ts
+++ b/src/lib/records.ts
@@ -29,7 +29,7 @@ export const allEnglish = englishCards as MangaCard[];
 
 // シリーズのページ(URL → 記事のシリーズ名)。Studio の一覧の絞り込み条件と同じ
 // intro はシリーズのページの見出しの下に出す説明(ないシリーズは出さない)
-export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number; intro?: string; sim?: { src: string; title: string; note: string } }> = {
+export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number; intro?: string; sim?: { src: string; title: string; note: string; obs?: { src: string; label: string; note: string } } }> = {
   series1: { series: 'HRI', title: 'Drug Delivery Robot | 薬剤搬送ロボットハコブくん', pageTitle: 'Series1 | 薬剤搬送ロボット', limit: 12,
     intro: '病院で薬を運ぶロボット「ハコブくん」を題材に、人とロボットの関わり(HRI)を設計している記録です。病院で使う画面(UI)の設計、Unity での自律移動、Fusion での形づくり、分身ロボット(アバター)の考察までを、4コママンガと一緒に残しています。' },
   series2: { series: 'R2R2R', title: 'R2R2R | デジタルツイン学習-R2R2R(Real to Render to Real)', pageTitle: 'Series2 | R2R2R', limit: 12,
@@ -47,5 +47,6 @@ export const SERIES_PAGES: Record<string, { series: string; title: string; pageT
   series8: { series: '導入シミュレーション', title: 'Service Robot Simulation | サービスロボット導入シミュレーション', pageTitle: 'Series8 | サービスロボット導入シミュレーション', limit: 12,
     intro: '配膳・清掃・搬送など、店や施設で動くサービスロボットを入れたら何が変わるかを、導入前に試算する記録です。時間・順番・数は SimPy で、ぶつかる・こぼれるといった空間と動きは Unity で扱います。前提の数字は公開事例と観察から置き、結果とコードは全量で公開しています。',
     sim: { src: '/sim/serving-robot.html', title: '配膳ロボット、何台入れたら店は変わる?',
-      note: '店を選び、人数・台数・ロボットの使い方を切り替えると、ピーク時の入店待ちや料理が届くまでの時間が変わります。1つの条件を40回回した平均で、数字はすべて仮の前提による試算です(試作 v2・7,665条件)。' } },
+      note: '店を選び、人数・台数・ロボットの使い方を切り替えると、ピーク時の入店待ちや料理が届くまでの時間が変わります。1つの条件を40回回した平均で、数字はすべて仮の前提による試算です(試作 v2・7,665条件)。',
+      obs: { src: '/sim/serving-robot-observation.html', label: '観察シートの例 ↗', note: '試算の仮の数字を確かめるために、店でロボットと人の動きを数える記録シートです。押した記録はこのブラウザの中にだけ保存されます。' } } },
 };
diff --git a/src/pages/[series].astro b/src/pages/[series].astro
index ae266b1..179c379 100644
--- a/src/pages/[series].astro
+++ b/src/pages/[series].astro
@@ -1,5 +1,5 @@
 ---
-// シリーズのページ(/series1〜7)。上に Studio で置いていた資料(Figma・スプレッドシートなど)、下にそのシリーズの記録
+// シリーズのページ(/series1〜8)。上に Studio で置いていた資料(Figma・スプレッドシートなど)、下にそのシリーズの記録
 import Base from '../layouts/Base.astro';
 import RecordCard from '../components/RecordCard.astro';
 import LoadMore from '../components/LoadMore.astro';
@@ -42,6 +42,12 @@ const jsonLd = {
       <p class="s-sim-note">{conf.sim.note}</p>
       <iframe data-embed src={`${conf.sim.src}?embed=1`} title={conf.sim.title} class="s-sim-frame" loading="lazy"></iframe>
       <a class="s-sim-open" href={conf.sim.src} target="_blank" rel="noopener" data-ev="試算を全画面で開く">全画面で開く ↗</a>
+      {conf.sim.obs && (
+        <div class="s-obs">
+          <a class="s-sim-open" href={conf.sim.obs.src} target="_blank" rel="noopener" data-ev="観察シートの例を開く">{conf.sim.obs.label}</a>
+          <p class="s-sim-note">{conf.sim.obs.note}</p>
+        </div>
+      )}
     </div></section>
   )}
   <section class="sec"><div class="panel">
@@ -65,6 +71,9 @@ const jsonLd = {
   .s-sim-frame { display: block; width: 100%; height: 1600px; border: 0; }
   .s-sim-open { display: inline-block; margin-top: 16px; font: 500 14px/22px var(--sans); color: var(--ink); }
   .s-sim-open:hover { text-decoration: underline; }
+  .s-obs { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--line, #e6e6e6); }
+  .s-obs .s-sim-open { margin-top: 0; }
+  .s-obs .s-sim-note { margin: 4px 0 0; }
   @media (max-width: 1023px) { .s-hero { padding: 72px 24px 0; } }
   @media (max-width: 599px) { .s-hero { padding: 64px 12px 0; } }
 </style>

Artifact のページは、公開したあとに記録の保存先が空で読めることを確かめました(保存先の一覧を読むと、まだ観察がないので0件)。

ArtifactData list visits
No documents matched in collection "visits".

この回に使ったコードの全文

サイトのコードは GitHub の PixelstoMotors/p2m-site にあります。観察シートの Artifact 版と、画面写真・図解の確認に使ったスクリプトは、記事の作業フォルダの observation/ と LOG353_scripts/ に置いています。

serving-robot-observation.html(観察シートの Artifact 版。記録を保存先へ送る)

claude.ai の Artifact として公開しているページです。公開するときに <!doctype>・<head>・<body> が自動で付くので、ファイルは <title> から始まります。記録の保存先を使うため、公開のときに capabilities: {db: {}} を付けています。

<title>配膳ロボット観察シート</title>
<meta name="claude:viewport-fit-sides" content="cover">
<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;500;600;700&family=Noto+Sans+JP:wght@400;500;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
<style>
/* 片手で押す縦1列の記録用ツール。上から「よく押すもの → 結果」。選んだ状態は黒地(試算ページと同じ決まり)。赤=止まった・困った、緑=戻った・終わった */
:root{
  --bg:#f7f7f7; --panel:#ffffff; --ink:#242424; --ink-2:#555555; --ink-3:#8a8a8a; --line:#e2e2e2; --soft:#efefef; --on-ink:#ffffff;
  --pause:#9a6412; --pause-bg:#fbf0dc; --stuck:#b3372a; --stuck-bg:#fae6e3; --go:#1d6b47; --go-bg:#e3f1e9;
  --f-ui:"Inter","Noto Sans JP",system-ui,-apple-system,"Hiragino Sans",sans-serif;
  --f-num:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,monospace;
  --r:8px;
}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
  --bg:#131313; --panel:#1d1d1d; --ink:#ececec; --ink-2:#b8b8b8; --ink-3:#8c8c8c; --line:#2f2f2f; --soft:#262626; --on-ink:#131313;
  --pause:#e3a64e; --pause-bg:#2e2413; --stuck:#f0806f; --stuck-bg:#331c18; --go:#62c492; --go-bg:#15291e; color-scheme:dark }}
:root[data-theme="dark"]{
  --bg:#131313; --panel:#1d1d1d; --ink:#ececec; --ink-2:#b8b8b8; --ink-3:#8c8c8c; --line:#2f2f2f; --soft:#262626; --on-ink:#131313;
  --pause:#e3a64e; --pause-bg:#2e2413; --stuck:#f0806f; --stuck-bg:#331c18; --go:#62c492; --go-bg:#15291e; color-scheme:dark }

*{box-sizing:border-box}
[hidden]{display:none!important}
body{background:var(--bg);color:var(--ink);font-family:var(--f-ui);font-size:15px;line-height:1.6;-webkit-text-size-adjust:100%}
body::before{content:"";position:fixed;inset:0 0 auto;height:env(safe-area-inset-top,0px);z-index:10;background:var(--ink)}
.wrap{max-width:680px;margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:0 48px}
h1{font-size:20px;line-height:1.35;margin:20px 0 4px;text-wrap:balance;letter-spacing:-.01em}
h2{font-size:16px;margin:0;text-wrap:balance}
p{margin:0}
.lead{color:var(--ink-2);font-size:13.5px}
.num{font-family:var(--f-num);font-variant-numeric:tabular-nums}

/* 上の帯 */
.bar{position:sticky;top:env(safe-area-inset-top,0px);z-index:5;background:var(--ink);color:var(--on-ink)}
.bar-in{max-width:680px;margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:10px;display:flex;align-items:center;gap:12px}
.bar .clock{font-family:var(--f-num);font-size:22px;font-weight:600;letter-spacing:.02em;font-variant-numeric:tabular-nums}
.bar .lbl{font-size:11px;letter-spacing:.06em;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar .grow{flex:1;min-width:0}
.sync{font-size:11.5px;opacity:.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar button{background:transparent;color:var(--on-ink);border:1px solid currentColor;border-radius:6px;font:600 13px var(--f-ui);padding:8px 12px;min-height:40px;cursor:pointer;white-space:nowrap}
.bar button.armed{background:var(--on-ink);color:var(--ink)}

/* 節 */
section{margin-top:22px}
.sec-h{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:8px}
.tag{font-size:11px;color:var(--ink-3);letter-spacing:.04em}
.box{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px}

/* ボタン */
button{font-family:var(--f-ui)}
.btn{display:flex;align-items:center;justify-content:center;gap:6px;min-height:56px;padding:8px 10px;border-radius:var(--r);border:1px solid var(--line);background:var(--soft);color:var(--ink);font-size:15px;font-weight:600;cursor:pointer;line-height:1.25;text-align:center;touch-action:manipulation;-webkit-tap-highlight-color:transparent}
.btn:active{transform:scale(.98)}
.btn.primary{background:var(--ink);border-color:var(--ink);color:var(--on-ink)}
.btn.pause{background:var(--pause-bg);border-color:var(--pause);color:var(--pause)}
.btn.stuck{background:var(--stuck-bg);border-color:var(--stuck);color:var(--stuck)}
.btn.go{background:var(--go-bg);border-color:var(--go);color:var(--go)}
.btn small{font-weight:500;font-size:12px;opacity:.85}
.btn:disabled{opacity:.4;cursor:default}
button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
.row{display:grid;gap:8px}
.r2{grid-template-columns:1fr 1fr}
.r3{grid-template-columns:1fr 1fr 1fr}
.r3 .btn{flex-direction:column;gap:2px}
.link{background:none;border:0;padding:4px 0;color:var(--ink-2);text-decoration:underline;font-size:13px;cursor:pointer}

/* ロボットのカード */
.robots{display:grid;gap:10px}
.robot{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px;display:grid;gap:10px}
.robot.moving{border-color:var(--ink)}
.robot.stopped{background:var(--stuck-bg);border-color:var(--stuck)}
.robot-h{display:flex;align-items:center;gap:8px}
.robot-h b{font-size:15px}
.pill{font-size:12px;font-weight:600;padding:2px 8px;border-radius:99px;background:var(--soft);color:var(--ink-2);font-variant-numeric:tabular-nums}
.moving .pill{background:var(--ink);color:var(--on-ink)}
.stopped .pill{background:var(--stuck);color:var(--stuck-bg)}
.robot-h .meta{margin-left:auto;font-size:12px;color:var(--ink-3)}

/* 止まった場所と原因のチップ */
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.chips .cl{font-size:12px;color:var(--ink-2);width:3em;flex:none}
.chips button{min-height:36px;padding:4px 10px;border-radius:99px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:13px;cursor:pointer}
.chips button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:var(--on-ink)}

/* 選択肢 */
.q{display:grid;gap:6px;padding:12px 0;border-top:1px solid var(--line)}
.q:first-child{border-top:0;padding-top:0}
.q:last-child{padding-bottom:0}
.q-t{font-size:14px;font-weight:500}
.seg{display:flex;gap:6px;flex-wrap:wrap}
.seg button{flex:1 1 0;min-width:72px;min-height:44px;border-radius:6px;border:1px solid var(--line);background:var(--soft);color:var(--ink);font-size:13.5px;font-weight:500;cursor:pointer}
.seg button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:var(--on-ink)}

/* 入力 */
.fields{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.field{display:grid;gap:4px;min-width:0}
.field.full{grid-column:1/-1}
.field label,.field .fl{font-size:12px;color:var(--ink-2)}
input[type=text],input[type=number],textarea,select{width:100%;font:15px var(--f-ui);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:10px;min-height:44px}
textarea{min-height:64px;resize:vertical}
.step{display:flex;align-items:center;border:1px solid var(--line);border-radius:6px;background:var(--bg);overflow:hidden}
.step button{width:44px;min-height:44px;border:0;background:transparent;color:var(--ink);font-size:20px;cursor:pointer}
.step output{flex:1;text-align:center;font-family:var(--f-num);font-size:17px;font-weight:600}

/* 下げ膳のタイマー */
.timers{display:grid;gap:8px;margin-top:10px}
.timer{display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;background:var(--soft);border-radius:6px;padding:8px 8px 8px 12px}
.timer .t{font-family:var(--f-num);font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}
.timer .w{font-size:12px;color:var(--ink-2)}
.timer .btn{min-height:44px;padding:6px 14px}
.x{width:40px;min-height:40px;border:0;background:transparent;color:var(--ink-3);font-size:18px;cursor:pointer;border-radius:6px}

/* 表 */
.tbl-wrap{overflow-x:auto;background:var(--panel);border:1px solid var(--line);border-radius:var(--r)}
table{border-collapse:collapse;width:100%;font-size:13.5px;min-width:560px}
th,td{text-align:left;vertical-align:top;padding:10px 12px;border-top:1px solid var(--line)}
thead th{border-top:0;font-size:11.5px;color:var(--ink-3);font-weight:600;letter-spacing:.04em;background:var(--soft)}
td.obs{font-weight:600}
td.obs .n{display:block;font-weight:400;font-size:12px;color:var(--ink-3)}
td.none{color:var(--ink-3);font-weight:400}
td.sim{color:var(--ink-2)}
.cmp-bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
.cmp-bar label{font-size:12.5px;color:var(--ink-2)}
.cmp-bar select{width:auto;flex:1;min-width:0;max-width:100%}
.stops th{white-space:nowrap}
.stops td.c{font-family:var(--f-num);font-variant-numeric:tabular-nums;white-space:nowrap}
.stops td.hot{color:var(--stuck);font-weight:600}

/* 記録の一覧 */
.log{list-style:none;margin:0;padding:0}
.log li{display:grid;grid-template-columns:64px 1fr 40px;gap:8px;align-items:center;padding:6px 0;border-top:1px solid var(--line);font-size:13.5px}
.log li:first-child{border-top:0}
.log .tm{font-family:var(--f-num);font-size:12.5px;color:var(--ink-3);font-variant-numeric:tabular-nums}
.log .un{width:6px;height:6px;border-radius:50%;background:var(--pause);display:inline-block;margin-left:6px;vertical-align:2px}
.empty{color:var(--ink-3);font-size:13.5px}

.note{font-size:12.5px;color:var(--ink-2)}
.foot{display:grid;gap:10px}
.copyout{width:100%;min-height:120px;font:12px var(--f-num)}
.ended{background:var(--soft);border-radius:var(--r);padding:12px;display:flex;gap:12px;align-items:center;justify-content:space-between;font-size:13.5px}
details summary{cursor:pointer;font-weight:600;font-size:14px;list-style:none;display:flex;justify-content:space-between;align-items:center;min-height:32px}
details summary::-webkit-details-marker{display:none}
details summary::after{content:"開く";font-weight:500;font-size:12px;color:var(--ink-3)}
details[open] summary::after{content:"閉じる"}
details[open] summary{margin-bottom:12px}

@media (max-width:420px){.fields{grid-template-columns:1fr}.r3 .btn{font-size:14px}}
@media (prefers-reduced-motion:reduce){.btn:active{transform:none}}
</style>

<div class="bar" role="banner">
  <div class="bar-in">
    <div class="grow">
      <div class="lbl" id="barLbl">観察前</div>
      <div class="clock" id="clock">0:00:00</div>
    </div>
    <div class="grow" style="text-align:right">
      <div class="sync" id="sync" aria-live="polite">この端末に保存</div>
    </div>
    <button type="button" id="endBtn">観察を終える</button>
  </div>
</div>

<div class="wrap">
  <h1>配膳ロボット観察シート</h1>
  <p class="lead">配膳ロボットが動いている店で、ロボットと人の動きを数える記録シートです。客席から見る人、店のスタッフ、ロボットの導入や保守を担当する人の誰でも使えます。止まった場所と原因、皿を下げる時間、ロボットとスタッフが運んだ割合を数えて、運用の見直しやロボットの調整の材料にします。地図や設定を変える前と後の観察を並べて比べられます。最初のボタンを押した時刻から観察が始まります。人や店内の写真は撮りません。</p>

  <div id="endedNote" class="ended" hidden style="margin-top:14px">
    <span>この観察は終わっています。</span>
    <button type="button" class="link" id="reopenBtn">続きを記録する</button>
  </div>

  <section>
    <details class="box" id="setupBox" open>
      <summary>観察の条件</summary>
      <div class="fields">
        <div class="field full"><span class="fl">記録する人の立場</span>
          <div class="seg" id="role" role="group" aria-label="記録する人の立場">
            <button type="button" data-role="guest">客席から</button>
            <button type="button" data-role="store">店のスタッフ</button>
            <button type="button" data-role="vendor">導入・保守の担当</button>
            <button type="button" data-role="other">その他</button>
          </div></div>
        <div class="field"><label for="f-store">店</label><input type="text" id="f-store" autocomplete="off" placeholder="例 ○○店"></div>
        <div class="field"><label for="f-model">ロボットの機種</label><input type="text" id="f-model" autocomplete="off" placeholder="例 BellaBot"></div>
        <div class="field full"><label for="f-label">この観察の条件</label><input type="text" id="f-label" autocomplete="off" placeholder="例 地図を直す前/ドリンクバーの前を通らない設定にした後"></div>
        <div class="field"><span class="fl">ホールのスタッフ</span>
          <div class="step"><button type="button" data-step="staff" data-d="-1" aria-label="1人減らす">−</button><output id="o-staff"></output><button type="button" data-step="staff" data-d="1" aria-label="1人増やす">+</button></div></div>
        <div class="field"><span class="fl">ロボットの台数</span>
          <div class="step"><button type="button" data-step="robots" data-d="-1" aria-label="1台減らす">−</button><output id="o-robots"></output><button type="button" data-step="robots" data-d="1" aria-label="1台増やす">+</button></div></div>
        <div class="field"><label for="f-tables">見えている卓の数</label><input type="number" inputmode="numeric" id="f-tables" min="0" placeholder="例 18"></div>
        <div class="field"><label for="f-seat">見ている範囲</label><input type="text" id="f-seat" placeholder="例 厨房の出口から奥の通路まで"></div>
        <div class="field full"><label for="f-places">止まる場所の呼び名(読点で区切る)</label><input type="text" id="f-places" placeholder="厨房の出口、通路、ドリンクバー前、卓の前、入口付近"></div>
        <div class="field full"><label for="f-note">メモ</label><textarea id="f-note" placeholder="通路の幅、段差、マット、ドリンクバーの位置など"></textarea></div>
        <div class="field"><label for="f-who">記録した人(任意)</label><input type="text" id="f-who" autocomplete="off"></div>
      </div>
    </details>
  </section>

  <section>
    <div class="sec-h"><h2>ロボットの往復と、止まった場所</h2><span class="tag">ロボット</span></div>
    <p class="note" style="margin-bottom:10px">厨房の受け渡し口を出たら「出発」、戻ったら「戻った」。卓で料理や皿を受け渡すたびに「卓に着いた」。通路で止まったら「止まった」を押し、分かれば場所と原因を選んでから、どう動き出したかを押します。</p>
    <div class="robots" id="robots"></div>
  </section>

  <section>
    <div class="sec-h"><h2>スタッフが運んだ回数</h2><span class="tag">人</span></div>
    <div class="box">
      <p class="note" style="margin-bottom:10px">ロボットを使わずに、スタッフが手で料理を卓まで運んだら押します。ロボットが運んだ卓の数と合わせて、運んだうちロボットの割合を出します。</p>
      <div class="row"><button type="button" class="btn" id="staffServe">スタッフが料理を運んだ <span class="num" id="staffServeN">0</span><small>回</small></button></div>
    </div>
  </section>

  <section>
    <div class="sec-h"><h2>皿を下げて卓を拭く時間</h2><span class="tag">人</span></div>
    <div class="box">
      <p class="note" style="margin-bottom:10px">食べ終わった卓に誰かが手を付けたら「始まった」、卓を拭き終えたら「終わった」。</p>
      <div class="seg" id="busWho" role="group" aria-label="誰が下げているか">
        <button type="button" data-who="staff" aria-pressed="true">スタッフだけ</button>
        <button type="button" data-who="robot" aria-pressed="false">ロボットに載せる</button>
        <button type="button" data-who="guest" aria-pressed="false">客が載せる</button>
      </div>
      <div class="row" style="margin-top:8px"><button type="button" class="btn primary" id="busStart">始まった</button></div>
      <div class="timers" id="timers"></div>
    </div>
  </section>

  <section>
    <div class="sec-h"><h2>入口</h2><span class="tag">店</span></div>
    <div class="box">
      <div class="row r2">
        <button type="button" class="btn" id="arrive">入店した <span class="num" id="arriveN">0</span><small>組</small></button>
        <div class="field"><span class="fl">入口で待っている組</span>
          <div class="step"><button type="button" id="qDown" aria-label="1組減らす">−</button><output id="qN" class="num">0</output><button type="button" id="qUp" aria-label="1組増やす">+</button></div></div>
      </div>
    </div>
  </section>

  <section>
    <div class="sec-h"><h2>ロボットの使い方</h2><span class="tag">店</span></div>
    <div class="box" id="usage"></div>
  </section>

  <section>
    <div class="sec-h"><h2>止まった場所と原因</h2><span class="tag">多い順</span></div>
    <div class="tbl-wrap"><table class="stops">
      <thead><tr><th>場所</th><th>原因</th><th>回数</th><th>スタッフが動かした</th><th>止まっていた時間(平均)</th></tr></thead>
      <tbody id="stops"></tbody>
    </table></div>
  </section>

  <section>
    <div class="sec-h"><h2>数えた結果</h2><span class="tag">押すたびに計算し直す</span></div>
    <div class="cmp-bar"><label for="cmp">比べる観察</label><select id="cmp"></select></div>
    <div class="tbl-wrap"><table>
      <thead><tr><th style="width:24%">項目</th><th style="width:22%" id="hCur">この観察</th><th style="width:22%" id="hCmp" hidden>比べる観察</th><th>試算で置いている値</th></tr></thead>
      <tbody id="sum"></tbody>
    </table></div>
    <p class="note" style="margin-top:8px">試算で置いている値は、サービスロボット導入シミュレーション Log#1(35卓のファミレスを想定した SimPy の試算)の仮の数字です。</p>
  </section>

  <section>
    <div class="sec-h"><h2>記録</h2><span class="tag" id="logCount"></span></div>
    <div class="box">
      <ul class="log" id="log"></ul>
      <button type="button" class="link" id="logMore" hidden>すべて表示する</button>
    </div>
    <p class="note" style="margin-top:8px">押し間違えたら ✕ で取り消せます。橙の点は、まだ送れていない記録です(電波が戻れば自動で送ります)。</p>
  </section>

  <section class="foot">
    <div class="row r2">
      <button type="button" class="btn" id="copyBtn">記録をコピー</button>
      <button type="button" class="btn" id="newBtn">新しい観察を始める</button>
    </div>
    <textarea class="copyout" id="copyOut" hidden readonly aria-label="コピー用の記録"></textarea>
    <div class="field" id="pastWrap" hidden><label for="past">開いている観察</label><select id="past"></select></div>
  </section>
</div>

<script>
(function(){
'use strict';
var LS='p2m-serving-obs-v1';
var USAGE=[
  {k:'serve',t:'ロボットが料理を運んでいる',o:[['yes','はい'],['no','いいえ'],['unk','見ていない']]},
  {k:'bus',t:'ロボットが下げた皿を洗い場へ運んでいる',o:[['yes','はい'],['no','いいえ'],['unk','見ていない']]},
  {k:'loader',t:'下げた皿をロボットに載せるのは',o:[['staff','スタッフ'],['guest','客'],['both','両方'],['unk','見ていない']]},
  {k:'collect',t:'食事中の卓を回って、空いた皿を集めている',o:[['yes','はい'],['no','いいえ'],['unk','見ていない']]},
  {k:'fragile',t:'デザートやドリンクもロボットが運んでいる',o:[['yes','はい'],['no','いいえ'],['unk','見ていない']]}
];
var WHO={staff:'スタッフだけ',robot:'ロボットに載せる',guest:'客が載せる'};
var ROLE={guest:'客席から',store:'店のスタッフ',vendor:'導入・保守の担当',other:'その他'};
var CAUSES=['人とすれ違った','人が立ち止まっていた','椅子・荷物','段差・床・マット','道に迷った','分からない'];
var DEF_PLACES='厨房の出口、通路、ドリンクバー前、卓の前、入口付近';

var S=load(), db=null, canWrite=true, syncing=false, again=false, syncErr=null, showAll=false, busWho='staff', pick={}, cmpId='';
function load(){try{var s=JSON.parse(localStorage.getItem(LS));if(s&&s.visits)return s;}catch(e){}return {cur:null,visits:{}};}
function save(){try{localStorage.setItem(LS,JSON.stringify(S));}catch(e){}}
function uid(p){return p+Date.now().toString(36)+Math.random().toString(36).slice(2,7);}
function newVisit(){var id=uid('v'),prev=S.cur&&S.visits[S.cur]?S.visits[S.cur].meta:null;
  S.visits[id]={id:id,events:{},meta:{role:prev?prev.role:'',who:prev?prev.who:'',store:prev?prev.store:'',model:prev?prev.model:'',label:'',staff:prev?prev.staff:3,robots:prev?prev.robots:2,tables:prev?prev.tables:'',seat:prev?prev.seat:'',places:prev?prev.places:DEF_PLACES,note:'',usage:{},startedAt:null,endedAt:null,_v:1,_sv:0}};
  S.cur=id;save();}
if(!S.cur||!S.visits[S.cur])newVisit();
function V(){return S.visits[S.cur];}
function M(){return V().meta;}
function started(){return !!M().startedAt;}
function ended(){return !!M().endedAt;}
function touchMeta(){M()._v=(M()._v||0)+1;}
function begin(){if(!started()){M().startedAt=Date.now();touchMeta();wake();}}
function evs(v){v=v||V();return Object.keys(v.events).map(function(k){return v.events[k];}).filter(function(e){return !e._d;}).sort(function(a,b){return a.t-b.t;});}
function places(m){return String((m||M()).places||DEF_PLACES).split(/[、,,]/).map(function(s){return s.trim();}).filter(Boolean);}

function add(type,f){
  if(ended())return;
  begin();
  var e={id:uid('e'),t:Date.now(),type:type};
  for(var k in f)if(f[k]!==undefined&&f[k]!=='')e[k]=f[k];
  V().events[e.id]=e;save();render();sync();
  try{navigator.vibrate&&navigator.vibrate(12);}catch(x){}
}
function del(id){var e=V().events[id];if(!e)return;e._d=true;save();render();sync();}

/* ---------- 記録から値を組み立てる ---------- */
function derive(list){
  var R={},trips=[],buses=[],openBus={},arrivals=0,qMax=0,qNow=0,staffServe=0;
  function rb(n){return R[n]||(R[n]={trip:null});}
  list.forEach(function(e){
    var r;
    switch(e.type){
      case 'trip_start': r=rb(e.robot);if(r.trip)closeTrip(r,e.t,true);r.trip={robot:e.robot,purpose:e.purpose,start:e.t,tables:0,stops:[],cur:null};break;
      case 'table': r=rb(e.robot);if(r.trip)r.trip.tables++;break;
      case 'stop_start': r=rb(e.robot);if(r.trip&&!r.trip.cur)r.trip.cur={start:e.t};break;
      case 'stop_end': r=rb(e.robot);if(r.trip&&r.trip.cur){var c=r.trip.cur;c.end=e.t;c.by=e.by;c.place=e.place;c.cause=e.cause;r.trip.stops.push(c);r.trip.cur=null;}break;
      case 'trip_end': r=rb(e.robot);if(r.trip)closeTrip(r,e.t,false);break;
      case 'bus_start': openBus[e.bus]={id:e.bus,who:e.who,start:e.t,ev:e.id};break;
      case 'bus_end': if(openBus[e.bus]){openBus[e.bus].end=e.t;buses.push(openBus[e.bus]);delete openBus[e.bus];}break;
      case 'arrive': arrivals++;break;
      case 'queue': qNow=e.n;if(e.n>qMax)qMax=e.n;break;
      case 'staff_serve': staffServe++;break;
    }
  });
  function closeTrip(r,t,cut){var tr=r.trip;if(tr.cur){tr.cur.end=t;tr.cur.by=null;tr.stops.push(tr.cur);tr.cur=null;}tr.end=t;tr.cut=cut;trips.push(tr);r.trip=null;}
  return {R:R,trips:trips,buses:buses,openBus:openBus,arrivals:arrivals,qMax:qMax,qNow:qNow,staffServe:staffServe};
}

/* ---------- 表示 ---------- */
function $(id){return document.getElementById(id);}
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
function dur(ms){var s=Math.max(0,Math.floor(ms/1000)),h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;return (h?h+':'+String(m).padStart(2,'0'):m)+':'+String(x).padStart(2,'0');}
function clk(t){var d=new Date(t);return [d.getHours(),d.getMinutes(),d.getSeconds()].map(function(n){return String(n).padStart(2,'0');}).join(':');}
function mean(a){return a.length?a.reduce(function(s,x){return s+x;},0)/a.length:null;}
function pct(n,d){return d?Math.round(n/d*100)+'%':'—';}
function r1(x){return Math.round(x*10)/10;}
function vname(v){var m=v.meta,d=m.startedAt?new Date(m.startedAt):null;return (d?(d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0'):'開始前')+' '+(m.store||'店名なし')+(m.label?' '+m.label:'');}

function renderSetup(){
  var m=M();
  ['store','model','label','tables','seat','places','note','who'].forEach(function(k){var el=$('f-'+k);if(document.activeElement!==el)el.value=m[k]==null?'':m[k];});
  $('o-staff').textContent=m.staff+'人';$('o-robots').textContent=m.robots+'台';
  [].forEach.call($('role').children,function(b){b.setAttribute('aria-pressed',String(b.dataset.role===m.role));});
}

function renderRobots(d){
  var html='',n=M().robots,pl=places();
  for(var i=1;i<=n;i++){
    var r=d.R[i]||{trip:null},tr=r.trip,done=d.trips.filter(function(t){return t.robot===i&&!t.cut;}).length;
    var st=!tr?'idle':(tr.cur?'stopped':'moving');
    html+='<div class="robot '+st+'"><div class="robot-h"><b>ロボット'+i+'</b>';
    if(st==='idle')html+='<span class="pill">待機</span>';
    else if(st==='moving')html+='<span class="pill">'+(tr.purpose==='bus'?'下げ膳':'配膳')+' <span data-since="'+tr.start+'">'+dur(Date.now()-tr.start)+'</span></span>';
    else html+='<span class="pill">止まっている <span data-since="'+tr.cur.start+'">'+dur(Date.now()-tr.cur.start)+'</span></span>';
    html+='<span class="meta">往復 <span class="num">'+done+'</span></span></div>';
    if(st==='idle'){
      html+='<div class="row r2"><button type="button" class="btn primary" data-a="trip_start" data-r="'+i+'" data-p="serve">配膳で出発</button><button type="button" class="btn" data-a="trip_start" data-r="'+i+'" data-p="bus">下げ膳で出発</button></div>';
    }else if(st==='moving'){
      html+='<div class="row r3"><button type="button" class="btn" data-a="table" data-r="'+i+'">卓に着いた<small class="num">この往復 '+tr.tables+'卓</small></button><button type="button" class="btn stuck" data-a="stop_start" data-r="'+i+'">止まった<small class="num">'+(tr.stops.length?'この往復 '+tr.stops.length+'回':'通路で')+'</small></button><button type="button" class="btn go" data-a="trip_end" data-r="'+i+'">戻った</button></div>';
    }else{
      var p=pick[i]||{};
      html+='<div class="chips" role="group" aria-label="止まった場所"><span class="cl">場所</span>'+pl.map(function(x){return '<button type="button" data-pl="'+esc(x)+'" data-r="'+i+'" aria-pressed="'+(p.place===x)+'">'+esc(x)+'</button>';}).join('')+'</div>';
      html+='<div class="chips" role="group" aria-label="止まった原因"><span class="cl">原因</span>'+CAUSES.map(function(x){return '<button type="button" data-ca="'+esc(x)+'" data-r="'+i+'" aria-pressed="'+(p.cause===x)+'">'+esc(x)+'</button>';}).join('')+'</div>';
      html+='<div class="row r2"><button type="button" class="btn go" data-a="stop_end" data-r="'+i+'" data-by="self">自然に動いた</button><button type="button" class="btn stuck" data-a="stop_end" data-r="'+i+'" data-by="staff">スタッフが動かした</button></div>';
    }
    html+='</div>';
  }
  $('robots').innerHTML=html;
}

function renderBus(d){
  [].forEach.call($('busWho').children,function(b){b.setAttribute('aria-pressed',String(b.dataset.who===busWho));});
  var html='';
  Object.keys(d.openBus).forEach(function(k){var b=d.openBus[k];
    html+='<div class="timer"><div><div class="t" data-since="'+b.start+'">'+dur(Date.now()-b.start)+'</div><div class="w">'+esc(WHO[b.who]||'')+'</div></div><button type="button" class="btn go" data-a="bus_end" data-b="'+k+'">終わった</button><button type="button" class="x" data-del="'+b.ev+'" aria-label="取り消す">✕</button></div>';});
  $('timers').innerHTML=html;
}

function renderUsage(){
  var u=M().usage||{},html='';
  USAGE.forEach(function(q){
    html+='<div class="q"><div class="q-t">'+q.t+'</div><div class="seg" role="group" aria-label="'+q.t+'">';
    q.o.forEach(function(o){html+='<button type="button" data-u="'+q.k+'" data-v="'+o[0]+'" aria-pressed="'+(u[q.k]===o[0])+'">'+o[1]+'</button>';});
    html+='</div></div>';
  });
  $('usage').innerHTML=html;
}

function usageText(m){
  var u=m.usage||{},L={yes:'○',no:'×',unk:'?'},parts=[];
  if(u.serve)parts.push('料理'+L[u.serve]);
  if(u.bus)parts.push('下げ膳'+L[u.bus]);
  if(u.collect)parts.push('食事中に回収'+L[u.collect]);
  if(u.fragile)parts.push('デザート・ドリンク'+L[u.fragile]);
  if(u.loader&&u.loader!=='unk')parts.push('載せるのは'+{staff:'スタッフ',guest:'客',both:'スタッフと客'}[u.loader]);
  return parts.join('・');
}

function stats(v){
  var d=derive(evs(v)),m=v.meta;
  var full=d.trips.filter(function(t){return !t.cut;});
  var serve=full.filter(function(t){return t.purpose==='serve';}),bus=full.filter(function(t){return t.purpose==='bus';});
  var selfStops=[],staffStops=0,tripsPaused=0,tripsStuck=0,allStops=[];
  full.forEach(function(t){var p=false,s=false;t.stops.forEach(function(x){allStops.push(x);if(x.by==='self'){p=true;selfStops.push((x.end-x.start)/1000);}if(x.by==='staff'){s=true;staffStops++;}});if(p)tripsPaused++;if(s)tripsStuck++;});
  var robotTables=serve.reduce(function(a,t){return a+t.tables;},0);
  var span=m.startedAt?((m.endedAt||Date.now())-m.startedAt)/3600000:0;
  return {
    d:d,full:full.length,allStops:allStops,
    serveSec:mean(serve.map(function(t){return (t.end-t.start)/1000;})),serveN:serve.length,
    busSec:mean(bus.map(function(t){return (t.end-t.start)/1000;})),busN:bus.length,
    tablesPer:mean(serve.map(function(t){return t.tables;})),multi:serve.filter(function(t){return t.tables>=2;}).length,
    tripsPaused:tripsPaused,pauseSec:mean(selfStops),pauseN:selfStops.length,
    tripsStuck:tripsStuck,staffStops:staffStops,stopsPerHour:span>=0.25?allStops.length/span:null,
    robotTables:robotTables,staffServe:d.staffServe,
    busTimes:d.buses,busMean:mean(d.buses.map(function(b){return (b.end-b.start)/1000;})),
    perHour:span>=0.25?d.arrivals/span:null,arrivals:d.arrivals,qMax:d.qMax,qNow:d.qNow,spanMin:Math.round(span*60),
    usage:usageText(m)
  };
}

function whoBreak(bs){var c={};bs.forEach(function(b){c[b.who]=(c[b.who]||0)+1;});var k=Object.keys(c);return k.length>1||(k[0]&&k[0]!=='staff')?'('+k.map(function(w){return WHO[w]+' '+c[w];}).join('・')+')':'';}

var ROWS=[
  {t:'ロボットの使い方',sim:'料理を運ぶだけ(皿を下げるのは人)',f:function(s){return [s.usage||null,''];}},
  {t:'配膳の1往復の時間',sim:'0.5m/秒で走る(厨房から卓まで6〜22m)',f:function(s){return [s.serveSec==null?null:Math.round(s.serveSec)+'秒',s.serveN+'往復の平均'];}},
  {t:'下げ膳の1往復の時間',sim:'(ロボットは下げ膳をしない)',f:function(s){return [s.busSec==null?null:Math.round(s.busSec)+'秒',s.busN+'往復の平均'];}},
  {t:'1往復で回る卓',sim:'1卓(「最大3卓」に切り替え可)',f:function(s){return [s.tablesPer==null?null:r1(s.tablesPer)+'卓','2卓以上は'+s.multi+'/'+s.serveN+'往復'];}},
  {t:'料理を運んだうちロボットの割合',sim:'(ロボットとスタッフのうち先に空いた方が運ぶ)',f:function(s){var n=s.robotTables+s.staffServe;return [n?pct(s.robotTables,n):null,'ロボット'+s.robotTables+'卓・スタッフ'+s.staffServe+'回'];}},
  {t:'一時停止(自然に動き出す)',sim:'往復の30%で、3〜10秒',f:function(s){return [s.full?pct(s.tripsPaused,s.full):null,s.full?(s.pauseSec==null?'止まった時間はまだない':'平均'+r1(s.pauseSec)+'秒('+s.pauseN+'回)')+'・'+s.full+'往復中':''];}},
  {t:'立ち往生(スタッフが動かす)',sim:'往復の1%',f:function(s){return [s.full?pct(s.tripsStuck,s.full):null,s.full?s.staffStops+'回・'+s.full+'往復中':''];}},
  {t:'1時間あたり止まった回数',sim:'—',f:function(s){return [s.stopsPerHour==null?(s.allStops.length?s.allStops.length+'回':null):r1(s.stopsPerHour)+'回/時',s.allStops.length+'回・'+s.spanMin+'分間'];}},
  {t:'皿を下げて卓を拭く',sim:'90秒+歩く時間',f:function(s){return [s.busMean==null?null:Math.round(s.busMean)+'秒',s.busTimes.length+'卓の平均'+whoBreak(s.busTimes)];}},
  {t:'入店のペース',sim:'3段階(例 1時間に35組)',f:function(s){return [s.perHour==null?(s.arrivals?s.arrivals+'組':null):Math.round(s.perHour)+'組/時',s.arrivals+'組・'+s.spanMin+'分間'];}},
  {t:'入口の待ち',sim:'試算の結果(入店待ち)と比べる',f:function(s){return [s.qMax||s.arrivals?'最大'+s.qMax+'組':null,'今 '+s.qNow+'組'];}}
];
function cell(r){return r[0]==null?'<td class="obs none">まだ記録がない</td>':'<td class="obs">'+esc(r[0])+(r[1]?'<span class="n">'+esc(r[1])+'</span>':'')+'</td>';}
function renderSum(s){
  var cv=cmpId&&S.visits[cmpId]?S.visits[cmpId]:null,cs=cv?stats(cv):null;
  $('hCmp').hidden=!cs;$('hCmp').textContent=cs?vname(cv):'';
  $('sum').innerHTML=ROWS.map(function(R){return '<tr><th scope="row">'+R.t+'</th>'+cell(R.f(s))+(cs?cell(R.f(cs)):'')+'<td class="sim">'+R.sim+'</td></tr>';}).join('');
  var ids=Object.keys(S.visits).filter(function(k){return k!==S.cur&&S.visits[k].meta.startedAt;}).sort(function(a,b){return S.visits[b].meta.startedAt-S.visits[a].meta.startedAt;});
  $('cmp').innerHTML='<option value="">比べない</option>'+ids.map(function(k){return '<option value="'+k+'"'+(k===cmpId?' selected':'')+'>'+esc(vname(S.visits[k]))+'</option>';}).join('');
  $('cmp').disabled=!ids.length;
}

function renderStops(s){
  var g={};
  s.allStops.forEach(function(x){var k=(x.place||'場所なし')+'\u0000'+(x.cause||'原因なし');var o=g[k]||(g[k]={place:x.place||'場所なし',cause:x.cause||'原因なし',n:0,staff:0,secs:[]});o.n++;if(x.by==='staff')o.staff++;o.secs.push((x.end-x.start)/1000);});
  var rows=Object.keys(g).map(function(k){return g[k];}).sort(function(a,b){return b.n-a.n||b.staff-a.staff;});
  $('stops').innerHTML=rows.length?rows.map(function(o,i){return '<tr><td>'+esc(o.place)+'</td><td>'+esc(o.cause)+'</td><td class="c'+(i===0?' hot':'')+'">'+o.n+'回</td><td class="c">'+o.staff+'回</td><td class="c">'+r1(mean(o.secs))+'秒</td></tr>';}).join(''):'<tr><td colspan="5" class="none">まだ止まった記録はありません。</td></tr>';
}

function label(e){
  var r=e.robot?'ロボット'+e.robot+' ':'';
  switch(e.type){
    case 'trip_start':return r+(e.purpose==='bus'?'下げ膳で出発':'配膳で出発');
    case 'table':return r+'卓に着いた';
    case 'stop_start':return r+'止まった';
    case 'stop_end':return r+(e.by==='staff'?'スタッフが動かした':'自然に動いた')+(e.place||e.cause?'('+[e.place,e.cause].filter(Boolean).join('・')+')':'');
    case 'trip_end':return r+'戻った';
    case 'bus_start':return '下げ膳・卓拭き 始まった('+(WHO[e.who]||'')+')';
    case 'bus_end':return '下げ膳・卓拭き 終わった';
    case 'arrive':return '入店した';
    case 'queue':return '入口の待ち '+e.n+'組';
    case 'staff_serve':return 'スタッフが料理を運んだ';
  }
  return e.type;
}
function renderLog(list){
  var rev=list.slice().reverse(),shown=showAll?rev:rev.slice(0,30);
  $('logCount').textContent=list.length?list.length+'件':'';
  $('log').innerHTML=shown.length?shown.map(function(e){return '<li><span class="tm">'+clk(e.t)+'</span><span>'+esc(label(e))+(e._s?'':'<span class="un" title="未送信"></span>')+'</span><button type="button" class="x" data-del="'+e.id+'" aria-label="取り消す">✕</button></li>';}).join(''):'<li class="empty" style="display:block">まだ記録はありません。ロボットが厨房を出たら「配膳で出発」を押してください。</li>';
  $('logMore').hidden=showAll||rev.length<=30;
}
function renderPast(){
  var ids=Object.keys(S.visits).filter(function(k){return S.visits[k].meta.startedAt||k===S.cur;});
  $('pastWrap').hidden=ids.length<2;
  ids.sort(function(a,b){return (S.visits[b].meta.startedAt||9e15)-(S.visits[a].meta.startedAt||9e15);});
  $('past').innerHTML=ids.map(function(k){return '<option value="'+k+'"'+(k===S.cur?' selected':'')+'>'+esc(vname(S.visits[k]))+'</option>';}).join('');
}
function renderSync(){
  var v=V(),pend=evs(v).filter(function(e){return !e._s;}).length+((v.meta.startedAt&&v.meta._v!==v.meta._sv)?1:0);
  var t;
  if(!db||!canWrite)t='この端末にだけ保存(記録をコピーで渡せます)';
  else if(syncErr&&pend)t='未送信 '+pend+'件(再送します)';
  else if(pend)t='送信中…';
  else t='保存済み';
  $('sync').textContent=t;
}
function renderBar(){
  var m=M();
  $('barLbl').textContent=!started()?'観察前':(ended()?'観察を終えた':'観察中 '+clk(m.startedAt)+'から');
  $('clock').textContent=started()?dur((m.endedAt||Date.now())-m.startedAt):'0:00:00';
  $('endBtn').hidden=!started()||ended();
  $('endedNote').hidden=!ended();
  document.querySelectorAll('.wrap .btn,#busWho button').forEach(function(b){if(b.id!=='copyBtn'&&b.id!=='newBtn')b.disabled=ended();});
}
function render(){
  var list=evs(),s=stats(V()),d=s.d;
  renderSetup();renderRobots(d);renderBus(d);renderUsage();renderStops(s);renderSum(s);renderLog(list);renderPast();renderSync();renderBar();
  $('arriveN').textContent=d.arrivals;$('qN').textContent=d.qNow;$('staffServeN').textContent=d.staffServe;
}
function tick(){
  document.querySelectorAll('[data-since]').forEach(function(el){el.textContent=dur(Date.now()-Number(el.dataset.since));});
  if(started()&&!ended())$('clock').textContent=dur(Date.now()-M().startedAt);
}

/* ---------- 操作 ---------- */
document.addEventListener('click',function(ev){
  var b=ev.target.closest('button');if(!b)return;
  var a=b.dataset.a;
  if(a==='trip_start')return add('trip_start',{robot:+b.dataset.r,purpose:b.dataset.p});
  if(a==='stop_start'){pick[+b.dataset.r]={};return add('stop_start',{robot:+b.dataset.r});}
  if(a==='table'||a==='trip_end')return add(a,{robot:+b.dataset.r});
  if(a==='stop_end'){var p=pick[+b.dataset.r]||{};delete pick[+b.dataset.r];return add('stop_end',{robot:+b.dataset.r,by:b.dataset.by,place:p.place,cause:p.cause});}
  if(a==='bus_end')return add('bus_end',{bus:b.dataset.b});
  if(b.dataset.del)return del(b.dataset.del);
  if(b.dataset.pl||b.dataset.ca){var r=+b.dataset.r,pk=pick[r]||(pick[r]={}),key=b.dataset.pl?'place':'cause',val=b.dataset.pl||b.dataset.ca;pk[key]=pk[key]===val?undefined:val;renderRobots(derive(evs()));return;}
  if(b.dataset.u){if(ended())return;var u=M().usage||(M().usage={});if(u[b.dataset.u]===b.dataset.v)delete u[b.dataset.u];else u[b.dataset.u]=b.dataset.v;begin();touchMeta();save();render();sync();return;}
  if(b.dataset.role){M().role=M().role===b.dataset.role?'':b.dataset.role;touchMeta();save();render();sync();return;}
  if(b.dataset.who){busWho=b.dataset.who;renderBus(derive(evs()));return;}
  if(b.dataset.step){var k=b.dataset.step,m=M(),hi=k==='robots'?6:12;m[k]=Math.min(hi,Math.max(1,m[k]+Number(b.dataset.d)));touchMeta();save();render();sync();return;}
});
$('busStart').addEventListener('click',function(){add('bus_start',{bus:uid('b'),who:busWho});});
$('arrive').addEventListener('click',function(){add('arrive',{});});
$('staffServe').addEventListener('click',function(){add('staff_serve',{});});
$('qUp').addEventListener('click',function(){add('queue',{n:derive(evs()).qNow+1});});
$('qDown').addEventListener('click',function(){var n=derive(evs()).qNow;if(n>0)add('queue',{n:n-1});});
$('logMore').addEventListener('click',function(){showAll=true;render();});
$('cmp').addEventListener('change',function(e){cmpId=e.target.value;render();if(cmpId)pull(cmpId).then(render);});

var metaTimer=null;
['store','model','label','tables','seat','places','note','who'].forEach(function(k){
  $('f-'+k).addEventListener('input',function(e){var v=e.target.value;M()[k]=k==='tables'?(v===''?'':Number(v)):v;touchMeta();save();clearTimeout(metaTimer);metaTimer=setTimeout(function(){render();sync();},900);});
});

function twoTap(btn,first,fn){
  if(btn.dataset.armed){delete btn.dataset.armed;btn.classList.remove('armed');btn.textContent=first;fn();return;}
  btn.dataset.armed='1';btn.classList.add('armed');btn.textContent='もう一度押すと確定';
  setTimeout(function(){if(btn.dataset.armed){delete btn.dataset.armed;btn.classList.remove('armed');btn.textContent=first;}},4000);
}
$('endBtn').addEventListener('click',function(){twoTap(this,'観察を終える',function(){M().endedAt=Date.now();touchMeta();save();render();sync();});});
$('reopenBtn').addEventListener('click',function(){M().endedAt=null;touchMeta();save();render();sync();});
$('newBtn').addEventListener('click',function(){if(!started())return;twoTap(this,'新しい観察を始める',function(){var prev=S.cur;newVisit();cmpId=prev;$('setupBox').open=true;showAll=false;pick={};render();window.scrollTo(0,0);});});
$('past').addEventListener('change',function(e){S.cur=e.target.value;if(cmpId===S.cur)cmpId='';save();showAll=false;pick={};render();pull(S.cur).then(render);});

$('copyBtn').addEventListener('click',function(){
  var s=stats(V()),m={};for(var k in M())if(k[0]!=='_')m[k]=M()[k];
  var summary={};ROWS.forEach(function(R){var r=R.f(s);summary[R.t]={value:r[0],detail:r[1]};});
  var out=JSON.stringify({visit:S.cur,meta:m,summary:summary,events:evs().map(strip)},null,1);
  var ta=$('copyOut'),btn=this;
  function fallback(){ta.hidden=false;ta.value=out;ta.focus();ta.select();btn.textContent='下の欄を選んでコピー';}
  try{navigator.clipboard.writeText(out).then(function(){btn.textContent='コピーしました';setTimeout(function(){btn.textContent='記録をコピー';},2500);},fallback);}catch(x){fallback();}
});

/* ---------- 画面を消さない ---------- */
var lock=null;
function wake(){try{if(navigator.wakeLock&&!lock&&document.visibilityState==='visible')navigator.wakeLock.request('screen').then(function(l){lock=l;l.addEventListener('release',function(){lock=null;});},function(){});}catch(x){}}
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&started()&&!ended())wake();});

/* ---------- 保存(この端末 → 共有の保存先) ---------- */
function strip(e){var o={};for(var k in e)if(k[0]!=='_')o[k]=e[k];return o;}
function pending(){return Object.keys(S.visits).some(function(id){var v=S.visits[id];if(!v.meta.startedAt)return false;if(v.meta._v!==v.meta._sv)return true;for(var k in v.events){var e=v.events[k];if(e._d||!e._s)return true;}return false;});}
function sync(){
  if(syncing){again=true;return;}
  if(!db||!canWrite){Object.keys(S.visits).forEach(function(id){var ev=S.visits[id].events;for(var k in ev)if(ev[k]._d&&!ev[k]._s)delete ev[k];});save();renderSync();return;}
  syncing=true;
  (async function(){
    try{
      for(var id of Object.keys(S.visits)){
        var v=S.visits[id];if(!v.meta.startedAt)continue;
        if(v.meta._v!==v.meta._sv){var ver=v.meta._v,body={};for(var k in v.meta)if(k[0]!=='_')body[k]=v.meta[k];await db.doc('visits/'+id).set(body);v.meta._sv=ver;save();}
        for(var eid of Object.keys(v.events)){
          var e=v.events[eid];if(!e)continue;var ref=db.doc('visits/'+id+'/events/'+eid);
          if(e._d){await ref.delete();delete v.events[eid];save();}
          else if(!e._s){await ref.set(strip(e));e._s=true;save();}
        }
      }
      syncErr=null;
    }catch(err){syncErr=(err&&err.code)||'error';if(syncErr==='invalid_argument')canWrite=false;}
    syncing=false;render();
    if(again){again=false;sync();}
  })();
}
async function pull(id){
  var v=S.visits[id];if(!db||!v||!v.meta.startedAt)return;
  try{var qs=await db.doc('visits/'+id).collection('events').orderBy('t').limit(1000).get();
    qs.docs.forEach(function(d){if(!v.events[d.id]){var e=d.data();var o={};for(var k in e)o[k]=e[k];o.id=d.id;o._s=true;v.events[d.id]=o;}});save();}catch(x){}
}
setInterval(function(){if(db&&canWrite&&!syncing&&pending())sync();},20000);
window.addEventListener('online',function(){if(pending())sync();});

render();
setInterval(tick,1000);

if(window.claude&&window.claude.use){
  window.claude.use('db').then(async function(x){
    db=x;
    if(!db){renderSync();return;}
    try{
      var qs=await db.collection('visits').orderBy('startedAt','desc').limit(50).get();
      qs.docs.forEach(function(d){if(!S.visits[d.id]){var m=d.data(),o={};for(var k in m)o[k]=m[k];o._v=1;o._sv=1;S.visits[d.id]={id:d.id,events:{},meta:o};}});
      await pull(S.cur);save();
    }catch(e){syncErr=e&&e.code;}
    render();sync();
  },function(){renderSync();});
}
})();
</script>

LOG353_scripts/make_site_copy.py(Artifact 版をサイトに置く形に直す)

# claude.ai の Artifact として作った観察シート(serving-robot-observation.html)を、
# pixelstomotors.com に置く形(public/sim/serving-robot-observation.html)に直す。
# 使い方: python3 make_site_copy.py <Artifact の HTML> <出力先>
#  - サイトにダークモードがないので、暗い色の指定を外して明るい色だけにする
#  - Artifact では公開時に付く <!doctype>・<head>・<body> を自分で付ける
#  - 例であることと、記録がこのブラウザの中にだけ残ることを冒頭に書き足す
import re, sys
src = open(sys.argv[1], encoding='utf-8').read()
src = re.sub(r'@media \(prefers-color-scheme: dark\)\{:root:not\(\[data-theme="light"\]\)\{.*?\}\}\n', '', src, flags=re.S)
src = re.sub(r':root\[data-theme="dark"\]\{.*?\}\n', '', src, flags=re.S)
src = src.replace('--r:8px;\n}', '--r:8px;\n  color-scheme:light;\n}')
src = src.replace('<meta name="claude:viewport-fit-sides" content="cover">\n', '')
assert 'prefers-color-scheme' not in src and 'data-theme' not in src
lead_old = '人や店内の写真は撮りません。</p>'
assert src.count(lead_old) == 1
src = src.replace(lead_old, '人や店内の写真は撮りません。</p>\n  <p class="lead" style="margin-top:8px">このページは観察シートの例です。押した記録はこのブラウザの中にだけ保存され、どこにも送られません。「記録をコピー」で記録の全部を JSON として持ち帰れます。</p>')
old_sync = "if(!db||!canWrite)t='この端末にだけ保存(記録をコピーで渡せます)';"
assert src.count(old_sync) == 1
src = src.replace(old_sync, "if(!db||!canWrite)t='ブラウザに保存';")
head = '''<!doctype html>
<html lang="ja">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<!-- サービスロボット導入シミュレーション Log#2 配膳ロボットの観察シートの例(LOG353)。元は claude.ai の Artifact として作ったページ(記録を共有の保存先へ送る版)で、LOG353 の scripts/make_site_copy.py でサイト用に直した。window.claude がないので、記録はブラウザの localStorage にだけ残る -->
'''
src = head + src
i = src.index('</style>\n') + len('</style>\n')
out = src[:i] + '<style>html,body{margin:0}img{max-width:100%}</style>\n</head>\n<body>\n' + src[i:] + '\n</body>\n</html>\n'
open(sys.argv[2], 'w', encoding='utf-8').write(out)
print(sys.argv[2], len(out.encode('utf-8')), 'bytes')

src/pages/[series].astro(シリーズのページ。「試算を試す」の欄の下に観察シートの例のリンク)

---
// シリーズのページ(/series1〜8)。上に Studio で置いていた資料(Figma・スプレッドシートなど)、下にそのシリーズの記録
import Base from '../layouts/Base.astro';
import RecordCard from '../components/RecordCard.astro';
import LoadMore from '../components/LoadMore.astro';
import { seriesCards, SERIES_PAGES } from '../lib/records';
import { media } from '../lib/media';
import staticPages from '../data/static.json';
import { SITE, SITE_NAME } from '../lib/seo';

export function getStaticPaths() {
  return Object.keys(SERIES_PAGES).map((series) => ({ params: { series } }));
}
const { series } = Astro.params;
const conf = SERIES_PAGES[series];
const blocks = (staticPages[series]?.blocks || []).slice(1); // 先頭はページの見出し
const items = await seriesCards(conf.series);
const [enName, jaName] = conf.title.split(' | ');
const description = conf.intro ? `${SITE_NAME} のシリーズ「${jaName || conf.title}」(${enName})。${conf.intro}` : `${SITE_NAME} のシリーズ「${jaName || conf.title}」(${enName})の記録一覧。ロボットとフィジカルAIを、29年のデザイナー経験から実機とシミュレーションで試した過程を、失敗も含めて公開しています。`;
const jsonLd = {
  '@context': 'https://schema.org', '@type': 'CollectionPage', name: conf.title, description, url: `${SITE}/${series}`,
  inLanguage: 'ja',
  hasPart: items.slice(0, 50).map((c) => ({ '@type': 'BlogPosting', headline: c.title, url: `${SITE}/record/${c.slug}` })),
};
---
<Base title={`${jaName || conf.title}(${enName})| ${SITE_NAME}`} description={description} path={`/${series}`} jsonLd={jsonLd}>
  <section class="s-hero"><div class="panel">
    <h1 class="sec-h s-title">{conf.title}</h1>
    {conf.intro && <p class="s-intro">{conf.intro}</p>}
    {blocks.map((b) => (
      b.type === 'text' ? (b.link ? <a class="s-label" href={b.link} target={b.link.startsWith('/') ? undefined : '_blank'} rel={b.link.startsWith('/') ? undefined : 'noopener'} set:html={b.html} /> : <p class="s-label" set:html={b.html} />)
      : b.type === 'iframe' ? <div class="s-frame" style={`height:${b.height}px`} set:html={b.html} />
      : b.type === 'embed' ? <div class="s-embed" set:html={b.html} />
      : b.type === 'img' ? (b.link ? <a class="s-img" href={b.link} target="_blank" rel="noopener"><img src={media(b.src)} alt={b.alt || conf.title} loading="lazy" /></a> : <div class="s-img"><img src={media(b.src)} alt={b.alt || conf.title} loading="lazy" /></div>)
      : null
    ))}
  </div></section>
  {conf.sim && (
    <section class="sec s-sim" data-ev-area="sim"><div class="panel">
      <h2 class="sec-h">Simulator | 試算を試す</h2>
      <p class="s-sim-title">{conf.sim.title}</p>
      <p class="s-sim-note">{conf.sim.note}</p>
      <iframe data-embed src={`${conf.sim.src}?embed=1`} title={conf.sim.title} class="s-sim-frame" loading="lazy"></iframe>
      <a class="s-sim-open" href={conf.sim.src} target="_blank" rel="noopener" data-ev="試算を全画面で開く">全画面で開く ↗</a>
      {conf.sim.obs && (
        <div class="s-obs">
          <a class="s-sim-open" href={conf.sim.obs.src} target="_blank" rel="noopener" data-ev="観察シートの例を開く">{conf.sim.obs.label}</a>
          <p class="s-sim-note">{conf.sim.obs.note}</p>
        </div>
      )}
    </div></section>
  )}
  <section class="sec"><div class="panel">
    <h2 class="sec-h">Record | 記録</h2>
    <LoadMore limit={conf.limit} total={items.length}>{items.map((c) => <RecordCard item={c} />)}</LoadMore>
  </div></section>
</Base>
<style>
  .s-hero { padding: 88px 80px 0; }
  .s-title { margin: 0; }
  .s-intro { margin: 16px 0 0; font: 400 16px/28px var(--sans); color: var(--ink-2); }
  .s-label { display: block; margin: 40px 0 16px; font: 500 16px/24px var(--sans); color: var(--ink); }
  .s-title + .s-label { margin-top: 8px; }
  a.s-label:hover { text-decoration: underline; }
  .s-frame { width: 100%; }
  .s-frame :global(iframe) { display: block; width: 100% !important; height: 100% !important; }
  .s-embed :global(.embed iframe) { display: block; width: 100%; height: 150px; border: 0; }
  .s-img img { width: 100%; height: auto; display: block; }
  .s-sim-title { margin: -12px 0 8px; font: 600 18px/28px var(--sans); color: var(--ink); }
  .s-sim-note { margin: 0 0 20px; font: 400 14px/24px var(--sans); color: var(--ink-2); }
  .s-sim-frame { display: block; width: 100%; height: 1600px; border: 0; }
  .s-sim-open { display: inline-block; margin-top: 16px; font: 500 14px/22px var(--sans); color: var(--ink); }
  .s-sim-open:hover { text-decoration: underline; }
  .s-obs { margin-top: 24px; padding-top: 20px; border-top: 1px solid var(--line, #e6e6e6); }
  .s-obs .s-sim-open { margin-top: 0; }
  .s-obs .s-sim-note { margin: 4px 0 0; }
  @media (max-width: 1023px) { .s-hero { padding: 72px 24px 0; } }
  @media (max-width: 599px) { .s-hero { padding: 64px 12px 0; } }
</style>

src/lib/records.ts の SERIES_PAGES(シリーズのページの名前・説明文・はめ込む試算・観察シートの例)

export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number; intro?: string; sim?: { src: string; title: string; note: string; obs?: { src: string; label: string; note: string } } }> = {
  series1: { series: 'HRI', title: 'Drug Delivery Robot | 薬剤搬送ロボットハコブくん', pageTitle: 'Series1 | 薬剤搬送ロボット', limit: 12,
    intro: '病院で薬を運ぶロボット「ハコブくん」を題材に、人とロボットの関わり(HRI)を設計している記録です。病院で使う画面(UI)の設計、Unity での自律移動、Fusion での形づくり、分身ロボット(アバター)の考察までを、4コママンガと一緒に残しています。' },
  series2: { series: 'R2R2R', title: 'R2R2R | デジタルツイン学習-R2R2R(Real to Render to Real)', pageTitle: 'Series2 | R2R2R', limit: 12,
    intro: 'iPhone で物を3Dスキャンし、Unity の中で動きを試してから、机の上のロボットアーム(mechArm 270)で実際に動かす記録です。ロボット工学を専門に学んでいないデザイナーでも、アームのつかむ・渡すといった動きを設計できるかを、失敗も含めて残しています。' },
  series3: { series: 'UV-C農業ロボット', title: 'UV-C Agricultural Robot | UV-C農業ロボット', pageTitle: 'Series3 | UV-C農業ロボット', limit: 20,
    intro: '湿気の多い日本の農業で、紫外線(UV-C)を当てて作物の病気を抑えるロボットが何を変えるかを考えた記録です。フィジカルAIを使った農業を、製造業のように管理する仕組みとして捉え直しています。' },
  series4: { series: 'ひとり研究所の裏側', title: 'Behind the Solo Lab | ひとり研究所の裏側', pageTitle: 'Series4 | ひとり研究所の裏側', limit: 12,
    intro: 'フィジカルAIの研究をひとりで続けるために、AIと一緒に作ってきた仕組みの記録です。サイトの移行、動画の制作、記録の整理など、研究の外側の作業をどう回しているかを残しています。' },
  series5: { series: '考察記事', title: 'Deep Dive | 考察記事', pageTitle: 'Series5 | 考察記事', limit: 12,
    intro: '実装の記録とは別に、フィジカルAIやロボットについて、なぜそうなるのか、何を意味するのかを考えた記事です。論文や企業の発表、市場の動きを読み、対話の形で考えたことを、外れた議論も含めてそのまま残しています。' },
  series6: { series: 'フィジカルAI研究室', title: 'Physical AI Lab | フィジカルAI研究室', pageTitle: 'Series6 | フィジカルAI研究室', limit: 12,
    intro: 'ロボットアームが動きを覚える環境を、自宅にひとりで作っている記録です。机の上のアームでお手本の動きを集め、ロボット用のAI(π0.5 など)に学習させ、実機で動かして確かめます。借りたGPUの種類・時間・費用も含めて、誰でも同じ手順で始められる形で残しています。' },
  series7: { series: '画像認識・物体検出', title: 'Image recognition and Object detection | 画像認識と物体検出', pageTitle: 'Series7 | 画像認識と物体検出', limit: 12,
    intro: 'ロボットのカメラが物を見つける仕組みを、下の層から学び直している記録です。画素の数字、色の見分け方、しきい値、輪郭、特徴点といった昔からの手法を、机の上のアームを撮った写真で1つずつ測って確かめ、その上で SAM 3 のような新しいモデルも試します。評価の物差し(IoU・適合率・再現率など)を先に決め、結果は数字で残しています。' },
  series8: { series: '導入シミュレーション', title: 'Service Robot Simulation | サービスロボット導入シミュレーション', pageTitle: 'Series8 | サービスロボット導入シミュレーション', limit: 12,
    intro: '配膳・清掃・搬送など、店や施設で動くサービスロボットを入れたら何が変わるかを、導入前に試算する記録です。時間・順番・数は SimPy で、ぶつかる・こぼれるといった空間と動きは Unity で扱います。前提の数字は公開事例と観察から置き、結果とコードは全量で公開しています。',
    sim: { src: '/sim/serving-robot.html', title: '配膳ロボット、何台入れたら店は変わる?',
      note: '店を選び、人数・台数・ロボットの使い方を切り替えると、ピーク時の入店待ちや料理が届くまでの時間が変わります。1つの条件を40回回した平均で、数字はすべて仮の前提による試算です(試作 v2・7,665条件)。',
      obs: { src: '/sim/serving-robot-observation.html', label: '観察シートの例 ↗', note: '試算の仮の数字を確かめるために、店でロボットと人の動きを数える記録シートです。押した記録はこのブラウザの中にだけ保存されます。' } } },
};

KIOXIA r2r2r-data/service_sim/observations/README.md(観察の記録の置き方)

# service_sim/observations — 配膳ロボットの観察記録

配膳ロボットが動いている店で、ロボットと人の動きを観察した記録(観察シートで押した記録の全量)を置く。
客席から見る人、店のスタッフ、ロボットの導入・保守の担当の誰が記録してもよい。
使い道は2つ:運用の見直しやロボットの調整の材料(止まった場所と原因、設定を変える前と後の比較)と、試算(SimPy)の仮の数字の置き換え。

- 1回の観察 = 1フォルダ:`{YYYYMMDD}_{店}_{visit_id}/`
  - `meta.json`:記録する人の立場・店・ロボットの機種・この観察の条件(例 地図を直す前)・スタッフ数・ロボット台数・見ている範囲・止まる場所の呼び名・ロボットの使い方の回答・開始と終了の時刻
  - `events.json`:押した記録の全量(時刻つき。止まった記録には場所・原因・どう動き出したかが付く)
  - `summary.json`:観察シートの「数えた結果」「止まった場所と原因」と同じ集計
- 記録の元は観察シート(claude.ai の Artifact、保存先は visits/{visit_id} と visits/{visit_id}/events)。Claude が読み出してここへ書き出す。シートに書き込めない人は「記録をコピー」で渡した JSON をここへ置く
- 測定データなので、書き出したあとは上書き・移動・削除しない。直すときは別名で足す
- 観察シートの HTML(作業ファイル)は iCloud の `デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/observation/`

LOG353_scripts/shots.py(手元のサイトを開き、ボタンを試しに押して画面写真を撮る)

import asyncio
from playwright.async_api import async_playwright
U='http://localhost:8765'
async def main():
    async with async_playwright() as p:
        b=await p.chromium.launch()
        # シリーズのページの入口
        pg=await b.new_page(viewport={'width':1280,'height':900},device_scale_factor=2)
        errs=[];pg.on('pageerror',lambda e:errs.append(str(e)))
        await pg.goto(U+'/series8.html'); await pg.wait_for_timeout(1500)
        el=await pg.query_selector('.s-obs'); await el.scroll_into_view_if_needed(); await pg.evaluate("window.scrollBy(0,200)"); await pg.wait_for_timeout(500)
        bb=await el.bounding_box()
        await pg.screenshot(path='LOG353_images/LOG353_03_series_page_link.png',clip={'x':0,'y':max(0,bb['y']-260),'width':1280,'height':bb['height']+300})
        # 観察シート(スマホ幅)
        m=await b.new_page(viewport={'width':390,'height':844},device_scale_factor=2,is_mobile=True,has_touch=True)
        m.on('pageerror',lambda e:errs.append(str(e)))
        await m.goto(U+'/sim/serving-robot-observation.html'); await m.wait_for_timeout(1200)
        await m.screenshot(path='LOG353_images/LOG353_04_sheet_top.png')
        await m.click('[data-role="guest"]')
        await m.fill('#f-store','(試しに押した記録)')
        await m.evaluate("document.getElementById('setupBox').open=false")
        await m.click('[data-a="trip_start"][data-r="1"]')
        await m.click('[data-a="table"][data-r="1"]')
        await m.click('[data-a="stop_start"][data-r="1"]')
        await m.click('[data-a="trip_start"][data-r="2"]')
        await m.wait_for_timeout(1300)
        el=await m.query_selector('#robots'); await el.scroll_into_view_if_needed()
        await m.evaluate("window.scrollBy(0,-170)")
        await m.screenshot(path='LOG353_images/LOG353_01_sheet_robot_moving_stopped.png')
        await m.click('[data-pl="ドリンクバー前"]'); await m.click('[data-ca="人とすれ違った"]')
        await m.wait_for_timeout(300)
        await m.screenshot(path='LOG353_images/LOG353_02_sheet_stop_place_cause.png')
        await m.click('[data-a="stop_end"][data-r="1"][data-by="self"]')
        await m.click('[data-a="trip_end"][data-r="1"]')
        await m.click('[data-a="stop_start"][data-r="2"]'); await m.click('[data-pl="通路"]'); await m.click('[data-ca="椅子・荷物"]')
        await m.click('[data-a="stop_end"][data-r="2"][data-by="staff"]')
        await m.click('[data-a="table"][data-r="2"]'); await m.click('[data-a="trip_end"][data-r="2"]')
        await m.click('#staffServe'); await m.click('#busStart'); await m.wait_for_timeout(1200); await m.click('[data-a="bus_end"]')
        await m.click('#arrive'); await m.click('#qUp')
        await m.click('[data-u="serve"][data-v="yes"]'); await m.click('[data-u="bus"][data-v="no"]')
        el=await m.query_selector('.stops'); await el.scroll_into_view_if_needed(); await m.evaluate("window.scrollBy(0,-60)")
        await m.screenshot(path='LOG353_images/LOG353_05_sheet_results.png')
        # PC 幅で全体
        d=await b.new_page(viewport={'width':1100,'height':900},device_scale_factor=1)
        d.on('pageerror',lambda e:errs.append(str(e)))
        await d.goto(U+'/sim/serving-robot-observation.html'); await d.wait_for_timeout(800)
        await d.screenshot(path='full_check.png',full_page=True)
        print('errors',errs)
        await b.close()
asyncio.run(main())

LOG353_scripts/fig_check.py(図解のボタンを押して、値と横のはみ出しを確かめる)

import asyncio
from playwright.async_api import async_playwright
async def main():
    async with async_playwright() as p:
        b=await p.chromium.launch(); errs=[]
        pg=await b.new_page(viewport={'width':800,'height':900}); pg.on('pageerror',lambda e:errs.append(str(e)))
        await pg.goto('file:///home/claude/LOG353/LOG353_tap_to_numbers.html'); await pg.wait_for_timeout(800)
        for i in range(4): await pg.click('#next')
        await pg.wait_for_timeout(600)
        await pg.screenshot(path='/tmp/claude-0/-home-claude/2a32a3fa-7af0-58c8-a576-a00d7c5e7afc/scratchpad/fig.png',full_page=True)
        await pg.click('#all'); await pg.wait_for_timeout(600)
        print(await pg.inner_text('#calc'))
        m=await b.new_page(viewport={'width':380,'height':800}); m.on('pageerror',lambda e:errs.append(str(e)))
        await m.goto('file:///home/claude/LOG353/LOG353_tap_to_numbers.html'); await m.click('#all')
        print('overflow',await m.evaluate('document.documentElement.scrollWidth'))
        print(errs); await b.close()
asyncio.run(main())

公開したあと、観察シートの選択肢とシリーズのページの見せ方を直した

記事を公開したあと、本人が観察シートを触って、3つの指示を出しました。

指示直したところ
条件が変わると選択肢が切り替わるようにする。選択肢にないものは書き込めるようにする「記録する人の立場」で、止まった原因と動き出し方の選択肢が切り替わるようにした。止まったときに、選択肢にない場所や原因をその場で書き足せるようにした
店とロボットの機種は選ぶ形にする店と機種を、ボタンで選べるようにした。選択肢にない店名や機種は、下の欄に書き込める
シリーズのページのリンクだけでは気づかれないシリーズのページに、スマホの幅の観察シートをそのまま置き、説明と「観察シートを開く」のボタンを並べた

止まった原因の選択肢は、記録する人の立場で切り替える

1つ目の指示は、観察の条件の欄(立場・店・機種などを入れたところ)の画面写真付きでした。

導入の条件が変わると、選択内容が切り替わるように作るといいかも(選択にないのは自由に書き込みはできる。使う人が使いやすいように修正)

画面写真では、立場に「導入・保守の担当」、機種に「ハコブくん(ファミレスVer)」が入っていました。「導入の条件」は、観察の条件の欄、中でも記録する人の立場のことだと受け取りました。立場によって、止まった原因として見えるものが違うからです。客席からは、ロボットが人とすれ違って止まったことまでしか分かりません。導入・保守の担当なら、ロボットの管理画面で、人を検知して止まったのか、自分の位置を見失ったのか、通信が切れたのかまで分かります。店のスタッフなら、行き先を入れ間違えた、呼ばれて手で止めた、という店の中の事情が分かります。

立場ごとの選択肢は次のとおりです。

立場止まった原因の選択肢どう動き出したか止まる場所の初期値
客席から(その他も同じ)人とすれ違った/人が立ち止まっていた/椅子・荷物/段差・床・マット/道に迷った/分からない自然に動いた/スタッフが動かした厨房の出口、通路、ドリンクバー前、卓の前、入口付近
店のスタッフお客さんとすれ違った/スタッフとすれ違った/椅子・荷物・ベビーカー/床の濡れ・マット/行き先の入れ間違い/呼ばれて手で止めた/分からない自然に動いた/スタッフが動かした/入れ直した厨房の出口、洗い場の前、通路、ドリンクバー前、卓の前、入口付近
導入・保守の担当人を検知して止まった/障害物を検知した/自分の位置を見失った/段差・床・マット/通路の幅が足りない/通信が切れた/電池が少ない/エラーが出た/分からない自然に動いた/スタッフが動かした/再起動・再設定厨房の出口、通路、曲がり角、ドリンクバー前、卓の前、充電場所、入口付近

決めたことが3つあります。

  • 止まる場所の呼び名は、書き換えていなければ立場の初期値に入れ替える。 店に合わせて書き換えた呼び名は、立場を変えても消さない
  • 「入れ直した」「再起動・再設定」も、人が手を出した止まり(立ち往生)として数える。 「数えた結果」の表では、立ち往生の回数のうち入れ直し・再起動が何回だったかを分けて出す。止まった場所と原因の表の列名も「スタッフが動かした」から「人が手を出した」にした
  • 選択肢にないものは、止まったときに書いて「場所に足す」か「原因に足す」を押す。 足したものはその場で選ばれ、次の観察にも引き継がれる。原因は「分からない」の前に入る
観察の条件の欄で、立場に導入・保守の担当、店にガスト、機種に BellaBot(ネコ型)を選んだところ。立場の下に、選べる原因の説明が出ている
導入・保守の担当として止まったロボットを記録しているところ。原因に検知・位置・通信・電池などが並び、書き足した「ベビーカーが通路に」が選ばれている。下に自然に動いた・スタッフが動かした・再起動・再設定のボタン

作っている途中で、止まったカードの書き足す欄が、場所と原因のボタンのすき間に押し込まれて、入力欄が狭くなっていました。書き足す欄を1行まるごと使うようにし、その下に「場所に足す」「原因に足す」を置いています。動き出し方のボタンが3つ並ぶときは、文字を少し小さくしました。「再起動・設定し直した」は3つ並びで4行に折れたので、「再起動・再設定」に縮め、店のスタッフの「行き先を入れ直した」も「入れ直した」にしています。

店とロボットの機種は、ボタンで選び、ないものだけ書く

1つ目の指示を直している途中で、続けて指示がありました。

ロボットの機種とか、店とかは選択で良さそう

店の選択肢は、LOG351 で調べた導入事例の店(ガスト、バーミヤン、ジョナサン、しゃぶ葉、焼肉きんぐ、サイゼリヤ、ブロンコビリー、ホテルのレストラン)です。機種は、配膳ロボットとしてよく見る BellaBot(ネコ型)・KettyBot・HolaBot(下げ膳)・Servi・T8(Keenon)にしました。これまでの観察で書いた店や機種(たとえば「ハコブくん(ファミレスVer)」)も選択肢に加わります。もう一度押すと選択を外せます。店舗名まで残したいときや選択肢にない機種は、ボタンの下の欄に書きます。

シリーズのページには、スマホの幅の観察シートをそのまま置いた

3つ目の指示は、シリーズのページ(/series8)の画面写真付きでした。

観察シートの例 ↗ だけど、これだと気づかないかも。プレビューがないと

試算の欄の下に、文字のリンクが1行あるだけでした。そこで、左に見出し・説明・できることの3行・「観察シートを開く ↗」のボタン、右にスマホの幅(390px)の観察シートを iframe でそのまま置きました。右の観察シートは中のボタンも押せます。画面の幅が 1023px 以下では、縦に並べて観察シートを中央に置きます。

シリーズのページの観察シートの欄。左に説明と「観察シートを開く」のボタン、右にスマホの幅の観察シートが表示されている

プレビューの観察シートで押した記録も、そのブラウザの中にだけ残ります。プレビューと「開く」で開いたページは同じサイトなので、プレビューで押した記録は、開いたページにも残っています。

公開後の変更で実行したコマンドと出力

立場で選択肢が切り替わるか、書き足しが効くか、入れ直し・再起動が立ち往生に数えられるかを、手元で作ったサイトを開いて確かめました(python3 -m http.server 8766 で dist を開いています)。表の行は「場所・原因・回数・人が手を出した回数・止まっていた平均の秒数」です。最後の行は、ページの中で起きたエラーの一覧で、空でした。

$ python3 LOG353_scripts/shots_addendum.py
places (立場なし) 厨房の出口、通路、ドリンクバー前、卓の前、入口付近
places (導入・保守の担当) 厨房の出口、通路、曲がり角、ドリンクバー前、卓の前、充電場所、入口付近
曲がり角	ベビーカーが通路に	1回	1回	0.3秒
['立ち往生(人が手を出す)\t100%', '1回(うち入れ直し・再起動 1回)・1往復中']
log ['14:21:21', 'ロボット1 戻った', '✕', '14:21:21', 'ロボット1 入れ直した・再起動した(曲がり角・ベビーカーが通路に)', '✕', '14:21:20', 'ロボット1 止まった', '✕']
places (店のスタッフ) 厨房の出口、洗い場の前、通路、ドリンクバー前、卓の前、入口付近
横幅 1280 1280
errors []

サイト用のページを作り直したとき、最初の push はサイトのリポジトリに入れていない変更が残っていたために止まりました。シリーズのページのプレビューを足して、まとめて commit しています。

$ python3 LOG353/scripts/make_site_copy.py serving-robot-observation.html p2m-site/public/sim/serving-robot-observation.html
p2m-site/public/sim/serving-robot-observation.html 50628 bytes
$ git pull -q --rebase origin main
error: cannot pull with rebase: You have unstaged changes.
error: Please commit or stash them.
$ npm run build 2>&1 | tail -1
14:13:10 [build] Complete!
$ git log --oneline -1
f62227f 観察シートの例:立場で原因と動き出し方の選択肢を切り替え、店と機種を選べるようにし、選択肢にないものを書き足せるようにする。/series8 にスマホの幅のプレビューと開くボタンを置く
$ git diff 3b0772d f62227f --stat
 public/sim/serving-robot-observation.html | 78 +++++++++++++++++++++++--------
 src/lib/records.ts                        |  6 ++-
 src/pages/[series].astro                  | 24 ++++++++--
 3 files changed, 81 insertions(+), 27 deletions(-)

公開後の変更で使ったコードの全文

観察シートの全文は、上の「この回に使ったコードの全文」に載せた版から変わっています。ここに載せているものが今の版です。サイト用のページは、同じ make_site_copy.py で作っています。

serving-robot-observation.html(観察シートの Artifact 版。立場で選択肢が切り替わる版)

<title>配膳ロボット観察シート</title>
<meta name="claude:viewport-fit-sides" content="cover">
<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;500;600;700&family=Noto+Sans+JP:wght@400;500;700&family=IBM+Plex+Mono:wght@500;600&display=swap" rel="stylesheet">
<style>
/* 片手で押す縦1列の記録用ツール。上から「よく押すもの → 結果」。選んだ状態は黒地(試算ページと同じ決まり)。赤=止まった・困った、緑=戻った・終わった */
:root{
  --bg:#f7f7f7; --panel:#ffffff; --ink:#242424; --ink-2:#555555; --ink-3:#8a8a8a; --line:#e2e2e2; --soft:#efefef; --on-ink:#ffffff;
  --pause:#9a6412; --pause-bg:#fbf0dc; --stuck:#b3372a; --stuck-bg:#fae6e3; --go:#1d6b47; --go-bg:#e3f1e9;
  --f-ui:"Inter","Noto Sans JP",system-ui,-apple-system,"Hiragino Sans",sans-serif;
  --f-num:"IBM Plex Mono",ui-monospace,"SF Mono",Menlo,monospace;
  --r:8px;
}
@media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
  --bg:#131313; --panel:#1d1d1d; --ink:#ececec; --ink-2:#b8b8b8; --ink-3:#8c8c8c; --line:#2f2f2f; --soft:#262626; --on-ink:#131313;
  --pause:#e3a64e; --pause-bg:#2e2413; --stuck:#f0806f; --stuck-bg:#331c18; --go:#62c492; --go-bg:#15291e; color-scheme:dark }}
:root[data-theme="dark"]{
  --bg:#131313; --panel:#1d1d1d; --ink:#ececec; --ink-2:#b8b8b8; --ink-3:#8c8c8c; --line:#2f2f2f; --soft:#262626; --on-ink:#131313;
  --pause:#e3a64e; --pause-bg:#2e2413; --stuck:#f0806f; --stuck-bg:#331c18; --go:#62c492; --go-bg:#15291e; color-scheme:dark }

*{box-sizing:border-box}
[hidden]{display:none!important}
body{background:var(--bg);color:var(--ink);font-family:var(--f-ui);font-size:15px;line-height:1.6;-webkit-text-size-adjust:100%}
body::before{content:"";position:fixed;inset:0 0 auto;height:env(safe-area-inset-top,0px);z-index:10;background:var(--ink)}
.wrap{max-width:680px;margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:0 48px}
h1{font-size:20px;line-height:1.35;margin:20px 0 4px;text-wrap:balance;letter-spacing:-.01em}
h2{font-size:16px;margin:0;text-wrap:balance}
p{margin:0}
.lead{color:var(--ink-2);font-size:13.5px}
.num{font-family:var(--f-num);font-variant-numeric:tabular-nums}

/* 上の帯 */
.bar{position:sticky;top:env(safe-area-inset-top,0px);z-index:5;background:var(--ink);color:var(--on-ink)}
.bar-in{max-width:680px;margin:0 auto;padding-left:max(16px,env(safe-area-inset-left,0px));padding-right:max(16px,env(safe-area-inset-right,0px));padding-block:10px;display:flex;align-items:center;gap:12px}
.bar .clock{font-family:var(--f-num);font-size:22px;font-weight:600;letter-spacing:.02em;font-variant-numeric:tabular-nums}
.bar .lbl{font-size:11px;letter-spacing:.06em;opacity:.75;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar .grow{flex:1;min-width:0}
.sync{font-size:11.5px;opacity:.85;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.bar button{background:transparent;color:var(--on-ink);border:1px solid currentColor;border-radius:6px;font:600 13px var(--f-ui);padding:8px 12px;min-height:40px;cursor:pointer;white-space:nowrap}
.bar button.armed{background:var(--on-ink);color:var(--ink)}

/* 節 */
section{margin-top:22px}
.sec-h{display:flex;align-items:baseline;justify-content:space-between;gap:8px;margin-bottom:8px}
.tag{font-size:11px;color:var(--ink-3);letter-spacing:.04em}
.box{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:14px}

/* ボタン */
button{font-family:var(--f-ui)}
.btn{display:flex;align-items:center;justify-content:center;gap:6px;min-height:56px;padding:8px 10px;border-radius:var(--r);border:1px solid var(--line);background:var(--soft);color:var(--ink);font-size:15px;font-weight:600;cursor:pointer;line-height:1.25;text-align:center;touch-action:manipulation;-webkit-tap-highlight-color:transparent}
.btn:active{transform:scale(.98)}
.btn.primary{background:var(--ink);border-color:var(--ink);color:var(--on-ink)}
.btn.pause{background:var(--pause-bg);border-color:var(--pause);color:var(--pause)}
.btn.stuck{background:var(--stuck-bg);border-color:var(--stuck);color:var(--stuck)}
.btn.go{background:var(--go-bg);border-color:var(--go);color:var(--go)}
.btn small{font-weight:500;font-size:12px;opacity:.85}
.btn:disabled{opacity:.4;cursor:default}
button:focus-visible,input:focus-visible,select:focus-visible,textarea:focus-visible{outline:2px solid var(--ink);outline-offset:2px}
.row{display:grid;gap:8px}
.r2{grid-template-columns:1fr 1fr}
.r3{grid-template-columns:1fr 1fr 1fr}
.r3 .btn{flex-direction:column;gap:2px;font-size:14px}
.link{background:none;border:0;padding:4px 0;color:var(--ink-2);text-decoration:underline;font-size:13px;cursor:pointer}

/* ロボットのカード */
.robots{display:grid;gap:10px}
.robot{background:var(--panel);border:1px solid var(--line);border-radius:var(--r);padding:12px;display:grid;gap:10px}
.robot.moving{border-color:var(--ink)}
.robot.stopped{background:var(--stuck-bg);border-color:var(--stuck)}
.robot-h{display:flex;align-items:center;gap:8px}
.robot-h b{font-size:15px}
.pill{font-size:12px;font-weight:600;padding:2px 8px;border-radius:99px;background:var(--soft);color:var(--ink-2);font-variant-numeric:tabular-nums}
.moving .pill{background:var(--ink);color:var(--on-ink)}
.stopped .pill{background:var(--stuck);color:var(--stuck-bg)}
.robot-h .meta{margin-left:auto;font-size:12px;color:var(--ink-3)}

/* 止まった場所と原因のチップ */
.chips{display:flex;flex-wrap:wrap;gap:6px;align-items:center}
.chips .cl{font-size:12px;color:var(--ink-2);width:3em;flex:none}
.chips button{min-height:36px;padding:4px 10px;border-radius:99px;border:1px solid var(--line);background:var(--panel);color:var(--ink);font-size:13px;cursor:pointer}
.chips button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:var(--on-ink)}
.chips .add{display:flex;flex-wrap:wrap;gap:6px;flex:1 1 100%;min-width:0}
.chips .add input{flex:1 1 100%;min-height:40px;padding:6px 12px;font-size:14px;border-radius:99px;background:var(--panel)}
.chips .add button{white-space:nowrap}
.chips.pick{margin-bottom:6px}
.role-note{font-size:12px;color:var(--ink-3);margin-top:2px}

/* 選択肢 */
.q{display:grid;gap:6px;padding:12px 0;border-top:1px solid var(--line)}
.q:first-child{border-top:0;padding-top:0}
.q:last-child{padding-bottom:0}
.q-t{font-size:14px;font-weight:500}
.seg{display:flex;gap:6px;flex-wrap:wrap}
.seg button{flex:1 1 0;min-width:72px;min-height:44px;border-radius:6px;border:1px solid var(--line);background:var(--soft);color:var(--ink);font-size:13.5px;font-weight:500;cursor:pointer}
.seg button[aria-pressed="true"]{background:var(--ink);border-color:var(--ink);color:var(--on-ink)}

/* 入力 */
.fields{display:grid;grid-template-columns:1fr 1fr;gap:12px}
.field{display:grid;gap:4px;min-width:0}
.field.full{grid-column:1/-1}
.field label,.field .fl{font-size:12px;color:var(--ink-2)}
input[type=text],input[type=number],textarea,select{width:100%;font:15px var(--f-ui);color:var(--ink);background:var(--bg);border:1px solid var(--line);border-radius:6px;padding:10px;min-height:44px}
textarea{min-height:64px;resize:vertical}
.step{display:flex;align-items:center;border:1px solid var(--line);border-radius:6px;background:var(--bg);overflow:hidden}
.step button{width:44px;min-height:44px;border:0;background:transparent;color:var(--ink);font-size:20px;cursor:pointer}
.step output{flex:1;text-align:center;font-family:var(--f-num);font-size:17px;font-weight:600}

/* 下げ膳のタイマー */
.timers{display:grid;gap:8px;margin-top:10px}
.timer{display:grid;grid-template-columns:1fr auto auto;gap:8px;align-items:center;background:var(--soft);border-radius:6px;padding:8px 8px 8px 12px}
.timer .t{font-family:var(--f-num);font-size:18px;font-weight:600;font-variant-numeric:tabular-nums}
.timer .w{font-size:12px;color:var(--ink-2)}
.timer .btn{min-height:44px;padding:6px 14px}
.x{width:40px;min-height:40px;border:0;background:transparent;color:var(--ink-3);font-size:18px;cursor:pointer;border-radius:6px}

/* 表 */
.tbl-wrap{overflow-x:auto;background:var(--panel);border:1px solid var(--line);border-radius:var(--r)}
table{border-collapse:collapse;width:100%;font-size:13.5px;min-width:560px}
th,td{text-align:left;vertical-align:top;padding:10px 12px;border-top:1px solid var(--line)}
thead th{border-top:0;font-size:11.5px;color:var(--ink-3);font-weight:600;letter-spacing:.04em;background:var(--soft)}
td.obs{font-weight:600}
td.obs .n{display:block;font-weight:400;font-size:12px;color:var(--ink-3)}
td.none{color:var(--ink-3);font-weight:400}
td.sim{color:var(--ink-2)}
.cmp-bar{display:flex;gap:10px;align-items:center;flex-wrap:wrap;margin-bottom:8px}
.cmp-bar label{font-size:12.5px;color:var(--ink-2)}
.cmp-bar select{width:auto;flex:1;min-width:0;max-width:100%}
.stops th{white-space:nowrap}
.stops td.c{font-family:var(--f-num);font-variant-numeric:tabular-nums;white-space:nowrap}
.stops td.hot{color:var(--stuck);font-weight:600}

/* 記録の一覧 */
.log{list-style:none;margin:0;padding:0}
.log li{display:grid;grid-template-columns:64px 1fr 40px;gap:8px;align-items:center;padding:6px 0;border-top:1px solid var(--line);font-size:13.5px}
.log li:first-child{border-top:0}
.log .tm{font-family:var(--f-num);font-size:12.5px;color:var(--ink-3);font-variant-numeric:tabular-nums}
.log .un{width:6px;height:6px;border-radius:50%;background:var(--pause);display:inline-block;margin-left:6px;vertical-align:2px}
.empty{color:var(--ink-3);font-size:13.5px}

.note{font-size:12.5px;color:var(--ink-2)}
.foot{display:grid;gap:10px}
.copyout{width:100%;min-height:120px;font:12px var(--f-num)}
.ended{background:var(--soft);border-radius:var(--r);padding:12px;display:flex;gap:12px;align-items:center;justify-content:space-between;font-size:13.5px}
details summary{cursor:pointer;font-weight:600;font-size:14px;list-style:none;display:flex;justify-content:space-between;align-items:center;min-height:32px}
details summary::-webkit-details-marker{display:none}
details summary::after{content:"開く";font-weight:500;font-size:12px;color:var(--ink-3)}
details[open] summary::after{content:"閉じる"}
details[open] summary{margin-bottom:12px}

@media (max-width:420px){.fields{grid-template-columns:1fr}.r3 .btn{font-size:14px}}
@media (prefers-reduced-motion:reduce){.btn:active{transform:none}}
</style>

<div class="bar" role="banner">
  <div class="bar-in">
    <div class="grow">
      <div class="lbl" id="barLbl">観察前</div>
      <div class="clock" id="clock">0:00:00</div>
    </div>
    <div class="grow" style="text-align:right">
      <div class="sync" id="sync" aria-live="polite">この端末に保存</div>
    </div>
    <button type="button" id="endBtn">観察を終える</button>
  </div>
</div>

<div class="wrap">
  <h1>配膳ロボット観察シート</h1>
  <p class="lead">配膳ロボットが動いている店で、ロボットと人の動きを数える記録シートです。客席から見る人、店のスタッフ、ロボットの導入や保守を担当する人の誰でも使えます。止まった場所と原因、皿を下げる時間、ロボットとスタッフが運んだ割合を数えて、運用の見直しやロボットの調整の材料にします。地図や設定を変える前と後の観察を並べて比べられます。最初のボタンを押した時刻から観察が始まります。人や店内の写真は撮りません。</p>

  <div id="endedNote" class="ended" hidden style="margin-top:14px">
    <span>この観察は終わっています。</span>
    <button type="button" class="link" id="reopenBtn">続きを記録する</button>
  </div>

  <section>
    <details class="box" id="setupBox" open>
      <summary>観察の条件</summary>
      <div class="fields">
        <div class="field full"><span class="fl">記録する人の立場</span>
          <div class="seg" id="role" role="group" aria-label="記録する人の立場">
            <button type="button" data-role="guest">客席から</button>
            <button type="button" data-role="store">店のスタッフ</button>
            <button type="button" data-role="vendor">導入・保守の担当</button>
            <button type="button" data-role="other">その他</button>
          </div><p class="role-note" id="roleNote"></p></div>
        <div class="field full"><span class="fl">店</span><div class="chips pick" id="pick-store" role="group" aria-label="店を選ぶ"></div><input type="text" id="f-store" autocomplete="off" placeholder="選択肢にない店や、店舗名を書く" aria-label="店"></div>
        <div class="field full"><span class="fl">ロボットの機種</span><div class="chips pick" id="pick-model" role="group" aria-label="ロボットの機種を選ぶ"></div><input type="text" id="f-model" autocomplete="off" placeholder="選択肢にない機種を書く" aria-label="ロボットの機種"></div>
        <div class="field full"><label for="f-label">この観察の条件</label><input type="text" id="f-label" autocomplete="off" placeholder="例 地図を直す前/ドリンクバーの前を通らない設定にした後"></div>
        <div class="field"><span class="fl">ホールのスタッフ</span>
          <div class="step"><button type="button" data-step="staff" data-d="-1" aria-label="1人減らす">−</button><output id="o-staff"></output><button type="button" data-step="staff" data-d="1" aria-label="1人増やす">+</button></div></div>
        <div class="field"><span class="fl">ロボットの台数</span>
          <div class="step"><button type="button" data-step="robots" data-d="-1" aria-label="1台減らす">−</button><output id="o-robots"></output><button type="button" data-step="robots" data-d="1" aria-label="1台増やす">+</button></div></div>
        <div class="field"><label for="f-tables">見えている卓の数</label><input type="number" inputmode="numeric" id="f-tables" min="0" placeholder="例 18"></div>
        <div class="field"><label for="f-seat">見ている範囲</label><input type="text" id="f-seat" placeholder="例 厨房の出口から奥の通路まで"></div>
        <div class="field full"><label for="f-places">止まる場所の呼び名(読点で区切る)</label><input type="text" id="f-places" placeholder="厨房の出口、通路、ドリンクバー前、卓の前、入口付近"></div>
        <div class="field full"><label for="f-note">メモ</label><textarea id="f-note" placeholder="通路の幅、段差、マット、ドリンクバーの位置など"></textarea></div>
        <div class="field"><label for="f-who">記録した人(任意)</label><input type="text" id="f-who" autocomplete="off"></div>
      </div>
    </details>
  </section>

  <section>
    <div class="sec-h"><h2>ロボットの往復と、止まった場所</h2><span class="tag">ロボット</span></div>
    <p class="note" style="margin-bottom:10px">厨房の受け渡し口を出たら「出発」、戻ったら「戻った」。卓で料理や皿を受け渡すたびに「卓に着いた」。通路で止まったら「止まった」を押し、分かれば場所と原因を選んでから、どう動き出したかを押します。選択肢は「記録する人の立場」で切り替わり、ないものはその場で書き足せます。</p>
    <div class="robots" id="robots"></div>
  </section>

  <section>
    <div class="sec-h"><h2>スタッフが運んだ回数</h2><span class="tag">人</span></div>
    <div class="box">
      <p class="note" style="margin-bottom:10px">ロボットを使わずに、スタッフが手で料理を卓まで運んだら押します。ロボットが運んだ卓の数と合わせて、運んだうちロボットの割合を出します。</p>
      <div class="row"><button type="button" class="btn" id="staffServe">スタッフが料理を運んだ <span class="num" id="staffServeN">0</span><small>回</small></button></div>
    </div>
  </section>

  <section>
    <div class="sec-h"><h2>皿を下げて卓を拭く時間</h2><span class="tag">人</span></div>
    <div class="box">
      <p class="note" style="margin-bottom:10px">食べ終わった卓に誰かが手を付けたら「始まった」、卓を拭き終えたら「終わった」。</p>
      <div class="seg" id="busWho" role="group" aria-label="誰が下げているか">
        <button type="button" data-who="staff" aria-pressed="true">スタッフだけ</button>
        <button type="button" data-who="robot" aria-pressed="false">ロボットに載せる</button>
        <button type="button" data-who="guest" aria-pressed="false">客が載せる</button>
      </div>
      <div class="row" style="margin-top:8px"><button type="button" class="btn primary" id="busStart">始まった</button></div>
      <div class="timers" id="timers"></div>
    </div>
  </section>

  <section>
    <div class="sec-h"><h2>入口</h2><span class="tag">店</span></div>
    <div class="box">
      <div class="row r2">
        <button type="button" class="btn" id="arrive">入店した <span class="num" id="arriveN">0</span><small>組</small></button>
        <div class="field"><span class="fl">入口で待っている組</span>
          <div class="step"><button type="button" id="qDown" aria-label="1組減らす">−</button><output id="qN" class="num">0</output><button type="button" id="qUp" aria-label="1組増やす">+</button></div></div>
      </div>
    </div>
  </section>

  <section>
    <div class="sec-h"><h2>ロボットの使い方</h2><span class="tag">店</span></div>
    <div class="box" id="usage"></div>
  </section>

  <section>
    <div class="sec-h"><h2>止まった場所と原因</h2><span class="tag">多い順</span></div>
    <div class="tbl-wrap"><table class="stops">
      <thead><tr><th>場所</th><th>原因</th><th>回数</th><th>人が手を出した</th><th>止まっていた時間(平均)</th></tr></thead>
      <tbody id="stops"></tbody>
    </table></div>
  </section>

  <section>
    <div class="sec-h"><h2>数えた結果</h2><span class="tag">押すたびに計算し直す</span></div>
    <div class="cmp-bar"><label for="cmp">比べる観察</label><select id="cmp"></select></div>
    <div class="tbl-wrap"><table>
      <thead><tr><th style="width:24%">項目</th><th style="width:22%" id="hCur">この観察</th><th style="width:22%" id="hCmp" hidden>比べる観察</th><th>試算で置いている値</th></tr></thead>
      <tbody id="sum"></tbody>
    </table></div>
    <p class="note" style="margin-top:8px">試算で置いている値は、サービスロボット導入シミュレーション Log#1(35卓のファミレスを想定した SimPy の試算)の仮の数字です。</p>
  </section>

  <section>
    <div class="sec-h"><h2>記録</h2><span class="tag" id="logCount"></span></div>
    <div class="box">
      <ul class="log" id="log"></ul>
      <button type="button" class="link" id="logMore" hidden>すべて表示する</button>
    </div>
    <p class="note" style="margin-top:8px">押し間違えたら ✕ で取り消せます。橙の点は、まだ送れていない記録です(電波が戻れば自動で送ります)。</p>
  </section>

  <section class="foot">
    <div class="row r2">
      <button type="button" class="btn" id="copyBtn">記録をコピー</button>
      <button type="button" class="btn" id="newBtn">新しい観察を始める</button>
    </div>
    <textarea class="copyout" id="copyOut" hidden readonly aria-label="コピー用の記録"></textarea>
    <div class="field" id="pastWrap" hidden><label for="past">開いている観察</label><select id="past"></select></div>
  </section>
</div>

<script>
(function(){
'use strict';
var LS='p2m-serving-obs-v1';
var USAGE=[
  {k:'serve',t:'ロボットが料理を運んでいる',o:[['yes','はい'],['no','いいえ'],['unk','見ていない']]},
  {k:'bus',t:'ロボットが下げた皿を洗い場へ運んでいる',o:[['yes','はい'],['no','いいえ'],['unk','見ていない']]},
  {k:'loader',t:'下げた皿をロボットに載せるのは',o:[['staff','スタッフ'],['guest','客'],['both','両方'],['unk','見ていない']]},
  {k:'collect',t:'食事中の卓を回って、空いた皿を集めている',o:[['yes','はい'],['no','いいえ'],['unk','見ていない']]},
  {k:'fragile',t:'デザートやドリンクもロボットが運んでいる',o:[['yes','はい'],['no','いいえ'],['unk','見ていない']]}
];
var WHO={staff:'スタッフだけ',robot:'ロボットに載せる',guest:'客が載せる'};
var ROLE={guest:'客席から',store:'店のスタッフ',vendor:'導入・保守の担当',other:'その他'};
// 記録する人の立場ごとの選択肢。客席からは見えることだけ、店のスタッフは店の中の事情、導入・保守の担当はロボットの側の原因まで選べる
var ROLE_OPT={
  guest:{causes:['人とすれ違った','人が立ち止まっていた','椅子・荷物','段差・床・マット','道に迷った','分からない'],
    places:'厨房の出口、通路、ドリンクバー前、卓の前、入口付近',
    resolve:[['self','自然に動いた','go'],['staff','スタッフが動かした','stuck']],
    note:'席から見えることだけを選べます。足りない場所や原因は、止まったときにその場で書き足せます。'},
  store:{causes:['お客さんとすれ違った','スタッフとすれ違った','椅子・荷物・ベビーカー','床の濡れ・マット','行き先の入れ間違い','呼ばれて手で止めた','分からない'],
    places:'厨房の出口、洗い場の前、通路、ドリンクバー前、卓の前、入口付近',
    resolve:[['self','自然に動いた','go'],['staff','スタッフが動かした','stuck'],['reset','入れ直した','stuck']],
    note:'店の中の事情(入れ間違い・手で止めた)まで選べます。足りない場所や原因は、止まったときにその場で書き足せます。'},
  vendor:{causes:['人を検知して止まった','障害物を検知した','自分の位置を見失った','段差・床・マット','通路の幅が足りない','通信が切れた','電池が少ない','エラーが出た','分からない'],
    places:'厨房の出口、通路、曲がり角、ドリンクバー前、卓の前、充電場所、入口付近',
    resolve:[['self','自然に動いた','go'],['staff','スタッフが動かした','stuck'],['reset','再起動・再設定','stuck']],
    note:'ロボットの側の原因(検知・位置・通信・電池)まで選べます。足りない場所や原因は、止まったときにその場で書き足せます。'}
};
ROLE_OPT.other=ROLE_OPT.guest;
// 店とロボットの機種の選択肢。LOG351 で調べた導入事例の店と、よく見る機種。これまでの観察で書いたものも足す
var PICK={
  store:['ガスト','バーミヤン','ジョナサン','しゃぶ葉','焼肉きんぐ','サイゼリヤ','ブロンコビリー','ホテルのレストラン'],
  model:['BellaBot(ネコ型)','KettyBot','HolaBot(下げ膳)','Servi','T8(Keenon)']
};
function pickOpts(k){var a=PICK[k].slice();Object.keys(S.visits).forEach(function(id){var v=(S.visits[id].meta||{})[k];if(v&&a.indexOf(v)<0)a.push(v);});return a;}
var RESOLVE_LABEL={self:'自然に動いた',staff:'スタッフが動かした',reset:'入れ直した・再起動した'};
function opt(m){return ROLE_OPT[(m||M()).role]||ROLE_OPT.guest;}
var DEF_PLACES=ROLE_OPT.guest.places;

var S=load(), db=null, canWrite=true, syncing=false, again=false, syncErr=null, showAll=false, busWho='staff', pick={}, cmpId='';
function load(){try{var s=JSON.parse(localStorage.getItem(LS));if(s&&s.visits)return s;}catch(e){}return {cur:null,visits:{}};}
function save(){try{localStorage.setItem(LS,JSON.stringify(S));}catch(e){}}
function uid(p){return p+Date.now().toString(36)+Math.random().toString(36).slice(2,7);}
function newVisit(){var id=uid('v'),prev=S.cur&&S.visits[S.cur]?S.visits[S.cur].meta:null;
  S.visits[id]={id:id,events:{},meta:{role:prev?prev.role:'',who:prev?prev.who:'',store:prev?prev.store:'',model:prev?prev.model:'',label:'',staff:prev?prev.staff:3,robots:prev?prev.robots:2,tables:prev?prev.tables:'',seat:prev?prev.seat:'',places:prev?prev.places:DEF_PLACES,extraCauses:prev&&prev.extraCauses?prev.extraCauses.slice():[],note:'',usage:{},startedAt:null,endedAt:null,_v:1,_sv:0}};
  S.cur=id;save();}
if(!S.cur||!S.visits[S.cur])newVisit();
function V(){return S.visits[S.cur];}
function M(){return V().meta;}
function started(){return !!M().startedAt;}
function ended(){return !!M().endedAt;}
function touchMeta(){M()._v=(M()._v||0)+1;}
function begin(){if(!started()){M().startedAt=Date.now();touchMeta();wake();}}
function evs(v){v=v||V();return Object.keys(v.events).map(function(k){return v.events[k];}).filter(function(e){return !e._d;}).sort(function(a,b){return a.t-b.t;});}
function causes(m){m=m||M();var a=opt(m).causes.slice();(m.extraCauses||[]).forEach(function(c){if(a.indexOf(c)<0)a.splice(a.length-1,0,c);});return a;}
function places(m){return String((m||M()).places||opt(m).places).split(/[、,,]/).map(function(s){return s.trim();}).filter(Boolean);}

function add(type,f){
  if(ended())return;
  begin();
  var e={id:uid('e'),t:Date.now(),type:type};
  for(var k in f)if(f[k]!==undefined&&f[k]!=='')e[k]=f[k];
  V().events[e.id]=e;save();render();sync();
  try{navigator.vibrate&&navigator.vibrate(12);}catch(x){}
}
function del(id){var e=V().events[id];if(!e)return;e._d=true;save();render();sync();}

/* ---------- 記録から値を組み立てる ---------- */
function derive(list){
  var R={},trips=[],buses=[],openBus={},arrivals=0,qMax=0,qNow=0,staffServe=0;
  function rb(n){return R[n]||(R[n]={trip:null});}
  list.forEach(function(e){
    var r;
    switch(e.type){
      case 'trip_start': r=rb(e.robot);if(r.trip)closeTrip(r,e.t,true);r.trip={robot:e.robot,purpose:e.purpose,start:e.t,tables:0,stops:[],cur:null};break;
      case 'table': r=rb(e.robot);if(r.trip)r.trip.tables++;break;
      case 'stop_start': r=rb(e.robot);if(r.trip&&!r.trip.cur)r.trip.cur={start:e.t};break;
      case 'stop_end': r=rb(e.robot);if(r.trip&&r.trip.cur){var c=r.trip.cur;c.end=e.t;c.by=e.by;c.place=e.place;c.cause=e.cause;r.trip.stops.push(c);r.trip.cur=null;}break;
      case 'trip_end': r=rb(e.robot);if(r.trip)closeTrip(r,e.t,false);break;
      case 'bus_start': openBus[e.bus]={id:e.bus,who:e.who,start:e.t,ev:e.id};break;
      case 'bus_end': if(openBus[e.bus]){openBus[e.bus].end=e.t;buses.push(openBus[e.bus]);delete openBus[e.bus];}break;
      case 'arrive': arrivals++;break;
      case 'queue': qNow=e.n;if(e.n>qMax)qMax=e.n;break;
      case 'staff_serve': staffServe++;break;
    }
  });
  function closeTrip(r,t,cut){var tr=r.trip;if(tr.cur){tr.cur.end=t;tr.cur.by=null;tr.stops.push(tr.cur);tr.cur=null;}tr.end=t;tr.cut=cut;trips.push(tr);r.trip=null;}
  return {R:R,trips:trips,buses:buses,openBus:openBus,arrivals:arrivals,qMax:qMax,qNow:qNow,staffServe:staffServe};
}

/* ---------- 表示 ---------- */
function $(id){return document.getElementById(id);}
function esc(s){return String(s).replace(/[&<>"]/g,function(c){return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c];});}
function dur(ms){var s=Math.max(0,Math.floor(ms/1000)),h=Math.floor(s/3600),m=Math.floor(s%3600/60),x=s%60;return (h?h+':'+String(m).padStart(2,'0'):m)+':'+String(x).padStart(2,'0');}
function clk(t){var d=new Date(t);return [d.getHours(),d.getMinutes(),d.getSeconds()].map(function(n){return String(n).padStart(2,'0');}).join(':');}
function mean(a){return a.length?a.reduce(function(s,x){return s+x;},0)/a.length:null;}
function pct(n,d){return d?Math.round(n/d*100)+'%':'—';}
function r1(x){return Math.round(x*10)/10;}
function vname(v){var m=v.meta,d=m.startedAt?new Date(m.startedAt):null;return (d?(d.getMonth()+1)+'/'+d.getDate()+' '+String(d.getHours()).padStart(2,'0')+':'+String(d.getMinutes()).padStart(2,'0'):'開始前')+' '+(m.store||'店名なし')+(m.label?' '+m.label:'');}

function renderSetup(){
  var m=M();
  ['store','model','label','tables','seat','places','note','who'].forEach(function(k){var el=$('f-'+k);if(document.activeElement!==el)el.value=m[k]==null?'':m[k];});
  ['store','model'].forEach(function(k){$('pick-'+k).innerHTML=pickOpts(k).map(function(x){return '<button type="button" data-pick="'+k+'" data-v="'+esc(x)+'" aria-pressed="'+(m[k]===x)+'">'+esc(x)+'</button>';}).join('');});
  $('o-staff').textContent=m.staff+'人';$('o-robots').textContent=m.robots+'台';
  [].forEach.call($('role').children,function(b){b.setAttribute('aria-pressed',String(b.dataset.role===m.role));});
  $('roleNote').textContent=opt(m).note+(m.role?'':'(立場を選ぶと、原因と動き出し方の選択肢が切り替わります)');
}

function renderRobots(d){
  var html='',n=M().robots,pl=places();
  for(var i=1;i<=n;i++){
    var r=d.R[i]||{trip:null},tr=r.trip,done=d.trips.filter(function(t){return t.robot===i&&!t.cut;}).length;
    var st=!tr?'idle':(tr.cur?'stopped':'moving');
    html+='<div class="robot '+st+'"><div class="robot-h"><b>ロボット'+i+'</b>';
    if(st==='idle')html+='<span class="pill">待機</span>';
    else if(st==='moving')html+='<span class="pill">'+(tr.purpose==='bus'?'下げ膳':'配膳')+' <span data-since="'+tr.start+'">'+dur(Date.now()-tr.start)+'</span></span>';
    else html+='<span class="pill">止まっている <span data-since="'+tr.cur.start+'">'+dur(Date.now()-tr.cur.start)+'</span></span>';
    html+='<span class="meta">往復 <span class="num">'+done+'</span></span></div>';
    if(st==='idle'){
      html+='<div class="row r2"><button type="button" class="btn primary" data-a="trip_start" data-r="'+i+'" data-p="serve">配膳で出発</button><button type="button" class="btn" data-a="trip_start" data-r="'+i+'" data-p="bus">下げ膳で出発</button></div>';
    }else if(st==='moving'){
      html+='<div class="row r3"><button type="button" class="btn" data-a="table" data-r="'+i+'">卓に着いた<small class="num">この往復 '+tr.tables+'卓</small></button><button type="button" class="btn stuck" data-a="stop_start" data-r="'+i+'">止まった<small class="num">'+(tr.stops.length?'この往復 '+tr.stops.length+'回':'通路で')+'</small></button><button type="button" class="btn go" data-a="trip_end" data-r="'+i+'">戻った</button></div>';
    }else{
      var p=pick[i]||{};
      html+='<div class="chips" role="group" aria-label="止まった場所"><span class="cl">場所</span>'+pl.concat(p.place&&pl.indexOf(p.place)<0?[p.place]:[]).map(function(x){return '<button type="button" data-pl="'+esc(x)+'" data-r="'+i+'" aria-pressed="'+(p.place===x)+'">'+esc(x)+'</button>';}).join('')+'</div>';
      html+='<div class="chips" role="group" aria-label="止まった原因"><span class="cl">原因</span>'+causes().map(function(x){return '<button type="button" data-ca="'+esc(x)+'" data-r="'+i+'" aria-pressed="'+(p.cause===x)+'">'+esc(x)+'</button>';}).join('')+'</div>';
      html+='<div class="chips"><span class="add"><input type="text" id="add-'+i+'" placeholder="選択肢にない場所・原因を書く" aria-label="選択肢にない場所や原因"><button type="button" data-add="place" data-r="'+i+'">場所に足す</button><button type="button" data-add="cause" data-r="'+i+'">原因に足す</button></span></div>';
      var rs=opt().resolve;
      html+='<div class="row '+(rs.length>2?'r3':'r2')+'">'+rs.map(function(o){return '<button type="button" class="btn '+o[2]+'" data-a="stop_end" data-r="'+i+'" data-by="'+o[0]+'">'+o[1]+'</button>';}).join('')+'</div>';
    }
    html+='</div>';
  }
  $('robots').innerHTML=html;
}

function renderBus(d){
  [].forEach.call($('busWho').children,function(b){b.setAttribute('aria-pressed',String(b.dataset.who===busWho));});
  var html='';
  Object.keys(d.openBus).forEach(function(k){var b=d.openBus[k];
    html+='<div class="timer"><div><div class="t" data-since="'+b.start+'">'+dur(Date.now()-b.start)+'</div><div class="w">'+esc(WHO[b.who]||'')+'</div></div><button type="button" class="btn go" data-a="bus_end" data-b="'+k+'">終わった</button><button type="button" class="x" data-del="'+b.ev+'" aria-label="取り消す">✕</button></div>';});
  $('timers').innerHTML=html;
}

function renderUsage(){
  var u=M().usage||{},html='';
  USAGE.forEach(function(q){
    html+='<div class="q"><div class="q-t">'+q.t+'</div><div class="seg" role="group" aria-label="'+q.t+'">';
    q.o.forEach(function(o){html+='<button type="button" data-u="'+q.k+'" data-v="'+o[0]+'" aria-pressed="'+(u[q.k]===o[0])+'">'+o[1]+'</button>';});
    html+='</div></div>';
  });
  $('usage').innerHTML=html;
}

function usageText(m){
  var u=m.usage||{},L={yes:'○',no:'×',unk:'?'},parts=[];
  if(u.serve)parts.push('料理'+L[u.serve]);
  if(u.bus)parts.push('下げ膳'+L[u.bus]);
  if(u.collect)parts.push('食事中に回収'+L[u.collect]);
  if(u.fragile)parts.push('デザート・ドリンク'+L[u.fragile]);
  if(u.loader&&u.loader!=='unk')parts.push('載せるのは'+{staff:'スタッフ',guest:'客',both:'スタッフと客'}[u.loader]);
  return parts.join('・');
}

function stats(v){
  var d=derive(evs(v)),m=v.meta;
  var full=d.trips.filter(function(t){return !t.cut;});
  var serve=full.filter(function(t){return t.purpose==='serve';}),bus=full.filter(function(t){return t.purpose==='bus';});
  var selfStops=[],staffStops=0,resetStops=0,tripsPaused=0,tripsStuck=0,allStops=[];
  full.forEach(function(t){var p=false,s=false;t.stops.forEach(function(x){allStops.push(x);if(x.by==='self'){p=true;selfStops.push((x.end-x.start)/1000);}if(x.by==='staff'||x.by==='reset'){s=true;staffStops++;if(x.by==='reset')resetStops++;}});if(p)tripsPaused++;if(s)tripsStuck++;});
  var robotTables=serve.reduce(function(a,t){return a+t.tables;},0);
  var span=m.startedAt?((m.endedAt||Date.now())-m.startedAt)/3600000:0;
  return {
    d:d,full:full.length,allStops:allStops,
    serveSec:mean(serve.map(function(t){return (t.end-t.start)/1000;})),serveN:serve.length,
    busSec:mean(bus.map(function(t){return (t.end-t.start)/1000;})),busN:bus.length,
    tablesPer:mean(serve.map(function(t){return t.tables;})),multi:serve.filter(function(t){return t.tables>=2;}).length,
    tripsPaused:tripsPaused,pauseSec:mean(selfStops),pauseN:selfStops.length,
    tripsStuck:tripsStuck,staffStops:staffStops,resetStops:resetStops,stopsPerHour:span>=0.25?allStops.length/span:null,
    robotTables:robotTables,staffServe:d.staffServe,
    busTimes:d.buses,busMean:mean(d.buses.map(function(b){return (b.end-b.start)/1000;})),
    perHour:span>=0.25?d.arrivals/span:null,arrivals:d.arrivals,qMax:d.qMax,qNow:d.qNow,spanMin:Math.round(span*60),
    usage:usageText(m)
  };
}

function whoBreak(bs){var c={};bs.forEach(function(b){c[b.who]=(c[b.who]||0)+1;});var k=Object.keys(c);return k.length>1||(k[0]&&k[0]!=='staff')?'('+k.map(function(w){return WHO[w]+' '+c[w];}).join('・')+')':'';}

var ROWS=[
  {t:'ロボットの使い方',sim:'料理を運ぶだけ(皿を下げるのは人)',f:function(s){return [s.usage||null,''];}},
  {t:'配膳の1往復の時間',sim:'0.5m/秒で走る(厨房から卓まで6〜22m)',f:function(s){return [s.serveSec==null?null:Math.round(s.serveSec)+'秒',s.serveN+'往復の平均'];}},
  {t:'下げ膳の1往復の時間',sim:'(ロボットは下げ膳をしない)',f:function(s){return [s.busSec==null?null:Math.round(s.busSec)+'秒',s.busN+'往復の平均'];}},
  {t:'1往復で回る卓',sim:'1卓(「最大3卓」に切り替え可)',f:function(s){return [s.tablesPer==null?null:r1(s.tablesPer)+'卓','2卓以上は'+s.multi+'/'+s.serveN+'往復'];}},
  {t:'料理を運んだうちロボットの割合',sim:'(ロボットとスタッフのうち先に空いた方が運ぶ)',f:function(s){var n=s.robotTables+s.staffServe;return [n?pct(s.robotTables,n):null,'ロボット'+s.robotTables+'卓・スタッフ'+s.staffServe+'回'];}},
  {t:'一時停止(自然に動き出す)',sim:'往復の30%で、3〜10秒',f:function(s){return [s.full?pct(s.tripsPaused,s.full):null,s.full?(s.pauseSec==null?'止まった時間はまだない':'平均'+r1(s.pauseSec)+'秒('+s.pauseN+'回)')+'・'+s.full+'往復中':''];}},
  {t:'立ち往生(人が手を出す)',sim:'往復の1%',f:function(s){return [s.full?pct(s.tripsStuck,s.full):null,s.full?s.staffStops+'回'+(s.resetStops?'(うち入れ直し・再起動 '+s.resetStops+'回)':'')+'・'+s.full+'往復中':''];}},
  {t:'1時間あたり止まった回数',sim:'—',f:function(s){return [s.stopsPerHour==null?(s.allStops.length?s.allStops.length+'回':null):r1(s.stopsPerHour)+'回/時',s.allStops.length+'回・'+s.spanMin+'分間'];}},
  {t:'皿を下げて卓を拭く',sim:'90秒+歩く時間',f:function(s){return [s.busMean==null?null:Math.round(s.busMean)+'秒',s.busTimes.length+'卓の平均'+whoBreak(s.busTimes)];}},
  {t:'入店のペース',sim:'3段階(例 1時間に35組)',f:function(s){return [s.perHour==null?(s.arrivals?s.arrivals+'組':null):Math.round(s.perHour)+'組/時',s.arrivals+'組・'+s.spanMin+'分間'];}},
  {t:'入口の待ち',sim:'試算の結果(入店待ち)と比べる',f:function(s){return [s.qMax||s.arrivals?'最大'+s.qMax+'組':null,'今 '+s.qNow+'組'];}}
];
function cell(r){return r[0]==null?'<td class="obs none">まだ記録がない</td>':'<td class="obs">'+esc(r[0])+(r[1]?'<span class="n">'+esc(r[1])+'</span>':'')+'</td>';}
function renderSum(s){
  var cv=cmpId&&S.visits[cmpId]?S.visits[cmpId]:null,cs=cv?stats(cv):null;
  $('hCmp').hidden=!cs;$('hCmp').textContent=cs?vname(cv):'';
  $('sum').innerHTML=ROWS.map(function(R){return '<tr><th scope="row">'+R.t+'</th>'+cell(R.f(s))+(cs?cell(R.f(cs)):'')+'<td class="sim">'+R.sim+'</td></tr>';}).join('');
  var ids=Object.keys(S.visits).filter(function(k){return k!==S.cur&&S.visits[k].meta.startedAt;}).sort(function(a,b){return S.visits[b].meta.startedAt-S.visits[a].meta.startedAt;});
  $('cmp').innerHTML='<option value="">比べない</option>'+ids.map(function(k){return '<option value="'+k+'"'+(k===cmpId?' selected':'')+'>'+esc(vname(S.visits[k]))+'</option>';}).join('');
  $('cmp').disabled=!ids.length;
}

function renderStops(s){
  var g={};
  s.allStops.forEach(function(x){var k=(x.place||'場所なし')+'\u0000'+(x.cause||'原因なし');var o=g[k]||(g[k]={place:x.place||'場所なし',cause:x.cause||'原因なし',n:0,staff:0,secs:[]});o.n++;if(x.by==='staff'||x.by==='reset')o.staff++;o.secs.push((x.end-x.start)/1000);});
  var rows=Object.keys(g).map(function(k){return g[k];}).sort(function(a,b){return b.n-a.n||b.staff-a.staff;});
  $('stops').innerHTML=rows.length?rows.map(function(o,i){return '<tr><td>'+esc(o.place)+'</td><td>'+esc(o.cause)+'</td><td class="c'+(i===0?' hot':'')+'">'+o.n+'回</td><td class="c">'+o.staff+'回</td><td class="c">'+r1(mean(o.secs))+'秒</td></tr>';}).join(''):'<tr><td colspan="5" class="none">まだ止まった記録はありません。</td></tr>';
}

function label(e){
  var r=e.robot?'ロボット'+e.robot+' ':'';
  switch(e.type){
    case 'trip_start':return r+(e.purpose==='bus'?'下げ膳で出発':'配膳で出発');
    case 'table':return r+'卓に着いた';
    case 'stop_start':return r+'止まった';
    case 'stop_end':return r+(RESOLVE_LABEL[e.by]||'動いた')+(e.place||e.cause?'('+[e.place,e.cause].filter(Boolean).join('・')+')':'');
    case 'trip_end':return r+'戻った';
    case 'bus_start':return '下げ膳・卓拭き 始まった('+(WHO[e.who]||'')+')';
    case 'bus_end':return '下げ膳・卓拭き 終わった';
    case 'arrive':return '入店した';
    case 'queue':return '入口の待ち '+e.n+'組';
    case 'staff_serve':return 'スタッフが料理を運んだ';
  }
  return e.type;
}
function renderLog(list){
  var rev=list.slice().reverse(),shown=showAll?rev:rev.slice(0,30);
  $('logCount').textContent=list.length?list.length+'件':'';
  $('log').innerHTML=shown.length?shown.map(function(e){return '<li><span class="tm">'+clk(e.t)+'</span><span>'+esc(label(e))+(e._s?'':'<span class="un" title="未送信"></span>')+'</span><button type="button" class="x" data-del="'+e.id+'" aria-label="取り消す">✕</button></li>';}).join(''):'<li class="empty" style="display:block">まだ記録はありません。ロボットが厨房を出たら「配膳で出発」を押してください。</li>';
  $('logMore').hidden=showAll||rev.length<=30;
}
function renderPast(){
  var ids=Object.keys(S.visits).filter(function(k){return S.visits[k].meta.startedAt||k===S.cur;});
  $('pastWrap').hidden=ids.length<2;
  ids.sort(function(a,b){return (S.visits[b].meta.startedAt||9e15)-(S.visits[a].meta.startedAt||9e15);});
  $('past').innerHTML=ids.map(function(k){return '<option value="'+k+'"'+(k===S.cur?' selected':'')+'>'+esc(vname(S.visits[k]))+'</option>';}).join('');
}
function renderSync(){
  var v=V(),pend=evs(v).filter(function(e){return !e._s;}).length+((v.meta.startedAt&&v.meta._v!==v.meta._sv)?1:0);
  var t;
  if(!db||!canWrite)t='この端末にだけ保存(記録をコピーで渡せます)';
  else if(syncErr&&pend)t='未送信 '+pend+'件(再送します)';
  else if(pend)t='送信中…';
  else t='保存済み';
  $('sync').textContent=t;
}
function renderBar(){
  var m=M();
  $('barLbl').textContent=!started()?'観察前':(ended()?'観察を終えた':'観察中 '+clk(m.startedAt)+'から');
  $('clock').textContent=started()?dur((m.endedAt||Date.now())-m.startedAt):'0:00:00';
  $('endBtn').hidden=!started()||ended();
  $('endedNote').hidden=!ended();
  document.querySelectorAll('.wrap .btn,#busWho button').forEach(function(b){if(b.id!=='copyBtn'&&b.id!=='newBtn')b.disabled=ended();});
}
function render(){
  var list=evs(),s=stats(V()),d=s.d;
  renderSetup();renderRobots(d);renderBus(d);renderUsage();renderStops(s);renderSum(s);renderLog(list);renderPast();renderSync();renderBar();
  $('arriveN').textContent=d.arrivals;$('qN').textContent=d.qNow;$('staffServeN').textContent=d.staffServe;
}
function tick(){
  document.querySelectorAll('[data-since]').forEach(function(el){el.textContent=dur(Date.now()-Number(el.dataset.since));});
  if(started()&&!ended())$('clock').textContent=dur(Date.now()-M().startedAt);
}

/* ---------- 操作 ---------- */
document.addEventListener('click',function(ev){
  var b=ev.target.closest('button');if(!b)return;
  var a=b.dataset.a;
  if(a==='trip_start')return add('trip_start',{robot:+b.dataset.r,purpose:b.dataset.p});
  if(a==='stop_start'){pick[+b.dataset.r]={};return add('stop_start',{robot:+b.dataset.r});}
  if(a==='table'||a==='trip_end')return add(a,{robot:+b.dataset.r});
  if(a==='stop_end'){var p=pick[+b.dataset.r]||{};delete pick[+b.dataset.r];return add('stop_end',{robot:+b.dataset.r,by:b.dataset.by,place:p.place,cause:p.cause});}
  if(a==='bus_end')return add('bus_end',{bus:b.dataset.b});
  if(b.dataset.del)return del(b.dataset.del);
  if(b.dataset.pl||b.dataset.ca){var r=+b.dataset.r,pk=pick[r]||(pick[r]={}),key=b.dataset.pl?'place':'cause',val=b.dataset.pl||b.dataset.ca;pk[key]=pk[key]===val?undefined:val;renderRobots(derive(evs()));return;}
  if(b.dataset.u){if(ended())return;var u=M().usage||(M().usage={});if(u[b.dataset.u]===b.dataset.v)delete u[b.dataset.u];else u[b.dataset.u]=b.dataset.v;begin();touchMeta();save();render();sync();return;}
  if(b.dataset.role){var m0=M(),was=opt(m0).places;m0.role=m0.role===b.dataset.role?'':b.dataset.role;if(!m0.places||m0.places===was||m0.places===DEF_PLACES)m0.places=opt(m0).places;touchMeta();save();render();sync();return;}
  if(b.dataset.pick){var mp=M(),kp=b.dataset.pick;mp[kp]=mp[kp]===b.dataset.v?'':b.dataset.v;touchMeta();save();render();sync();return;}
  if(b.dataset.add){var ri=+b.dataset.r,inp=$('add-'+ri),v=(inp&&inp.value||'').trim();if(!v)return;var m1=M(),pk2=pick[ri]||(pick[ri]={});
    if(b.dataset.add==='place'){if(places().indexOf(v)<0)m1.places=places().concat([v]).join('、');pk2.place=v;}
    else{m1.extraCauses=m1.extraCauses||[];if(causes().indexOf(v)<0)m1.extraCauses.push(v);pk2.cause=v;}
    touchMeta();save();render();sync();return;}
  if(b.dataset.who){busWho=b.dataset.who;renderBus(derive(evs()));return;}
  if(b.dataset.step){var k=b.dataset.step,m=M(),hi=k==='robots'?6:12;m[k]=Math.min(hi,Math.max(1,m[k]+Number(b.dataset.d)));touchMeta();save();render();sync();return;}
});
$('busStart').addEventListener('click',function(){add('bus_start',{bus:uid('b'),who:busWho});});
$('arrive').addEventListener('click',function(){add('arrive',{});});
$('staffServe').addEventListener('click',function(){add('staff_serve',{});});
$('qUp').addEventListener('click',function(){add('queue',{n:derive(evs()).qNow+1});});
$('qDown').addEventListener('click',function(){var n=derive(evs()).qNow;if(n>0)add('queue',{n:n-1});});
$('logMore').addEventListener('click',function(){showAll=true;render();});
$('cmp').addEventListener('change',function(e){cmpId=e.target.value;render();if(cmpId)pull(cmpId).then(render);});

var metaTimer=null;
['store','model','label','tables','seat','places','note','who'].forEach(function(k){
  $('f-'+k).addEventListener('input',function(e){var v=e.target.value;M()[k]=k==='tables'?(v===''?'':Number(v)):v;touchMeta();save();clearTimeout(metaTimer);metaTimer=setTimeout(function(){render();sync();},900);});
});

function twoTap(btn,first,fn){
  if(btn.dataset.armed){delete btn.dataset.armed;btn.classList.remove('armed');btn.textContent=first;fn();return;}
  btn.dataset.armed='1';btn.classList.add('armed');btn.textContent='もう一度押すと確定';
  setTimeout(function(){if(btn.dataset.armed){delete btn.dataset.armed;btn.classList.remove('armed');btn.textContent=first;}},4000);
}
$('endBtn').addEventListener('click',function(){twoTap(this,'観察を終える',function(){M().endedAt=Date.now();touchMeta();save();render();sync();});});
$('reopenBtn').addEventListener('click',function(){M().endedAt=null;touchMeta();save();render();sync();});
$('newBtn').addEventListener('click',function(){if(!started())return;twoTap(this,'新しい観察を始める',function(){var prev=S.cur;newVisit();cmpId=prev;$('setupBox').open=true;showAll=false;pick={};render();window.scrollTo(0,0);});});
$('past').addEventListener('change',function(e){S.cur=e.target.value;if(cmpId===S.cur)cmpId='';save();showAll=false;pick={};render();pull(S.cur).then(render);});

$('copyBtn').addEventListener('click',function(){
  var s=stats(V()),m={};for(var k in M())if(k[0]!=='_')m[k]=M()[k];
  var summary={};ROWS.forEach(function(R){var r=R.f(s);summary[R.t]={value:r[0],detail:r[1]};});
  var out=JSON.stringify({visit:S.cur,meta:m,summary:summary,events:evs().map(strip)},null,1);
  var ta=$('copyOut'),btn=this;
  function fallback(){ta.hidden=false;ta.value=out;ta.focus();ta.select();btn.textContent='下の欄を選んでコピー';}
  try{navigator.clipboard.writeText(out).then(function(){btn.textContent='コピーしました';setTimeout(function(){btn.textContent='記録をコピー';},2500);},fallback);}catch(x){fallback();}
});

/* ---------- 画面を消さない ---------- */
var lock=null;
function wake(){try{if(navigator.wakeLock&&!lock&&document.visibilityState==='visible')navigator.wakeLock.request('screen').then(function(l){lock=l;l.addEventListener('release',function(){lock=null;});},function(){});}catch(x){}}
document.addEventListener('visibilitychange',function(){if(document.visibilityState==='visible'&&started()&&!ended())wake();});

/* ---------- 保存(この端末 → 共有の保存先) ---------- */
function strip(e){var o={};for(var k in e)if(k[0]!=='_')o[k]=e[k];return o;}
function pending(){return Object.keys(S.visits).some(function(id){var v=S.visits[id];if(!v.meta.startedAt)return false;if(v.meta._v!==v.meta._sv)return true;for(var k in v.events){var e=v.events[k];if(e._d||!e._s)return true;}return false;});}
function sync(){
  if(syncing){again=true;return;}
  if(!db||!canWrite){Object.keys(S.visits).forEach(function(id){var ev=S.visits[id].events;for(var k in ev)if(ev[k]._d&&!ev[k]._s)delete ev[k];});save();renderSync();return;}
  syncing=true;
  (async function(){
    try{
      for(var id of Object.keys(S.visits)){
        var v=S.visits[id];if(!v.meta.startedAt)continue;
        if(v.meta._v!==v.meta._sv){var ver=v.meta._v,body={};for(var k in v.meta)if(k[0]!=='_')body[k]=v.meta[k];await db.doc('visits/'+id).set(body);v.meta._sv=ver;save();}
        for(var eid of Object.keys(v.events)){
          var e=v.events[eid];if(!e)continue;var ref=db.doc('visits/'+id+'/events/'+eid);
          if(e._d){await ref.delete();delete v.events[eid];save();}
          else if(!e._s){await ref.set(strip(e));e._s=true;save();}
        }
      }
      syncErr=null;
    }catch(err){syncErr=(err&&err.code)||'error';if(syncErr==='invalid_argument')canWrite=false;}
    syncing=false;render();
    if(again){again=false;sync();}
  })();
}
async function pull(id){
  var v=S.visits[id];if(!db||!v||!v.meta.startedAt)return;
  try{var qs=await db.doc('visits/'+id).collection('events').orderBy('t').limit(1000).get();
    qs.docs.forEach(function(d){if(!v.events[d.id]){var e=d.data();var o={};for(var k in e)o[k]=e[k];o.id=d.id;o._s=true;v.events[d.id]=o;}});save();}catch(x){}
}
setInterval(function(){if(db&&canWrite&&!syncing&&pending())sync();},20000);
window.addEventListener('online',function(){if(pending())sync();});

render();
setInterval(tick,1000);

if(window.claude&&window.claude.use){
  window.claude.use('db').then(async function(x){
    db=x;
    if(!db){renderSync();return;}
    try{
      var qs=await db.collection('visits').orderBy('startedAt','desc').limit(50).get();
      qs.docs.forEach(function(d){if(!S.visits[d.id]){var m=d.data(),o={};for(var k in m)o[k]=m[k];o._v=1;o._sv=1;S.visits[d.id]={id:d.id,events:{},meta:o};}});
      await pull(S.cur);save();
    }catch(e){syncErr=e&&e.code;}
    render();sync();
  },function(){renderSync();});
}
})();
</script>

src/pages/[series].astro(シリーズのページ。観察シートのプレビューと開くボタン)

---
// シリーズのページ(/series1〜8)。上に Studio で置いていた資料(Figma・スプレッドシートなど)、下にそのシリーズの記録
import Base from '../layouts/Base.astro';
import RecordCard from '../components/RecordCard.astro';
import LoadMore from '../components/LoadMore.astro';
import { seriesCards, SERIES_PAGES } from '../lib/records';
import { media } from '../lib/media';
import staticPages from '../data/static.json';
import { SITE, SITE_NAME } from '../lib/seo';

export function getStaticPaths() {
  return Object.keys(SERIES_PAGES).map((series) => ({ params: { series } }));
}
const { series } = Astro.params;
const conf = SERIES_PAGES[series];
const blocks = (staticPages[series]?.blocks || []).slice(1); // 先頭はページの見出し
const items = await seriesCards(conf.series);
const [enName, jaName] = conf.title.split(' | ');
const description = conf.intro ? `${SITE_NAME} のシリーズ「${jaName || conf.title}」(${enName})。${conf.intro}` : `${SITE_NAME} のシリーズ「${jaName || conf.title}」(${enName})の記録一覧。ロボットとフィジカルAIを、29年のデザイナー経験から実機とシミュレーションで試した過程を、失敗も含めて公開しています。`;
const jsonLd = {
  '@context': 'https://schema.org', '@type': 'CollectionPage', name: conf.title, description, url: `${SITE}/${series}`,
  inLanguage: 'ja',
  hasPart: items.slice(0, 50).map((c) => ({ '@type': 'BlogPosting', headline: c.title, url: `${SITE}/record/${c.slug}` })),
};
---
<Base title={`${jaName || conf.title}(${enName})| ${SITE_NAME}`} description={description} path={`/${series}`} jsonLd={jsonLd}>
  <section class="s-hero"><div class="panel">
    <h1 class="sec-h s-title">{conf.title}</h1>
    {conf.intro && <p class="s-intro">{conf.intro}</p>}
    {blocks.map((b) => (
      b.type === 'text' ? (b.link ? <a class="s-label" href={b.link} target={b.link.startsWith('/') ? undefined : '_blank'} rel={b.link.startsWith('/') ? undefined : 'noopener'} set:html={b.html} /> : <p class="s-label" set:html={b.html} />)
      : b.type === 'iframe' ? <div class="s-frame" style={`height:${b.height}px`} set:html={b.html} />
      : b.type === 'embed' ? <div class="s-embed" set:html={b.html} />
      : b.type === 'img' ? (b.link ? <a class="s-img" href={b.link} target="_blank" rel="noopener"><img src={media(b.src)} alt={b.alt || conf.title} loading="lazy" /></a> : <div class="s-img"><img src={media(b.src)} alt={b.alt || conf.title} loading="lazy" /></div>)
      : null
    ))}
  </div></section>
  {conf.sim && (
    <section class="sec s-sim" data-ev-area="sim"><div class="panel">
      <h2 class="sec-h">Simulator | 試算を試す</h2>
      <p class="s-sim-title">{conf.sim.title}</p>
      <p class="s-sim-note">{conf.sim.note}</p>
      <iframe data-embed src={`${conf.sim.src}?embed=1`} title={conf.sim.title} class="s-sim-frame" loading="lazy"></iframe>
      <a class="s-sim-open" href={conf.sim.src} target="_blank" rel="noopener" data-ev="試算を全画面で開く">全画面で開く ↗</a>
      {conf.sim.obs && (
        <div class="s-obs">
          <div class="s-obs-text">
            <h3 class="s-obs-h">{conf.sim.obs.title}</h3>
            <p class="s-sim-note">{conf.sim.obs.note}</p>
            <ul class="s-obs-list">{conf.sim.obs.points.map((t) => <li>{t}</li>)}</ul>
            <a class="s-obs-btn" href={conf.sim.obs.src} target="_blank" rel="noopener" data-ev="観察シートの例を開く">{conf.sim.obs.label}</a>
          </div>
          <div class="s-obs-phone">
            <iframe src={conf.sim.obs.src} title={conf.sim.obs.title} loading="lazy"></iframe>
            <p class="s-obs-cap">スマホの幅で表示しています。中のボタンも押せます</p>
          </div>
        </div>
      )}
    </div></section>
  )}
  <section class="sec"><div class="panel">
    <h2 class="sec-h">Record | 記録</h2>
    <LoadMore limit={conf.limit} total={items.length}>{items.map((c) => <RecordCard item={c} />)}</LoadMore>
  </div></section>
</Base>
<style>
  .s-hero { padding: 88px 80px 0; }
  .s-title { margin: 0; }
  .s-intro { margin: 16px 0 0; font: 400 16px/28px var(--sans); color: var(--ink-2); }
  .s-label { display: block; margin: 40px 0 16px; font: 500 16px/24px var(--sans); color: var(--ink); }
  .s-title + .s-label { margin-top: 8px; }
  a.s-label:hover { text-decoration: underline; }
  .s-frame { width: 100%; }
  .s-frame :global(iframe) { display: block; width: 100% !important; height: 100% !important; }
  .s-embed :global(.embed iframe) { display: block; width: 100%; height: 150px; border: 0; }
  .s-img img { width: 100%; height: auto; display: block; }
  .s-sim-title { margin: -12px 0 8px; font: 600 18px/28px var(--sans); color: var(--ink); }
  .s-sim-note { margin: 0 0 20px; font: 400 14px/24px var(--sans); color: var(--ink-2); }
  .s-sim-frame { display: block; width: 100%; height: 1600px; border: 0; }
  .s-sim-open { display: inline-block; margin-top: 16px; font: 500 14px/22px var(--sans); color: var(--ink); }
  .s-sim-open:hover { text-decoration: underline; }
  .s-obs { margin-top: 40px; padding-top: 40px; border-top: 1px solid var(--line, #e6e6e6); display: grid; grid-template-columns: minmax(0, 1fr) 390px; gap: 48px; align-items: start; }
  .s-obs-h { margin: 0 0 8px; font: 600 18px/28px var(--sans); color: var(--ink); }
  .s-obs .s-sim-note { margin: 0 0 16px; }
  .s-obs-list { margin: 0 0 24px; padding-left: 1.2em; font: 400 14px/26px var(--sans); color: var(--ink-2); }
  .s-obs-btn { display: inline-flex; align-items: center; gap: 6px; padding: 12px 20px; border-radius: 8px; background: var(--ink, #242424); color: #fff; font: 600 14px/20px var(--sans); }
  .s-obs-btn:hover { opacity: .85; }
  .s-obs-phone iframe { display: block; width: 390px; max-width: 100%; height: 720px; border: 10px solid #242424; border-radius: 32px; background: #f7f7f7; }
  .s-obs-cap { margin: 8px 0 0; text-align: center; font: 400 12px/18px var(--sans); color: var(--ink-2); }
  @media (max-width: 1023px) { .s-obs { grid-template-columns: minmax(0, 1fr); gap: 24px; } .s-obs-phone { justify-self: center; width: 100%; max-width: 390px; } .s-obs-phone iframe { height: 640px; } }
  @media (max-width: 1023px) { .s-hero { padding: 72px 24px 0; } }
  @media (max-width: 599px) { .s-hero { padding: 64px 12px 0; } }
</style>

src/lib/records.ts の SERIES_PAGES(観察シートの見出し・説明・できること)

export const SERIES_PAGES: Record<string, { series: string; title: string; pageTitle: string; limit: number; intro?: string; sim?: { src: string; title: string; note: string; obs?: { src: string; title: string; label: string; note: string; points: string[] } } }> = {
  series1: { series: 'HRI', title: 'Drug Delivery Robot | 薬剤搬送ロボットハコブくん', pageTitle: 'Series1 | 薬剤搬送ロボット', limit: 12,
    intro: '病院で薬を運ぶロボット「ハコブくん」を題材に、人とロボットの関わり(HRI)を設計している記録です。病院で使う画面(UI)の設計、Unity での自律移動、Fusion での形づくり、分身ロボット(アバター)の考察までを、4コママンガと一緒に残しています。' },
  series2: { series: 'R2R2R', title: 'R2R2R | デジタルツイン学習-R2R2R(Real to Render to Real)', pageTitle: 'Series2 | R2R2R', limit: 12,
    intro: 'iPhone で物を3Dスキャンし、Unity の中で動きを試してから、机の上のロボットアーム(mechArm 270)で実際に動かす記録です。ロボット工学を専門に学んでいないデザイナーでも、アームのつかむ・渡すといった動きを設計できるかを、失敗も含めて残しています。' },
  series3: { series: 'UV-C農業ロボット', title: 'UV-C Agricultural Robot | UV-C農業ロボット', pageTitle: 'Series3 | UV-C農業ロボット', limit: 20,
    intro: '湿気の多い日本の農業で、紫外線(UV-C)を当てて作物の病気を抑えるロボットが何を変えるかを考えた記録です。フィジカルAIを使った農業を、製造業のように管理する仕組みとして捉え直しています。' },
  series4: { series: 'ひとり研究所の裏側', title: 'Behind the Solo Lab | ひとり研究所の裏側', pageTitle: 'Series4 | ひとり研究所の裏側', limit: 12,
    intro: 'フィジカルAIの研究をひとりで続けるために、AIと一緒に作ってきた仕組みの記録です。サイトの移行、動画の制作、記録の整理など、研究の外側の作業をどう回しているかを残しています。' },
  series5: { series: '考察記事', title: 'Deep Dive | 考察記事', pageTitle: 'Series5 | 考察記事', limit: 12,
    intro: '実装の記録とは別に、フィジカルAIやロボットについて、なぜそうなるのか、何を意味するのかを考えた記事です。論文や企業の発表、市場の動きを読み、対話の形で考えたことを、外れた議論も含めてそのまま残しています。' },
  series6: { series: 'フィジカルAI研究室', title: 'Physical AI Lab | フィジカルAI研究室', pageTitle: 'Series6 | フィジカルAI研究室', limit: 12,
    intro: 'ロボットアームが動きを覚える環境を、自宅にひとりで作っている記録です。机の上のアームでお手本の動きを集め、ロボット用のAI(π0.5 など)に学習させ、実機で動かして確かめます。借りたGPUの種類・時間・費用も含めて、誰でも同じ手順で始められる形で残しています。' },
  series7: { series: '画像認識・物体検出', title: 'Image recognition and Object detection | 画像認識と物体検出', pageTitle: 'Series7 | 画像認識と物体検出', limit: 12,
    intro: 'ロボットのカメラが物を見つける仕組みを、下の層から学び直している記録です。画素の数字、色の見分け方、しきい値、輪郭、特徴点といった昔からの手法を、机の上のアームを撮った写真で1つずつ測って確かめ、その上で SAM 3 のような新しいモデルも試します。評価の物差し(IoU・適合率・再現率など)を先に決め、結果は数字で残しています。' },
  series8: { series: '導入シミュレーション', title: 'Service Robot Simulation | サービスロボット導入シミュレーション', pageTitle: 'Series8 | サービスロボット導入シミュレーション', limit: 12,
    intro: '配膳・清掃・搬送など、店や施設で動くサービスロボットを入れたら何が変わるかを、導入前に試算する記録です。時間・順番・数は SimPy で、ぶつかる・こぼれるといった空間と動きは Unity で扱います。前提の数字は公開事例と観察から置き、結果とコードは全量で公開しています。',
    sim: { src: '/sim/serving-robot.html', title: '配膳ロボット、何台入れたら店は変わる?',
      note: '店を選び、人数・台数・ロボットの使い方を切り替えると、ピーク時の入店待ちや料理が届くまでの時間が変わります。1つの条件を40回回した平均で、数字はすべて仮の前提による試算です(試作 v2・7,665条件)。',
      obs: { src: '/sim/serving-robot-observation.html', title: '試算の仮の数字を、店で数える観察シート', label: '観察シートを開く ↗',
        note: '配膳ロボットが通路で何回止まるかは、公開データが見つかりませんでした。店で片手で押して数えるための記録シートの例です。押した記録はこのブラウザの中にだけ保存され、どこにも送られません。',
        points: ['ロボットの出発・卓に着いた・止まった・戻ったを押すと、1往復の時間と止まった割合が出る', '止まった場所と原因を選べる。選択肢は記録する人の立場(客席から・店のスタッフ・導入や保守の担当)で切り替わる', '地図やルートを変える前と後の観察を並べて比べられる'] } } },
};

LOG353_scripts/shots_addendum.py(立場の切り替えを確かめ、追記の画面写真を撮る)

# LOG353 の追記用:手元で作ったサイト(dist を http.server 8766 で開く)で、
# 立場を切り替えたときの選択肢と、シリーズのページのプレビューを撮り、選択肢が切り替わるかを確かめる
import asyncio
from playwright.async_api import async_playwright
U = 'http://localhost:8766'
async def main():
    async with async_playwright() as p:
        b = await p.chromium.launch(); errs = []
        m = await b.new_page(viewport={'width': 390, 'height': 844}, device_scale_factor=2, is_mobile=True, has_touch=True)
        m.on('pageerror', lambda e: errs.append(str(e)))
        await m.goto(U + '/sim/serving-robot-observation.html'); await m.wait_for_timeout(1000)
        print('places (立場なし)', await m.input_value('#f-places'))
        await m.click('[data-pick="store"][data-v="ガスト"]')
        await m.click('[data-pick="model"][data-v="BellaBot(ネコ型)"]')
        await m.click('[data-role="vendor"]')
        print('places (導入・保守の担当)', await m.input_value('#f-places'))
        await m.evaluate("window.scrollTo(0, document.getElementById('role').getBoundingClientRect().top + scrollY - 200)"); await m.wait_for_timeout(300)
        await m.screenshot(path='LOG353_images/LOG353_06_setup_role_store_model.png')
        await m.evaluate("document.getElementById('setupBox').open=false")
        await m.click('[data-a="trip_start"][data-r="1"]'); await m.click('[data-a="stop_start"][data-r="1"]')
        await m.fill('#add-1', 'ベビーカーが通路に'); await m.click('[data-add="cause"][data-r="1"]')
        await m.click('[data-pl="曲がり角"]')
        el = await m.query_selector('#robots .robot'); await el.scroll_into_view_if_needed(); await m.evaluate("window.scrollBy(0,-90)")
        await m.screenshot(path='LOG353_images/LOG353_07_stop_vendor_choices.png')
        await m.click('[data-a="stop_end"][data-r="1"][data-by="reset"]'); await m.click('[data-a="trip_end"][data-r="1"]')
        print(await m.inner_text('#stops'))
        print([l for l in (await m.inner_text('#sum')).split('\n') if '立ち往生' in l or '入れ直し' in l])
        print('log', (await m.inner_text('#log')).split('\n')[:9])
        await m.evaluate("document.getElementById('setupBox').open=true")
        await m.click('[data-role="store"]')
        print('places (店のスタッフ)', await m.input_value('#f-places'))
        pg = await b.new_page(viewport={'width': 1280, 'height': 1000}, device_scale_factor=2)
        pg.on('pageerror', lambda e: errs.append(str(e)))
        await pg.goto(U + '/series8.html'); await pg.wait_for_timeout(1200)
        await pg.add_style_tag(content='.hk-parade,.hk-say{display:none!important}')
        el = await pg.query_selector('.s-obs'); await el.scroll_into_view_if_needed(); await pg.wait_for_timeout(1500)
        await el.screenshot(path='LOG353_images/LOG353_08_series_page_preview.png')
        print('横幅', 1280, await pg.evaluate('document.documentElement.scrollWidth'))
        print('errors', errs); await b.close()
asyncio.run(main())

保存先と、次にやること

種類保存先
記事・サムネイル・図解・SNS の作業ファイル、画像iCloud Drive デスクトップ/ADAPT DESIGN/じぶんの/PixelstoMotors/デジタルツイン学習R2R2R/サービスロボット導入シミュレーション/
観察シートの HTML(Artifact 版)同じフォルダの observation/
画面写真と図解・選択肢の確認のスクリプト同じフォルダの LOG353_scripts/
観察の記録(測定データ)KIOXIA r2r2r-data/service_sim/observations/{日付}_{店}_{ID}/
記事・サムネイル・図解(記録用)GitHub PixelstoMotors/r2r2r-content の articles/
SNS の本文GitHub PixelstoMotors/r2r2r-content の sns/
観察シートの例(公開)GitHub PixelstoMotors/p2m-site の public/sim/serving-robot-observation.html

次は、配膳ロボットのいる店で、ピークの1時間ほど観察シートを押します。数えた一時停止と立ち往生の割合、下げ膳と卓拭きの時間で、試算の仮の数字(30%・1%・90秒)を置き換えて、試算を回し直します。MASTER の保存先テーブルに、観察の記録の行(service_sim/observations/)を足すのも残っています。