[PR] 本記事にはプロモーションが含まれています。

【入門編 第6回】イベント駆動は割り込みだった ― 画面の部品とハンドラ(Step 1)

TypeScript

はじめに

前回は、単位換算ツールの計算部分を、C と並べて読みました。計算の関数は、画面のことを一切知らない作りになっています。

今回は、その計算を画面の操作とつなぐ部分を紹介します。検査項目を選んだり、入力欄に数字を打ったりすると、ボタンを押さなくても、すぐに結果が表示される仕組みです。

作ってみてわかったのは、この仕組みが組み込みの割り込みとそっくりだったことです。画面の部品はレジスタのように名前で特定して読み書きし、利用者の操作は割り込み要因として届き、登録しておいたハンドラが呼ばれます。この回では、その対応を1つずつ見ていきます。

画面の部品を並べる

最初に、計算はせずに、画面の部品を並べるだけの段階を作りました。組み込みでいえば、基板に部品を実装しただけで、まだファームを書いていない状態です。

画面は HTML で書きます。設計書の画面設計の表に沿って、部品ごとに id(名札)を付けました。

HTML
<h1>検査値の単位換算</h1>

<p>
  <label for="item">検査項目</label>
  <select id="item">
    <option value="glucose">血糖</option>
    <option value="tchol">総コレステロール</option>
    <option value="tg">中性脂肪</option>
    <option value="cre">クレアチニン</option>
  </select>
</p>

<p>
  <label for="direction">換算の向き</label>
  <select id="direction">
    <option value="toSi">mg/dL → SI単位</option>
    <option value="fromSi">SI単位 → mg/dL</option>
  </select>
</p>

<p>
  <label for="value">入力値</label>
  <input id="value" type="text" inputmode="decimal" autocomplete="off">
  <span id="in-unit">mg/dL</span>
</p>

<p>結果: <output id="result">—</output></p>

<p id="message"></p>

<script type="module" src="/src/main.ts"></script>
単位換算ツールの画面の各部品に、item、direction、value、in-unit、result、message の id が対応している様子を示した図

HTML は、タグの入れ子で部品を組み立てます。C の構造体の中に構造体を入れていくのと似た形です。

書き方意味
<p>…</p>1つの段落。ここでは1行分のまとまりとして使っている
<label for="item">「この文字は id=”item” の部品の名札」という対応づけ。文字をクリックすると部品が選ばれる
<select> と <option>ドロップダウンの選択肢。value がプログラムから見える内部の値、タグの間が表示名
type="text"自由入力欄。数値専用(type="number")にしないのは、入力チェックを自分で書くため
inputmode="decimal"スマホで数字のキーボードを出す指定
<output>計算結果を表示するための部品

選択肢の表示名(「血糖」)と内部の値(glucose)を分けているのは、C で表示用の文字列とは別に enum を定義するのと同じ考え方です。表示名を後から変えても、プログラムには影響しません。

部品をつかまえて、読み書きする

画面の部品をプログラムから扱うには、まず id で部品を探して、変数に入れておきます。

TypeScript
const itemSel = document.getElementById('item') as HTMLSelectElement;
const dirSel = document.getElementById('direction') as HTMLSelectElement;
const valueInput = document.getElementById('value') as HTMLInputElement;
const inUnit = document.getElementById('in-unit') as HTMLSpanElement;
const result = document.getElementById('result') as HTMLOutputElement;
const message = document.getElementById('message') as HTMLParagraphElement;

組み込みでいえば、レジスタのアドレスを取得して、ポインタ変数に入れておく操作です。

C
volatile uint32_t *message = (volatile uint32_t *)MESSAGE_REG_ADDR;
書き方意味
document今表示している画面全体
getElementById('item')画面の中から id=”item” の部品を探す
as HTMLSelectElement「この部品はドロップダウンだ」と型を指定する。C のキャストと同じ

as は C のキャストと同じく、TypeScript に「信じてくれ」と伝えるだけです。HTML 側の id を書き間違えると、部品が見つからずに実行時に壊れます。そのため、id は設計書の表と見比べながら書きました。

読むときは .value、書くときは .textContent

部品の中身は、次のように読み書きします。

操作書き方組み込みでいうと
選択肢・入力欄の中身を読むitemSel.value、valueInput.value入力レジスタを読む
表示する文字を書き換えるresult.textContent = '5.55 mmol/L'表示器のレジスタに書き込む

入力欄の .value は、利用者が 100 と打っても、数値の 100 ではなく文字列の "100" です。UART の受信バッファに '1' '0' '0' という文字コードが並んでいる状態と同じで、計算に使うには数値への変換が必要です。

