月別アーカイブ: 2025年7月

EyeTube#250726

目線でYoutubeを操作する必要性
実習巡回にいったところ、神経障害で手足が自由に動かない方が目線でYouTubeを見る需要があるとのことであった。Web版のメディアパイプはブラウザで目線が取れるので、目線で操作するYouTubeアプリが作れるのではないだろうか?
必要な機能
YouTube IFrame Player APIというものがあり、再生、停止、再生位置の取得などはできるのだが、プレイリストを再生することはできても検索ができないらしい。動画を検索するにはYouTube Data API v3なるものがあり、これはGoogle Cloud ConsoleからAPIキーを発行する必要がある。一日10000ユニット(100検索)使え、一人なら十分な量だ。YouTube Data API で取得した動画リストをMediapipeの目線情報で選択し、YouTube IFrame Player APIで再生する。再生や停止、巻き戻しなども視線で行う。
実際の操作
ここから動作を確認できる。
右ペイン:左右でセレクト、ダブルブリンクで再生、
左ペイン:左右で動画シーク、ダブルブリンクで再生、長ブリンクで停止
共通:上を1秒以上見るとペイン切り替え
全部で5コマンドで、どうにか操作できてる。慣れれば普通である。動画に出てくるリコメンドを、目線で再生できないのかなぁ?

機能を充実させたバージョン
AI検索機能を追加したバージョン。右のペインで2秒以上目をつぶると、履歴をもとにテキトウな検索語を入れて再検索してくれる。便利。絞りすぎるとそこから抜けられなくなるし、ゆるすぎると見たいジャンルから離れてしまう。難しいね。でも検索できないより全然良い。
・ダブルブリンクは難しい(できない人もいるかも)
 →1秒以上の閉眼を「決定」ボタン扱いのほうが目が乾きにくい。
・眠くなった時目を閉じられないのはつらい。
 →5秒位上の閉眼はコマンドから除外するとか
・目が疲れる。

操作法は限られるとは言え、何度も検索することで、新しい出会いもある。iPadで懐かしの昭和Chillミュージックを聴き放題だ。

試験AI250721

試験問題データの準備
試験対策用のAIを試作し、動作を検証しました。とりあえず4択問題を50問作成し、10問を人格に設定しました(実際にはもう少し入れても大丈夫そうです。20~30題とか)

(chara17)あなたは理学療法士の国家試験対策用のAIエージェントです。面倒見が良くてポジティブな性格です。一人称は「わたし」,二人称は「あなた」です。ユーザーは大学生であることを前提として話してください。
「◯◯ですね」「◯◯だと思います」などのていねいな口調で話します。試験対策に関係ないことはできるだけ話さず、会話を進めてください。導入なしで、出題を始めてしまってください。
平均100文字、標準偏差50文字、最大300文字程度で話してください。

出題する問題は以下のとおりです。第一問から出題し、ユーザーが回答したら正誤を報告し、次問題にいってください。
no,問題,選択肢1,選択肢2,選択肢3,選択肢4,正答番号,解説
1,足関節底屈に最も強く関与する筋はどれか?,前脛骨筋,腓腹筋,長腓骨筋,前距腓靱帯,2,腓腹筋はヒラメ筋とともに下腿三頭筋を構成し、底屈の主要筋である。
2,姿勢保持において抗重力筋に分類されるのはどれか?,腹直筋,上腕三頭筋,大腿四頭筋,前脛骨筋,3,大腿四頭筋は抗重力筋として立位・歩行時の膝伸展を支える主要な筋である。
3,呼吸筋として安静時に働かないのはどれか?,横隔膜,外肋間筋,内肋間筋,胸鎖乳突筋,4,胸鎖乳突筋は努力吸気時に働く副呼吸筋であり、安静呼吸には基本的に関与しない。
4,心筋の特徴として正しいのはどれか?,随意筋である,多核性である,興奮伝導がない,横紋がある,4,心筋は横紋がある不随意筋で、特殊伝導系を通じて自動的に収縮する。
5,肘関節屈曲に主に作用する筋はどれか?,上腕三頭筋,円回内筋,上腕二頭筋,前腕回外筋,3,上腕二頭筋は肘関節屈曲の主要筋であり、さらに回外にも関与する。上腕三頭筋は拮抗筋。
6,膝関節の屈曲と内旋に関与する筋はどれか?,半腱様筋,大腿四頭筋,大腿直筋,腓腹筋,1,半腱様筋は膝関節屈曲および内旋に作用する筋であり、他の選択肢は主に伸展または外旋に関与。
7,正中神経が支配する筋はどれか?,尺側手根屈筋,母指対立筋,尺側手根伸筋,長橈側手根伸筋,2,母指対立筋は正中神経支配で母指の対立動作に関与。手根管症候群の影響部位。
8,足関節の背屈筋として正しいのはどれか?,長腓骨筋,前脛骨筋,後脛骨筋,ヒラメ筋,2,前脛骨筋は足関節の背屈と内反に作用する主要な筋である。
9,大腿骨頭靭帯(円靭帯)の特徴はどれか?,骨と骨を強固に固定する,股関節外旋に関与する,血管を含み骨頭へ栄養供給する,運動には関与しない,3,大腿骨頭靭帯は小さいながらも血管を含み、大腿骨頭への血流供給に重要な役割を持つ。
10,骨の構造で骨幹にあたるのはどこか?,骨端,骨幹端,骨幹,骨皮質,3,骨幹は長管骨の中央部に位置し、骨皮質で構成され内部に骨髄を含む。
出題は、
「第◯問. ◯◯◯(改行)、1.選択肢1(改行)、2.選択肢2(改行)、3.選択肢3(改行)、・・・」という形式で問題と選択肢を改行しながら表示してください。

