単位変換ツールは無数にあるが、自分で作ってみると設計に 1 つだけ面白い分岐がある: ほぼ全ての単位は「基準単位への係数」1 つで表せるのに、温度だけはそれができない。1km = 1000m、1in = 0.0254m のように線形単位は係数だけで N×N の変換表なしに任意ペアを変換できる。でも 0°C は 0°F ではない (32°F)。摂氏・華氏・ケルビンは
y = a·x + bのアフィン変換で、係数だけでは表せない。この「温度だけ特例」をどう設計するかが、地味だが本質的なポイント。長さ・質量・温度・面積・速度・データ量を横断する変換器を作った。
🌐 デモ: https://sen.ltd/portfolio/unit-converter/
📦 GitHub: https://github.com/sen-ltd/unit-converter
線形単位: 係数 1 つで N×N 表を消す
長さの変換を素朴に考えると「mm↔cm, mm↔m, mm↔km, cm↔m...」と全ペアの変換式が要りそうに見える。11 単位なら 11×10 = 110 通り。これは破綻する。
解は基準単位を 1 つ決めて、各単位を「基準への係数」で表すこと。長さの基準を m にすると:
{ id: "mm", factor: 0.001 }, // 1mm = 0.001m
{ id: "m", factor: 1 }, // 基準
{ id: "km", factor: 1000 }, // 1km = 1000m
{ id: "in", factor: 0.0254 }, // 1in = 0.0254m
変換は基準単位を経由する 1 本道:
export function convert(categoryId, fromId, toId, value) {
// ...
const base = value * from.factor; // 任意単位 → 基準単位
return base / to.factor; // 基準単位 → 任意単位
}
12 in → ft なら 12 × 0.0254 = 0.3048m、0.3048 / 0.3048 = 1ft。N 個の単位に対して係数 N 個を持つだけで、N×N の変換表が消える。これがディメンショナル変換の基本形。
温度: 係数では表せない
ところが温度で同じことをやろうとすると破綻する。「摂氏の係数は?」が定義できない。なぜなら摂氏と華氏は原点がずれているから:
- 0°C = 32°F (氷点)
- 100°C = 212°F (沸点)
- 差分の比は 100:180 = 5:9 だが、0 同士が対応しない
これは比例 (y = a·x) ではなくアフィン変換 (y = a·x + b)。係数 a だけでなく切片 b が要る。線形単位の factor 1 つでは表現できない。
解決策: 温度カテゴリだけ、各単位に基準への変換関数のペア (toBase / fromBase) を持たせる。基準はケルビン:
{
id: "temperature", base: "K", affine: true,
units: [
{ id: "C", toBase: (c) => c + 273.15, fromBase: (k) => k - 273.15 },
{ id: "F", toBase: (f) => (f - 32) * 5/9 + 273.15, fromBase: (k) => (k - 273.15) * 9/5 + 32 },
{ id: "K", toBase: (k) => k, fromBase: (k) => k },
],
}
変換エンジンは affine フラグで分岐:
if (cat.affine) {
const base = from.toBase(value); // 任意温度 → K
return to.fromBase(base); // K → 任意温度
}
// 線形は係数ルート
const base = value * from.factor;
return base / to.factor;
構造は線形と同じ「基準経由の 1 本道」だが、係数の掛け算が関数呼び出しに変わっただけ。抽象を揃えつつ、表現力だけ上げる設計。
テストで「温度は単純比ではない」を固定
温度の特例性をテストで明示する。一番有名な事実、-40°C = -40°F の交差点:
test("-40°C = -40°F (the crossover)", () =>
approx(convert("temperature", "C", "F", -40), -40));
test("0°C = 32°F", () => approx(convert("temperature", "C", "F", 0), 32));
test("100°C = 212°F", () => approx(convert("temperature", "C", "F", 100), 212));
// 「もし係数だけで実装してたら 0°C→0°F になってしまう」を防ぐ回帰テスト
test("affine is NOT a simple ratio: 0°C→F is 32, not 0", () => {
assert.notEqual(convert("temperature", "C", "F", 0), 0);
});
最後のテストが効く。うっかり温度を線形係数で実装すると 0°C → 0°F になる。それを「0 ではないこと」で固定しておくと、リファクタで温度を線形扱いに戻してしまう事故を防げる。
カタログの自己検証
データ駆動なので、カタログ自体の整合性もテストする:
test("linear base unit has factor 1", () => {
for (const cat of CATEGORIES) {
if (cat.affine) continue;
assert.equal(getUnit(cat, cat.base).factor, 1);
}
});
test("affine units' toBase/fromBase round-trip", () => {
const temp = getCategory("temperature");
for (const u of temp.units) {
for (const v of [-40, 0, 37, 100]) {
approx(u.fromBase(u.toBase(v)), v); // 往復で元に戻る
}
}
});
「基準単位の係数は 1」は線形カテゴリの不変条件。これを破ると全変換がずれるので、カタログに新単位を追加したときの安全網になる。アフィンは「fromBase(toBase(x)) === x」のラウンドトリップで関数ペアの整合性を担保。
ついでに対応した細かい単位
汎用変換器なので、日常で「あれ、これいくつ?」となる単位を入れた:
- 尺貫法: 尺 (0.303m)・里 (3927m)・坪 (3.306m²)・畳 (1.653m²)・匁 (3.75g)・貫 (3.75kg)
-
データ量の 10³ vs 2¹⁰: KB (1000) と KiB (1024) を別単位として区別。
1 KB ≠ 1 KiBもテスト - 速度のマッハ: 海面・15℃ の音速 340.29 m/s
test("1 KB (10³) ≠ 1 KiB (2¹⁰)", () => {
assert.notEqual(
convert("data", "kb", "byte", 1),
convert("data", "kib", "byte", 1));
});
test("2 tatami ≈ 1 tsubo", () =>
approx(convert("area", "tatami", "tsubo", 2), 1, 1e-3));
「畳 2 枚 = 1 坪」が変換で出るのは、データに正しい係数が入っている証拠。
設計
units.js ← 単位カタログ (係数 + アフィン関数) (DOM-free)
convert.js ← 変換エンジン + 数値フォーマット (DOM-free, 40 tests)
app.js ← UI glue
UI はカテゴリタブ + 2 つのドロップダウン + 「全単位への変換」テーブル。convert.js は DOM 非依存なので 40 個のテストが全部 Node で走る。
試してみる
温度タブで -40 を入れると、摂氏も華氏も -40 になる交差点が見られる。データ量タブで 1 GB と 1 GiB の差 (約 7.4%) も。
まとめ
- 線形単位は基準への係数 1 つで表すと N×N 変換表が消える。
値 × from係数 ÷ to係数。 - 温度はアフィン変換 (
y = a·x + b)。原点がずれている (0°C ≠ 0°F) ので係数では表せず、toBase/fromBase関数を持たせる。 - 構造は線形・アフィンとも「基準経由の 1 本道」で揃え、係数の掛け算を関数呼び出しに置き換えるだけ。
- 「0°C→F が 0 ではない」を回帰テストにすると、温度を線形に戻す事故を防げる。
- カタログ駆動ならカタログ自体の不変条件 (基準係数=1、往復一致) もテストする。
- KB/KiB、尺貫法など「単位の細かい違い」こそ汎用ツールの価値。
これは SEN 合同会社の OSS ポートフォリオ #265 です。https://sen.ltd/portfolio/