textContent に代入すると、画面の表示がすぐに変わります。このとき変わるのは、ブラウザが持っている画面の状態だけで、サーバー上の index.html は空の <p id="message"></p> のままです。ブラウザの開発者ツール(F12)の「要素」タブを開いたまま操作すると、部品の中身がその場で書き換わる様子を見られます。レジスタの値をデバッガのウォッチ窓で見ているような感覚でした。

イベントは割り込み

要件の F-04 は「入力するとすぐに換算結果を表示する(ボタン不要)」でした。これを実現する仕組みがイベントです。

ブラウザの中で回っているメインループ

ブラウザの中では、組み込みのメインループに相当するイベントループが回っています。利用者が選択を変えたり、キーを押したりすると、それがイベント(割り込み要因)として発生し、あらかじめ登録しておいた関数(ハンドラ)が呼ばれます。

第2回のサーバーのプログラムに無限ループがなかったのと同じで、画面のプログラムにもメインループは書きません。書くのは、ハンドラの中身と、その登録だけです。

ハンドラを登録する

登録は、次の3行です。

TypeScript
itemSel.addEventListener('change', update);
dirSel.addEventListener('change', update);
valueInput.addEventListener('input', update);

addEventListener('change', update) は、「この部品で change というイベントが起きたら、update 関数を呼んでほしい」という登録です。割り込みベクタテーブルに、ハンドラのアドレスを書き込むのと同じ操作です。

イベント発生するとき使った部品
change選択肢が確定したとき検査項目、換算の向き
input入力欄の文字が1文字変わるたび入力値

入力欄で change ではなく input を使っているのは、change だと入力欄から別の場所に移ったときにしか発生しないからです。1文字打つたびに結果を変えたいので、input にしました。

利用者の操作が change や input のイベントとしてブラウザのイベントループに届き、update 関数が呼ばれて画面の部品を書き換える流れと、組み込みの割り込み要因・メインループ・割り込みハンドラ・出力レジスタとの対応を示した図

3つの部品に、同じハンドラを1つ

3つの部品の割り込みすべてに、同じハンドラ update を登録しています。どの部品が変わっても、「今の入力をすべて読み直して、計算して、表示する」だけにしたのがポイントです。

部品ごとに別のハンドラを書いて、「項目が変わったら単位の表示だけ変える」「入力が変わったら結果だけ変える」と細かく分けることもできます。ただ、それだと部品同士の組み合わせを考える必要が出てきて、書き忘れが起きやすくなります。毎回すべてを読み直せば、どの順番で操作されても、表示は必ず今の入力と一致します。

組み込みでいえば、割り込みごとに状態を少しずつ更新するのではなく、割り込みのたびに入力ポートを全部読み直して、出力を一から計算し直すような作りです。処理は少し増えますが、今回の計算はごく軽いので問題になりません。

ハンドラ update の中身

ハンドラの中身は、次のとおりです。入力チェックの関数 parseInput は、前回紹介したものです。

TypeScript
function update(): void {
  // ① 選ばれている検査項目を探す
  const item = findItem(itemSel.value);
  if (!item) return;

  // ② 換算の向きに合わせて、単位の表示を変える
  const dir = dirSel.value as Direction;
  const fromUnit = dir === 'toSi' ? item.convUnit : item.siUnit;
  const toUnit = dir === 'toSi' ? item.siUnit : item.convUnit;
  inUnit.textContent = fromUnit;

  // ③ 入力をチェックする
  const r = parseInput(valueInput.value.trim());
  if (!r.ok) {
    result.textContent = '';
    message.textContent = r.error;
    return;
  }

  // ④ 換算して表示する
  message.textContent = '';
  result.textContent = `${formatResult(convert(r.value, item, dir))} ${toUnit}`;
}

// ハンドラの登録
itemSel.addEventListener('change', update);
dirSel.addEventListener('change', update);
valueInput.addEventListener('input', update);

update();   // 起動時に一度呼んで、初期表示をそろえる

「読む → 調べる → 計算する → 書く」という、割り込みハンドラでおなじみの流れです。ハンドラを短く、すぐに終わる処理にしておく点も、組み込みと同じです。

`${…} ${toUnit}` は、文字列の中に値を埋め込む書き方(テンプレート文字列)で、sprintf の書式指定と同じ役割です。

起動時に1回呼ぶ

最後の update(); は、イベントを待たずに、起動したときに1回だけハンドラを呼んでいます。これがないと、最初に画面を開いたときは、入力欄の横の単位やメッセージが、HTML に書いた初期値のままになってしまいます。組み込みで、メインループに入る前に、出力ポートを初期状態にそろえておくのと同じです。

画面を開いた直後は入力欄が空なので、「値を入力してください」というメッセージが最初から表示されます。これは、何を入れればよいかの案内として、そのままにしました。

動作確認

開発サーバーで、次のように操作して確かめました。