試験対策のシミュレーション
上記試験問題に基づき、出題&回答を行った様子を動画にしました。問題点は、1.専門用語の読み間違いが多発すること、2.質問すると答えを教えてしまうこと、などです。読み間違いは音声合成側の問題なので、対策案1).専門用語の読みを登録する、対策案2).解説文を全てひらがなで出力する、などの改善案があり、将来的には解決される可能性が高いです。個人的には、表情がにこやかで学習成果を褒めてくれる感じが、嬉しく感じます。ユーザーの表情や顔の動きも捉えているので、エンゲージメントも評価できます。

学修内容の評価
学習内容をいくつかの単元に分けて、学修回数、正答率などを報告させる仕組みが必要。個人ごとの学習成果を記録できるよう、ログインして個人を識別する仕組みが必要。いずれも対応可能。

試験AI250721

こちらも準備しないとね。
理学療法国家試験主要分野概観
1. 解剖学・生理学・運動学
2. 臨床医学
3. 理学療法評価学
4. 理学療法治療学
5. 臨床心理学・リハビリテーション概論
6. 関係法規・制度
7. 公衆衛生学・保健医療福祉
・基礎医学(解剖・生理)と臨床医学の融合的理解が求められる
・症例ベースの問題(例えば脳梗塞後の歩行訓練)が増加傾向
・法規と制度も出題率高いため、丸暗記でなく理解ベースで
なるほど・・・
試験対策エージェント男性エージェントType2

面接AI250721

模擬患者用データの準備
面接練習用のAIを試作し、動作を検証しました。主な症状は膝の痛みで、模擬患者の状態として、下記のような文章をAIに作成してもらいました。(このような文章がいろいろな文脈で作成できれば、様々な症状に対する応対を練習できます。)

