分類の関数は色ではなく段階を返す:計算と表示の役目を分ける
もくじ
画面のデザインを作り替えたとき、天気や紫外線の値を「良好」から「危険」までの段階に分ける関数が、その段階を塗る色まで返していると分かった。この作りだと、画面の配色を変えるたびに、計算だけをするはずの関数まで直すことになる。配色を変えるときは見た目のコードだけを直し、値を段階に分けるコードには手を入れずに済ませたい。
関数は、値を受け取って、決まった計算をして値を返すコードのまとまりだ。配色は、画面の地(背景)や文字などに使う色の組み合わせを指す。この記事では、地を明るくする「明るい配色」と、地を暗くする「暗い配色」(ダークモードとも呼ぶ)の2つを扱う。
コードを役目ごとに分ける理由と、この記事が扱う場所
アプリのコードは、役目ごとに分けて書く。コードを役目ごとにどう分けて、どこに何を書くかの決め方を、アーキテクチャと呼ぶ。役目ごとに分けたコードのまとまりは、層と呼ぶ。分けておくと、1つの役目を変えたいときに、その層だけを直せば済む。
例として、架空の天気アプリのコードを役目ごとに分ける。表示の層は画面を描き、計算の層は天気の値を段階に分ける。
表示の層と計算の層の境目では、「雷雨は段階5」と決めるのが計算の層の仕事で、「段階5は赤で塗る」と決めるのが表示の層の仕事だ。前者はアプリの業務ルールで、後者は見た目の好みである。
配色を変えると分類の関数まで直すことになる
僕のアプリの画面は、デザインがダサかった。すりガラスのように後ろが透けてぼやけるカードを、気温で色が変わる背景のグラデーション(色が少しずつ移り変わる塗り方)の上に重ねていた。そのせいで、明るい配色では色が濁り、カードが背景に沈み、データより飾りのほうが目立っていた。天気を見せるための画面が、天気より背景を見せていたわけだ。
そこで、画面を作り替えると決めて、AI(人工知能)に頼んだ。その作り替えの中で、天気や紫外線の値を段階に分ける関数が、段階ではなく色の値を返していると分かった。この作りだと、配色を変えるたびに、値を分けるだけのはずの関数を開いて、中に書かれた色を書き換えることになる。色を変えたいだけなのに、計算のコードに手が入る。だから、分類の関数が段階を返す形に、AI に分け直させた。
悪い例:分類の関数が色の値を返す
架空の天気アプリで、天気の種類を分ける関数が色を返す形を書くと、こうなる。
// 計算の層
export type WeatherCondition = "clear" | "cloudy" | "rain" | "heavy-rain" | "thunderstorm";
// 天気の種類ごとに、塗る色を持っている
const weatherColors: Record<WeatherCondition, string> = {
clear: "#2a9d8f",
cloudy: "#457b9d",
rain: "#e9c46a",
"heavy-rain": "#f4a261",
thunderstorm: "#e63946",
};
export function classifyWeather(condition: WeatherCondition): string {
return weatherColors[condition];
}
画面の部品は、分類の関数が返した色をそのまま文字に使う。
// 表示の層
import { classifyWeather, type WeatherCondition } from "./weather";
export function WeatherBadge({ condition, label }: { condition: WeatherCondition; label: string }) {
return <span style={{ color: classifyWeather(condition) }}>{label}</span>;
}
一見、何も困らない。困るのは、色を変えたくなったときだ。
雷雨の赤を少し落ち着かせたいだけでも、直すのは計算の層の classifyWeather のファイルになる。暗い配色を足すなら、もっと面倒になる。この関数に「今は明るい配色か、暗い配色か」という引数(関数を呼ぶときに渡す値)を加え、色を2組持たせることになる。天気を分けるだけの関数が、画面の地の色まで知っていなければならない。天気予報士に、テレビの背景のセットまで組ませているようなものだ。
分類の関数は1〜5の段階を返し、色は表示の側が決める
分類の関数は色を返さず、1(良好)から5(危険)までの段階の数字だけを返す。段階を何色で塗るかは、画面を描く表示の層が決める。こうすると、配色を変えても、計算の層のコードは直さずに済む。
計算の層が知っているのは、「大雨は段階4」という業務ルールまでだ。段階4が何色なのかは、表示の層だけが知っている。
良い例:段階を色に置き換える対応表を表示の側に置く
計算の層は、段階だけを返す。
// 計算の層
export type WeatherCondition = "clear" | "cloudy" | "rain" | "heavy-rain" | "thunderstorm";
// 1が良好、5が危険
export type Level = 1 | 2 | 3 | 4 | 5;
const weatherLevels: Record<WeatherCondition, Level> = {
clear: 1,
cloudy: 2,
rain: 3,
"heavy-rain": 4,
thunderstorm: 5,
};
export function classifyWeather(condition: WeatherCondition): Level {
return weatherLevels[condition];
}
このファイルには、色が1つも出てこない。
段階を色に置き換える対応表は、表示の層に置く。明るい配色と暗い配色で、それぞれ別の色を持たせる。明るい配色では明るい地の上で読めるように暗めの色を、暗い配色では暗い地の上で読めるように明るめの色を選ぶ。ここに書いた色の値は例で、実際に読めるかは、地の色との差を確かめて決める。
// 表示の層
import { classifyWeather, type Level, type WeatherCondition } from "./weather";
type Theme = "light" | "dark";
// 段階を何色で塗るかは、表示の層が決める
const levelColors: Record<Theme, Record<Level, string>> = {
light: { 1: "#1f7a6d", 2: "#2f5f9e", 3: "#9a6a12", 4: "#b0521f", 5: "#a3262a" },
dark: { 1: "#7fd6c4", 2: "#8fb4ea", 3: "#f0c45a", 4: "#f29a62", 5: "#f07a74" },
};
export function WeatherBadge({
condition,
label,
theme,
}: {
condition: WeatherCondition;
label: string;
theme: Theme;
}) {
const level = classifyWeather(condition);
return <span style={{ color: levelColors[theme][level] }}>{label}</span>;
}
この対応表が、段階の色についての配色の定義になる。配色の定義は、明るい配色と暗い配色のそれぞれで、どの色を使うかを1か所にまとめた所だ。雷雨の赤を変えたいときも、暗い配色の色を変えたいときも、直すのはこの表だけである。classifyWeather は開かなくてよい。
段階は、天気以外の分類にも使い回せる。紫外線の値を分ける関数も同じ Level を返せば、同じ対応表で塗れる。僕のコードでも、天気、紫外線、風の強さなど、いくつかの分類が同じ1〜5の段階を返す形にそろった。分類が増えても、色の表は1つのままだ。
配色の定義の外に残っていたグラデーション16個を消した
段階を返す形に直していたとき、計算の層にもう1種類の色が残っていた。背景のグラデーションである。天気の種類に合わせた12個と、日の出と日の入りに合わせた時間帯(夜、朝焼け、日中、夕焼け)の4個で、合わせて16個ある。どれも、色の値をじかに書いたまま、天気や時間帯を分ける関数の近くに置かれていた。
この16個は、配色の定義の外にあった。配色の定義に入っている色には、明るい配色の値と暗い配色の値の2つがある。じかに書かれた16個には、値が1つずつしか無かった。だから、暗い配色の画面でこれらがどう見えるかは、確かめられていなかった。天気を分けるだけの関数が、確かめたことの無い空の色見本帳まで抱えていたわけだ。
16個は消した。分類の関数は、背景の色を返さなくなった。
分類のテストが色の値を見なくなった
テストは、関数が決めたとおりの値を返すかを確かめるコードだ。計算と色を分けると、分類のテストも変わる。
悪い例:色の文字列を確かめるテスト
import { expect, it } from "vitest";
import { classifyWeather } from "./weather";
it("大雨の色を返す", () => {
expect(classifyWeather("heavy-rain")).toContain("#");
});
このテストが確かめているのは、色らしい文字列が返ることまでだ。大雨がどの段階に分けられたのかは見ていない。しかも、配色を直すついでに色の書き方を変えれば、このテストまで直すことになる。
良い例:段階を確かめるテスト
import { expect, it } from "vitest";
import { classifyWeather } from "./weather";
it("大雨は段階4", () => {
expect(classifyWeather("heavy-rain")).toBe(4);
});
こちらは、分類の関数が本来受け持つ「どの段階に分けたか」を確かめている。配色をいくら変えても、このテストは直さなくてよい。
僕のコードでも、同じことが起きた。紫外線の分類のテストは、返ってきた色の文字列に、色の書き方の名前が含まれるかを確かめていた。段階を返す形に変えたあとは、段階が4かを確かめるようになった。日の出と日の入りの時間帯のテストからも、背景の色の値を含むかを見る行が消え、4つの時間帯の名前(夜、朝焼け、日中、夕焼け)を確かめる形になった。
色を返していたころのテストは、関数が何色を返すかに縛られていた。段階だけを返すようにしたら、テストも計算のことだけを見るようになった。
段階だけを返せば、配色の変更は計算のコードに届かない
値を段階に分ける関数は、色を返さずに1〜5の段階だけを返す。段階を色に置き換える仕事は、画面を描く表示の層の対応表に任せる。そうすると、配色を変えても、明るい配色と暗い配色を足しても、直すのは対応表だけで、計算のコードには手が入らない。テストも、色ではなく段階を確かめるようになる。
関数が返す値に色が混ざっていたら、それは見た目の話が計算の層に入り込んでいる印だ。
おわり😊