検査項目換算の向き入力値結果
血糖mg/dL → SI1005.55 mmol/L
血糖SI → mg/dL5.5599.99 mg/dL(入力欄の横の単位が mmol/L に変わる)
クレアチニンmg/dL → SI188.40 µmol/L
中性脂肪mg/dL → SI1501.69 mmol/L
(任意)(任意)-5「0 以上の値を入力してください」
(任意)(任意)abc「半角の数字で入力してください(例: 5.5)」

2行目が 100.00 ではなく 99.99 になるのは、5.55 自体が四捨五入された値だからで、計算の誤りではありません。どの行も、ボタンを押さずに、打った瞬間に表示が切り替わりました。

見た目を整え、スマホでも使えるようにする

機能ができたところで、非機能要件の N-03(スマホのブラウザでも崩れずに使える)に対応しました。使うのは CSS という、画面の見た目を指定する言語です。HTML が「どんな部品があるか」を表すのに対し、CSS は「その部品をどう見せるか」を担当します。

index.html の <head> の中に、次を追加しました。

CSS
body {
  font-family: system-ui, sans-serif;
  max-width: 32rem;
  margin: 1.5rem auto;
  padding: 0 1rem;
  line-height: 1.6;
}
p {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.5rem;
}
label { min-width: 6em; }
select, input { font-size: 1rem; padding: 0.3rem; }
input { width: 8em; }
output { font-size: 1.4rem; font-weight: bold; }
#message { color: #c00; min-height: 1.6em; }

CSS は、「どの部品に(セレクタ)」「何を(プロパティ)」「どうする(値)」の組み合わせで書きます。

セレクタ対象主な指定
body画面全体幅を最大 32rem に抑えて中央に寄せる(PC で横に間延びしない)
p各行横に並べ、幅が足りなければ折り返す
label項目名幅をそろえて、縦の列を合わせる
output結果大きく、太く表示する
#messageid=”message” の部品赤字にする。# は「id で指定する」という意味

スマホ対応の要は、p に指定した flex-wrap: wrap です。画面が狭いと、項目名と入力欄が自動で2段に折り返されます。また、入力欄の文字を 1rem(通常 16 ピクセル)にしているのは、iPhone で入力欄をタップしたときに、画面が勝手に拡大されるのを防ぐためです。

単位の rem は「標準の文字の大きさの何倍か」、em は「その部品の文字の大きさの何倍か」を表します。ピクセルで固定しないので、画面の大きさや文字サイズの設定が変わっても、比率が保たれます。

開発者ツールで、スマホの幅を試す

スマホを用意しなくても、ブラウザの開発者ツールで、画面の幅を切り替えて確かめられます。F12 で開発者ツールを開き、Ctrl + Shift + M を押すと、デバイスツールバーが表示されます。上部の機種の一覧から「iPhone SE」などを選ぶと、その機種の画面の幅で表示されます。

横にはみ出さずに操作できることを確かめた後、実機のスマホでも、公開した URL を開いて確認しました(公開までの流れは次回紹介します)。

まとめと次回予告

画面とプログラムのつながりを、組み込みの言葉に置き換えてまとめます。

Web の概念組み込みでいうと
HTML の部品と id基板の部品と、レジスタの名前
getElementByIdレジスタのアドレスを取得する
.value を読む入力レジスタを読む
.textContent に書く表示器のレジスタに書き込む
イベント(change、input)割り込み要因
イベントループ実行環境が持つメインループ
addEventListener割り込みベクタへのハンドラ登録
ハンドラ(update)割り込みハンドラ(短く、すぐ終わる)
起動時の update()メインループに入る前の出力の初期化
console.logUART への printf デバッグ

名前は見慣れないものばかりでしたが、やっていることは、入力を読んで、計算して、出力に書くという、組み込みで毎日書いてきた処理そのものでした。違うのは、メインループを自分で書かなくてよいことくらいです。

次回は、Step 1 の仕上げとして、完成したツールをビルドし、自宅サーバーで公開するまでを紹介します。ビルドしたファイルを返すだけの小さなサーバーを書き、PM2 と Apache の設定を Step 0 から使い回して、外の URL で確認するところまでです。

この記事は、シリーズ「組み込みエンジニアの Web 開発入門」の1本です。ほかの回は、シリーズの目次からどうぞ。

ほかの開発・プログラミングの記事は、開発・プログラミングメモの目次から、テーマ別に探せます。

このブログは、にほんブログ村と人気ブログランキングに参加しています。記事が役に立ったら、下のランキングのバナーのどれかを押して応援してもらえると励みになります。

ブログランキング・にほんブログ村へにほんブログ村

サーバー構築・運用ランキング プログラムランキング

TypeScript
watasoftdevをフォローする

コメント