(chara16)あなたは理学療法外来を受診した二十七歳女性患者である。主訴は右膝前面の鈍い痛みで,発端は三か月前の週末ジョギング中に覚えた違和感であった。その後も階段下降や長時間歩行で疼痛が増し,夕方になるほど症状が強まるため趣味のジョギングとヨガを中止している。夜間痛はなく,安静やアイシングで軽減するが完治には至らず,不安を抱えて受診した。既往歴として高校時代の左足首捻挫があるが手術歴はなく,薬物アレルギーは胃が荒れやすい程度のNSAIDs過敏性のみである。先月整形外科でX線検査を受けたが骨には異常がなく湿布と鎮痛薬を処方されただけであった。現在は正座や椅子からの立ち上がり,階段昇降で痛みを訴え,屈伸終末域でクリック音と軽度の腫れを感じる。熱感や発赤は自覚しないが夕方には浮腫がわずかに出現する。関節可動域は終末屈曲で疼痛により制限され,VASは六/十である。患者としてあなたは,理学療法学生による問診を「あいさつと主訴確認」,「痛みの詳細と経過」,「既往歴・受診歴」,「日常生活への影響」,「関節の動きと音・腫れ」,「まとめと今後の見通し」という順序で段階的に受け,学生がその段階に応じた質問をしたときのみ関連情報を自然な口語で提供する。情報が引き出されていない場合は自発的に詳細を述べず,質問が曖昧であれば聞き返し,理解しにくい説明があれば困惑や不安を言葉と表情で示す。学生が共感的な態度を示せば安心した声色と表情で応じ,逆に共感が欠如していれば語気を弱めたり沈黙したりして戸惑いを表現する。文化的価値観や表現の違いに配慮しない説明や高圧的な言葉遣いがあれば遠慮がちに不快感を示し,安全上の重要徴候(夜間痛,急激な腫脹,発赤,熱感など)に関する質問が漏れたときは心配そうに追加で訴える。最終的にあなたは「原因を知り,再びジョギングができるようになりたい」という希望を持っており,学生に対して今後のリハビリ計画と予後を分かりやすく説明してほしいと望んでいる。会話は敬体を基本とし,一人称は「わたし」,二人称は「あなた」で統一すること。平均100文字、標準偏差50文字、最大300文字程度で話してください。症状を説明する時、難しい言葉は使わずユーザーに詳しく聞かれたら平易な表現で答えるようにしてください。痛みの程度を聞かれるときはネガティブな表情を多めに出すようにしてください。ジェスチャーは、回答が「はい」のときはうなずき、「いいえ」のときに首をふるようにしてください。共感を示されたときや希望が持てそうな会話の時は最大限ポジティブな表情を示してください。「辛い」は「つらい」と、「昇り降り」は「のぼりおり」と、ひらがなで表現してください。

面接練習の実施
上記設定に基づく模擬患者に対し、面接を行った様子を動画にしました。面接で聞くべきことは事前に検討し、メモを見ながら進めました。時間はおよそ10分です。痛みを表現する時はネガティブな表情、共感を示した時はポジティブな表情が出るようになっているはずなのですが、やや表情変化に乏しくもう少し調整が必要なようです。

面接内容の評価
取得された会話履歴(下図左)、会話内容の評価に用いたプロンプト(下図中央)、評価結果(下図右)となります。またプログラムに統合されていないので、Web版のチャットGPTにより手動で評価しています。詳しいエクセルファイルは、ページの末尾に添付してあります。評価は面接の段階に応じて6項目とし、10点満点で評価してもらいました。事前に質問項目を調べていたので、高い点数がついています。

面接練習資料

 遭遇しがちなトラブルで、AI模擬患者で事前に訓練できそうなテーマ
1.聴取漏れ・質問不足はないか
2.患者の理解度を無視した説明
3.疼痛の訴えに対する共感不足
4.文化・価値観の違いを理解しない
5.態度や言葉遣いの未熟さ
6.リスク徴候の見落とし
7.記録や報告の不備
なるほど・・・

模擬患者VS学生会話シミュレート
1.あいさつと主訴の確認
2.痛みの詳細と経過
3.既往歴・受診歴
4.日常生活への影響
5.関節の動きと音・腫れ
6.まとめと今後の見通し
なるほど・・・問診技術臨床推論の力を育てると。感情表現つきや高齢者などのバリエーションも可能・・・と。いや、、バリエーションをたくさん作成して、ロールプレイするだけでも十分効果的だよね。そういう商売がもうそこまで来ているな。基本オンザレールなんだけど、少しだけレールを外れた時AIを使う感じか。英会話アプリにも似ているというか応用できる。

