はじめに
前回は、単位換算ツールの計算部分を、C と並べて読みました。計算の関数は、画面のことを一切知らない作りになっています。
今回は、その計算を画面の操作とつなぐ部分を紹介します。検査項目を選んだり、入力欄に数字を打ったりすると、ボタンを押さなくても、すぐに結果が表示される仕組みです。
作ってみてわかったのは、この仕組みが組み込みの割り込みとそっくりだったことです。画面の部品はレジスタのように名前で特定して読み書きし、利用者の操作は割り込み要因として届き、登録しておいたハンドラが呼ばれます。この回では、その対応を1つずつ見ていきます。
画面の部品を並べる
最初に、計算はせずに、画面の部品を並べるだけの段階を作りました。組み込みでいえば、基板に部品を実装しただけで、まだファームを書いていない状態です。
画面は HTML で書きます。設計書の画面設計の表に沿って、部品ごとに id(名札)を付けました。
<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>
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 で部品を探して、変数に入れておきます。
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;組み込みでいえば、レジスタのアドレスを取得して、ポインタ変数に入れておく操作です。
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行です。
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 にしました。

3つの部品に、同じハンドラを1つ
3つの部品の割り込みすべてに、同じハンドラ update を登録しています。どの部品が変わっても、「今の入力をすべて読み直して、計算して、表示する」だけにしたのがポイントです。
部品ごとに別のハンドラを書いて、「項目が変わったら単位の表示だけ変える」「入力が変わったら結果だけ変える」と細かく分けることもできます。ただ、それだと部品同士の組み合わせを考える必要が出てきて、書き忘れが起きやすくなります。毎回すべてを読み直せば、どの順番で操作されても、表示は必ず今の入力と一致します。
組み込みでいえば、割り込みごとに状態を少しずつ更新するのではなく、割り込みのたびに入力ポートを全部読み直して、出力を一から計算し直すような作りです。処理は少し増えますが、今回の計算はごく軽いので問題になりません。
ハンドラ update の中身
ハンドラの中身は、次のとおりです。入力チェックの関数 parseInput は、前回紹介したものです。
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 → SI | 100 | 5.55 mmol/L |
| 血糖 | SI → mg/dL | 5.55 | 99.99 mg/dL(入力欄の横の単位が mmol/L に変わる) |
| クレアチニン | mg/dL → SI | 1 | 88.40 µmol/L |
| 中性脂肪 | mg/dL → SI | 150 | 1.69 mmol/L |
| (任意) | (任意) | -5 | 「0 以上の値を入力してください」 |
| (任意) | (任意) | abc | 「半角の数字で入力してください(例: 5.5)」 |
2行目が 100.00 ではなく 99.99 になるのは、5.55 自体が四捨五入された値だからで、計算の誤りではありません。どの行も、ボタンを押さずに、打った瞬間に表示が切り替わりました。
見た目を整え、スマホでも使えるようにする
機能ができたところで、非機能要件の N-03(スマホのブラウザでも崩れずに使える)に対応しました。使うのは CSS という、画面の見た目を指定する言語です。HTML が「どんな部品があるか」を表すのに対し、CSS は「その部品をどう見せるか」を担当します。
index.html の <head> の中に、次を追加しました。
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 | 結果 | 大きく、太く表示する |
#message | id=”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.log | UART への printf デバッグ |
名前は見慣れないものばかりでしたが、やっていることは、入力を読んで、計算して、出力に書くという、組み込みで毎日書いてきた処理そのものでした。違うのは、メインループを自分で書かなくてよいことくらいです。
次回は、Step 1 の仕上げとして、完成したツールをビルドし、自宅サーバーで公開するまでを紹介します。ビルドしたファイルを返すだけの小さなサーバーを書き、PM2 と Apache の設定を Step 0 から使い回して、外の URL で確認するところまでです。
この記事は、シリーズ「組み込みエンジニアの Web 開発入門」の1本です。ほかの回は、シリーズの目次からどうぞ。



コメント