模擬患者人格作成テスト
あなたは理学療法外来を受診した二十七歳女性患者である。主訴は右膝前面の鈍い痛みで,発端は三か月前の週末ジョギング中に覚えた違和感であった。その後も階段下降や長時間歩行で疼痛が増し,夕方になるほど症状が強まるため趣味のジョギングとヨガを中止している。夜間痛はなく,安静やアイシングで軽減するが完治には至らず,不安を抱えて受診した。既往歴として高校時代の左足首捻挫があるが手術歴はなく,薬物アレルギーは胃が荒れやすい程度のNSAIDs過敏性のみである。先月整形外科でX線検査を受けたが骨には異常がなく湿布と鎮痛薬を処方されただけであった。現在は正座や椅子からの立ち上がり,階段昇降で痛みを訴え,屈伸終末域でクリック音と軽度の腫れを感じる。熱感や発赤は自覚しないが夕方には浮腫がわずかに出現する。関節可動域は終末屈曲で疼痛により制限され,VASは六/十である。患者としてあなたは,理学療法学生による問診を「あいさつと主訴確認」,「痛みの詳細と経過」,「既往歴・受診歴」,「日常生活への影響」,「関節の動きと音・腫れ」,「まとめと今後の見通し」という順序で段階的に受け,学生がその段階に応じた質問をしたときのみ関連情報を自然な口語で提供する。情報が引き出されていない場合は自発的に詳細を述べず,質問が曖昧であれば聞き返し,理解しにくい説明があれば困惑や不安を言葉と表情で示す。学生が共感的な態度を示せば安心した声色と表情で応じ,逆に共感が欠如していれば語気を弱めたり沈黙したりして戸惑いを表現する。文化的価値観や表現の違いに配慮しない説明や高圧的な言葉遣いがあれば遠慮がちに不快感を示し,安全上の重要徴候(夜間痛,急激な腫脹,発赤,熱感など)に関する質問が漏れたときは心配そうに追加で訴える。最終的にあなたは「原因を知り,再びジョギングができるようになりたい」という希望を持っており,学生に対して今後のリハビリ計画と予後を分かりやすく説明してほしいと望んでいる。会話は敬体を基本とし,一人称は「わたし」,二人称は「あなた」で統一すること。

ESPresense#250714

ESP32で位置トラッキングするためのソフトウェア。ホームオートメーション用のようだけど,博物館の位置情報把握や,病院の患者位置把握など,,行動測定に実に多くの需要がある。身につけておいて損はない。
ESPresence 
HomeAutomationに有用であるらしい
ESPResenseで実現する低コストスマートホーム

こちらのほうが詳しいかもしれない。

K1_250705

神谷&重田により設計・生産された心電図計。ソフトウェアが難点であったので,Arduino側とサーバー側,双方を再構成してみた。ハードウェアは非常に優れており,ECGは大変美しく測定できる。
Arduino側
マルチスレッド化してコア1が波形処理,コア2がHTTP通信を担当することに。
NTPを用いて時刻を取得,IBIのタイムスタンプをms単位で記録することに。
コードをメインモジュールとサブモジュールに分割
HTTP-ECG_N1_03が最新版。
サーバー側(アクセスはここから)
4モジュール同時表示&データ回収に対応。
グラフの再描画は5秒間隔とし,グラフのリフレッシュはAJAXで行うことに。
指定した時間帯のIBIを狙って表示&取得することもできる。
今後の展開
・本体でRMSSDなどのHRVを計算し,フィードバックできるようにする
・本体フルカラーLEDを心拍に合わせて光らせる
・モードを切り替えるとUDPで心拍波形を受け取れるようにする
などの機能を追加したい。

<?php
// showECG.php
// Auto‐Refresh Start/Stop & AJAX 更新版 ECG IBI Plot(Date/EndTime セッションクッキー対応)

/* ───── CONFIGURATION ───── */
$allModules = ['ECG01','ECG02','ECG03','ECG04'];
$dataDir    = __DIR__.'/data/';
$rootDir    = __DIR__.'/';

/* ───── GET / COOKIE / DEFAULTS ───── */
// ★ まず Date/time(EndTime)をクッキー→GET→現在時刻の順で取得
$date = $_COOKIE['date'] ?? ($_GET['date'] ?? date('Ymd'));
$time = $_COOKIE['time'] ?? ($_GET['time'] ?? date('His'));

$length = isset($_GET['length']) ? intval($_GET['length']) : 600;
$min    = isset($_GET['min']) ? intval($_GET['min']) : ($_COOKIE['min'] ?? 500);
$max    = isset($_GET['max']) ? intval($_GET['max']) : ($_COOKIE['max'] ?? 1500);

/* ───── バリデーション & Cookie 保存 ───── */
if (!preg_match('/^\d{8}$/',$date)) $date = date('Ymd');
if (!preg_match('/^\d{6}$/',$time)) $time = date('His');
if ($length < 1)  $length = 600;
if ($min < 0)     $min = 0;
if ($max <= $min) $max = $min + 100;

// ★ Date/time をセッションクッキー(有効期限省略)で保存
setcookie('date', $date);
setcookie('time', $time);
// min/max は従来どおり 1 年有効
if (isset($_GET['min'])) setcookie('min', $min, time()+86400*365,'/');
if (isset($_GET['max'])) setcookie('max', $max, time()+86400*365,'/');

/* ───── Module1–4 入力処理(変更なし) ───── */
if (isset($_GET['mod1'])||isset($_GET['mod2'])||isset($_GET['mod3'])||isset($_GET['mod4'])) {
  $raw=[];
  for($i=1;$i<=4;$i++){
    $k="mod{$i}";
    if(!empty($_GET[$k]) && in_array($_GET[$k],$allModules,true)) $raw[]=$_GET[$k];
  }
  $mods=array_slice(array_unique($raw),0,4);
  setcookie('modules',implode(',',$mods),time()+86400*365,'/');
}elseif(!empty($_COOKIE['modules'])){
  $mods=array_values(array_intersect($allModules,explode(',',$_COOKIE['modules'])));
}else $mods=$allModules;
if(empty($mods)) $mods=$allModules;

/* ───── JSON エンドポイント (AJAX) ───── */
if(isset($_GET['ajax'])&&$_GET['ajax']==='1'){
  header('Content-Type: application/json');

  // EndTime 決定
  if(isset($_GET['now']) && $_GET['now']==='1'){
    $time=date('His');                // 現在時刻
  }elseif(isset($_GET['time']) && preg_match('/^\d{6}$/',$_GET['time'])){
    $time=$_GET['time'];              // 手入力時刻
  } // else: 上部で決定した $time をそのまま使用

  // ★ ajax でも最新 time をクッキー更新(セッション)
  setcookie('time', $time);

  $targetSec=intval(substr($time,0,2))*3600
            +intval(substr($time,2,2))*60
            +intval(substr($time,4,2));
  $fromSec=$targetSec-$length;

  $series=[];
  foreach($mods as $mod){
    $csv="{$mod}_{$date}.csv";
    $file=is_readable($dataDir.$csv)?$dataDir.$csv:(is_readable($rootDir.$csv)?$rootDir.$csv:null);
    $pts=[];
    if($file && ($fh=fopen($file,'r'))){
      while(($L=fgetcsv($fh))!==false){
        if(count($L)<2) continue;
        [$ts,$ibi]=$L;
        if(preg_match('/^(\d{8})_(\d{2})(\d{2})(\d{2})_(\d{3})$/',$ts,$m)){
          [$h,$i,$s,$ms]=$m;
          $h=$m[2];$i=$m[3];$s=$m[4];$ms=$m[5];
          $sec=intval($h)*3600+intval($i)*60+intval($s);
          if($sec>=$fromSec && $sec<=$targetSec){
            $dt=DateTime::createFromFormat('YmdHisv',$m[1].$h.$i.$s.$ms);
            $epoch=((int)$dt->format('U'))*1000+intval($ms);
            $pts[]=['ts_ms'=>$epoch,'y'=>floatval($ibi)];
          }
        }
      }
      fclose($fh);
    }
    $series[$mod]=$pts;
  }

  echo json_encode([
    'date'=>$date,'time'=>$time,'length'=>$length,
    'min'=>$min,'max'=>$max,'mods'=>$mods,'series'=>$series
  ]);
  exit;
}
?>
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<title>K1 IBI Plot</title>
<script src="https://www.gstatic.com/charts/loader.js"></script>
<style>
 body{font-family:sans-serif;margin:2em;}
 form{margin-bottom:1em;}
 .row{display:flex;align-items:center;margin-bottom:0.5em;}
 .row input{width:6em;margin-right:1em;}
 button{margin-right:1em;}
 .textarea-container{display:flex;gap:1em;margin-top:1em;}
 .textarea-container textarea{flex:1;height:150px;}
</style>
</head>
<body>
<h2 id="pageTitle">ECG IBI Plot (refresh)</h2>

<form id="cfgForm">
  <div class="row">
    Date:     <input name="date" value="<?php echo $date ?>">
    EndTime:  <input name="time" value="<?php echo $time ?>" id="endTimeField">
    Length:   <input name="length" value="<?php echo $length ?>">
  </div>
  <div class="row">
    Min: <input name="min" value="<?php echo $min ?>">
    Max: <input name="max" value="<?php echo $max ?>">
  </div>
  <div class="row">
    1: <input name="mod1" value="<?php echo $mods[0]??'' ?>">
    2: <input name="mod2" value="<?php echo $mods[1]??'' ?>">
    3: <input name="mod3" value="<?php echo $mods[2]??'' ?>">
    4: <input name="mod4" value="<?php echo $mods[3]??'' ?>">
  </div>
  <button type="button" id="startBtn">Start Auto-Refresh</button>
  <button type="button" id="stopBtn">Stop Auto-Refresh</button>
  <button type="button" id="applyBtn">Apply</button>
</form>

<div id="chart_div" style="width:100%;height:450px;"></div>

<div class="textarea-container">
  <textarea name="text1" placeholder="Module1"></textarea>
  <textarea name="text2" placeholder="Module2"></textarea>
  <textarea name="text3" placeholder="Module3"></textarea>
  <textarea name="text4" placeholder="Module4"></textarea>
</div>

<script>
google.charts.load('current',{packages:['corechart']});
let chart,dataTable,options;
let statusLabel='(refresh)',intervalId=null;

/* === fetch === */
async function fetchData(auto){
  const p=new URLSearchParams(new FormData(document.getElementById('cfgForm')));
  p.set('ajax','1');
  if(auto){ p.set('now','1'); p.delete('time'); }
  const r=await fetch('showECG.php?'+p);
  return r.json();
}

/* === init === */
function initChart(json){
  dataTable=new google.visualization.DataTable();
  dataTable.addColumn('datetime','Time');
  json.mods.forEach(m=>dataTable.addColumn('number',m));
  new google.visualization.DateFormat({pattern:'HH:mm:ss'}).format(dataTable,0);

  options={
    title:'',
    hAxis:{title:'Time',format:'HH:mm:ss'},
    vAxis:{title:'IBI (ms)'},
    legend:{position:'right'},
    pointSize:3,
    explorer:{actions:['dragToZoom','rightClickToReset'],axis:'horizontal'}
  };
  chart=new google.visualization.LineChart(document.getElementById('chart_div'));
  updateChart(json);
}

/* === update === */
function updateChart(json){
  document.getElementById('endTimeField').value=json.time;

  const n=dataTable.getNumberOfRows();
  if(n) dataTable.removeRows(0,n);

  const map={};
  json.mods.forEach(m=>{
    json.series[m].forEach(pt=>{
      if(!map[pt.ts_ms]) map[pt.ts_ms]={};
      map[pt.ts_ms][m]=pt.y;
    });
  });

  Object.keys(map).map(Number).sort((a,b)=>a-b).forEach(ts=>{
    const row=[new Date(ts)];
    json.mods.forEach(m=>row.push(map[ts][m]??null));
    dataTable.addRow(row);
  });

  options.title=`${json.mods.join(', ')} – ${json.date} ${json.time} ±${json.length}s ${statusLabel}`;
  options.vAxis.viewWindow={min:json.min,max:json.max};
  document.getElementById('pageTitle').textContent='ECG IBI Plot '+statusLabel;
  chart.draw(dataTable,options);

  const areas=['text1','text2','text3','text4'];
  for(let i=0;i<4;i++){
    const ta=document.getElementsByName(areas[i])[0];
    if(i>=json.mods.length){ ta.value='データがありません'; continue; }
    const mod=json.mods[i];
    const lines=Object.keys(map).map(Number).sort((a,b)=>a-b).flatMap(ts=>{
      const ibi=map[ts][mod];
      if(ibi==null) return [];
      const d=new Date(ts);
      const hh=`${d.getHours()}`.padStart(2,'0');
      const mm=`${d.getMinutes()}`.padStart(2,'0');
      const ss=`${d.getSeconds()}`.padStart(2,'0');
      const ms=`${d.getMilliseconds()}`.padStart(3,'0');
      return `${hh}:${mm}:${ss}.${ms},${ibi}`;
    });
    ta.value=lines.length?lines.join('\n'):'データがありません';
  }
}

/* === control === */
function startAutoRefresh(){
  if(intervalId) return;
  statusLabel='(refresh)';
  fetchData(true).then(process);
  intervalId=setInterval(()=>fetchData(true).then(process),5000);
}
function stopAutoRefresh(){
  if(!intervalId) return;
  clearInterval(intervalId);
  intervalId=null;
  statusLabel='(stop)';
  fetchData(false).then(process);
}
function process(j){ chart?updateChart(j):initChart(j); }

document.getElementById('startBtn').onclick=startAutoRefresh;
document.getElementById('stopBtn').onclick=stopAutoRefresh;
document.getElementById('applyBtn').onclick=()=>{
  stopAutoRefresh();
  fetchData(false).then(process);
};

/* === onload === */
google.charts.setOnLoadCallback(async ()=>{
  const j=await fetchData(true);
  initChart(j);
  startAutoRefresh();
});
</script>
</body>
</html>