ClockHub
ブログお問い合わせ
記事一覧
プロダクト11 分で読めます

ClockHub アイコンの設計思想:16 ピクセルから逆算した文字盤

ClockHub

古い柱時計の文字盤。CC0、Wikimedia Commons より

アンティーク時計の文字盤のクローズアップ。ローマ数字、唐草模様の彫刻、透かし彫りの針、金属面に一本の反射

この写真の要素は、ひとつもアイコンに残っていない。

唐草も、ローマ数字も、透かしもない。最終的に書き出したのは 2 種類の配色、それぞれ 14 ファイル、そのうち最小のものは 16×16 の正方形で、中身は 12 本の線と 2 本の針と赤い点がひとつだけ。

これは「ミニマルな作風」を選んだという話ではない。単に好みの問題なら、もう少し凝った形にしてもよかった。この図形を決めたのは、もっと硬い制約だった。16 ピクセルの中で見分けられなければならない。

だからこの記事はひらめきの話ではなく、逆算の話だ。16 ピクセルから遡って、各段階がどう追い込まれたのかを書く。

I. 正方形の中に目盛りはいくつ入るか

デザインのキャンバスは 512×512、設計半径は R=200。16 ピクセルに縮めると比率は 1:32。

つまりすべてを 32 で割る。

  • 文字盤の設計直径 400 は、16 ピクセルでは 12.5 ピクセル
  • 目盛りの外側の半径 190 は、5.94 ピクセル
  • 文字盤の見える幅は、12 ピクセルに満たない

これが全予算だ。余りも、削れる余白もない。

では目盛りはいくつ入るか。円周を出せばわかる。半径 5.94 ピクセル、円周 37.3 ピクセル。

目盛りの数隣り合う間隔
600.62 px
241.55 px
123.11 px

実際に 16×16 ピクセルで描画して 14 倍に拡大した 3 つの文字盤。左は目盛り 60 本で塗りつぶされた輪になり、中央は 24 本で太い点の連なりになり、右は 12 本で目盛りが分離して見える。それぞれの下に 0.62、1.55、3.11 ピクセルの間隔を表示

いずれも 1.1 ピクセルまで太らせた状態。変数は目盛りの数だけ

チューリッヒ中央駅の有名なスイス鉄道時計は、文字盤に 60 本の目盛りを持つ。時刻用の太い 12 本と、分用の細い 48 本。このデザインの系譜の頂点だが、その 60 本を比率どおり 16 ピクセルに押し込むと、隙間が埋まって一本の輪になる。時計だとすらわからない。

ここから、この記事でいちばん大事な一文が出てくる。

線の太さは太らせられる。間隔は太らせられない。

幅 0.6 ピクセルの線は、1.1 にも 2 にも太らせられる。太らせても線は線のままだ。だが 2 本の線のあいだの 0.62 ピクセルという隙間は、どんな手段でも広げられない。あいだにあるものを取り除く以外には。

目盛りが 60 から 12 になったのは、12 が好きだからではない。16 ピクセルには 12 しか入らないからだ。

II. 引き算の一覧

形は一度で決まっていない。アイコンの調整ツールには 3 世代のパラメータが残っていて、変更した値はすべて scripts/icon/tuner.html に記録してある。

版目盛り幅針の幅中心円時針の角度分針の角度
参考画像1516.5なし10°125°
細い版(前バージョン)4.04.8r = 50°120°
決定版1012r = 120°120°

(長さと幅は半径 R に対する百分率。10 なら線幅は 200 の 10%、設計上 20 ピクセル。)

3 世代の文字盤を並べた比較。左は参考画像版で目盛りも針も最も太く中心円なし、中央は細い版で線が非常に細く中心円も小さい、右は決定版で太さが中間で中心円がはっきり見える。それぞれの下に 16 ピクセルの縮小描画を添付

3 世代のパラメータと、それぞれの 16 ピクセルでの見え方

最初の版は参考画像どおりに作った。太い目盛り、太い針、中心円なし。大きなサイズでは重みがあったが、近くで見ると鈍い。そこで細い版を作った。目盛り 4.0、針 4.8、線はきれいで、中心円は r=5 まで絞った。3 つのうちでいちばん美しいのは細い版だ。

そしてそれは 16 ピクセルで消えた。

「見劣りする」のではない。そもそも描けない。細い版の目盛り幅 4.0 は、16 ピクセルでは 0.25 ピクセル。太らせる計算は 4.4 倍をはじき出すが、上限は 3.2 倍で止まる。目一杯太らせても 0.8 ピクセルで、下限の 1.1 ピクセルには届かない。

決定版は一歩戻した形だ。目盛り 10、針 12、中心円 r=12。大きなサイズでは細い版ほど優雅ではないが、16 ピクセルで「12 本の目盛りと 2 本の針」と読めるのはこれだけだった。

教訓はこうだ。美しさは、あるひとつのサイズにおける局所最適にすぎない。 判断は最小のサイズで下して、最大のサイズでの結果は受け入れるしかない。

III. 時刻を告げない文字盤

決定版の時針は 0°、分針は 120° を指している。この文字盤が示す時刻は 12:20。

そして 12:20 は実在しない時刻だ。本物の時計が 20 分を指すとき、時針はすでに 12 から三分の一だけ離れた 10° の位置にある。参考画像の版は 10° で、そちらが「正しい」。

時針を 0° に引き戻したのは、12 時の赤い目盛りと同じ半径に乗せるためだ。中心から上へ、白い針の下半分と赤い目盛りが一本の線になる。遠目には、先端だけ赤く染まった針に見える。

分針を 125° から 120° へ動かしたのも同じ理屈だ。0 も 120 も 30 の倍数なので、2 本の針先がちょうど目盛りの上に乗る。時針は 12 時の赤い目盛りに、分針は 4 時の目盛りに。参考画像の版は 2 本とも目盛りの間に止まっていた。10° は 12 時と 1 時のあいだ、125° は 4 時と 5 時のあいだ。

この 2 つの決定は、実は 16 ピクセルとは関係がない。 そのサイズでは、5° は分針の先を 0.35 ピクセル、10° は時針の先を 0.49 ピクセル動かすだけ——見えない。これは 220 ピクセル以上に向けた構図の決定だ。

だが方向は同じ。グリッドに合わせる。真実には合わせない。 12 本の目盛りがこの図形の唯一の座標系で、針先は目盛りの上に乗るか、目盛りのあいだに落ちるかしかない。前者は「設計された」に見え、後者は「適当に置かれた」に見える。

ついでに、時計広告で有名な 10:10 について。時針が 305°、分針が 60° なので、角度は実際には 115°。これも目盛りの上にはない。私たちはその角度を真似ていない。

代償として、この文字盤は実在の時刻を示さなくなった。

アイコンは時を告げない。 伝えるのは「これは時計だ」であって「今は 20 分過ぎだ」ではない。両者が衝突したとき、可読性が勝ち、正確さが負ける。

IV. 線一本の最小幅

0.625 ピクセルの話に戻る。

幅 0.625 ピクセルの線は、画面の上では細い線ではなく、濃さの違うピクセルの列になる。レンダラは隣り合う 2 ピクセルに半分ずつ配る。墨色であるべき目盛りが、淡い灰色のかすれになる。

scripts/icon/generate.mjs の対処は、最小線幅をそのまま数値として書き下すことだった。

const MIN_PX = 1.1 // 出力ピクセルでこれより細くしない
const MAX_BOOST = 3.2 // ただし 3.2 倍まで。それ以上は塊になる

function boostFor(size) {
  const tickPx = ((SHAPE.tick.width / 100) * SHAPE.R * size) / CANVAS
  const k = MIN_PX / tickPx
  return Math.min(MAX_BOOST, Math.max(1, k))
}

論理は逆順になっている。「このサイズでは何倍に拡大するか」ではなく、「線幅を 1.1 ピクセルにするには何倍必要か」を問う。出た倍数を線幅と線長に掛け戻す。

10 個の出力サイズを全部通すとこうなる。

太らせ係数のグラフ。横軸はアイコンサイズ 16〜512 ピクセル(対数)、縦軸は倍率。16 ピクセルで 1.76 倍、24 ピクセルで 1.17 倍、32 ピクセル以上は正確に 1.00 のまま水平に伸びる

変更されるのは 16 と 24 の 2 つだけ。32 ピクセル以上は一切触らない

触られるのは 16 と 24 だけ。 32 ピクセルでは線幅がすでに 1.25 ピクセルあり下限を超えているので、倍率は正確に 1.00。コードは何もしていない。これがこの仕組みの最も良い性質だ。これは作風ではなく、保険である。

見落としやすい細部がもうひとつある。太らせるとき、長さも一緒に伸ばす。

const grow = 1 + (k - 1) * 0.35 // 太らせて丸点になるのを防ぐため長さも伸ばす
const hubGrow = 1 + (k - 1) * 0.4 // 中心円はより控えめに

線の端がすべて丸(stroke-linecap: round)だからだ。長さ 48、幅 20 の目盛りを、長さはそのままで幅 35 にすると、もう目盛りではなくカプセルになる。もう少し太らせれば円になる。だから幅を 1.76 倍するとき、長さは 1.27 倍。半分強の比率で、「細長い棒」という形をちょうど保てる。

中心円はさらに控えめ(0.35 ではなく 0.4)。もともとほぼ正円なので、これ以上大きくすると 2 本の針の根元を食ってしまう。

このやり方は私たちが発明したものではない。Apple のヒューマンインターフェースガイドラインにははっきり書かれている。「極端に細い線や鋭い角は避けること。小さなアイコンサイズや低解像度では、細部と鮮明さを失いやすい。」Google はさらに進んで、これをフォントの軸にした。Material Symbols の opsz(オプティカルサイズ)は 20dp から 48dp まで動き、「異なるサイズで同じに見えるよう、線幅がサイズに応じて変化する」。

違いはただひとつ。彼らは何組も手で描く。私たちはコードに計算させる。

V. 2 つの下地

アイコンには外円がない。

これは好みではなく、空間の勘定だ。輪を描けば最低 2 ピクセルを食う。16 ピクセルの文字盤では、直径 12.5 ピクセルの 16% にあたる。外せば目盛りを外へ押し出せて、図形全体が一回り大きくなる。

だが外円を外すには代償がある。図形が自分の境界を失う。

輪のあるアイコンは、どんな背景の上でも「円」だ。輪がなくなると、宙に浮いた 12 本の線は既知の下地に寄りかかるしかない。板を敷くか、必ず白の上に来ることを保証するか。

そこで 2 つの配色になり、役割ははっきり分かれる。

ブラウザのタブバーを模した 2 行の比較。1 行目は濃色アイコンで、明るいタブバーでも暗いタブバーでも判別できる。2 行目は淡色アイコンで、明るいタブバーでは白い板が消えて淡い線だけが残り、暗いタブバーではっきり見える

同じ 2 本のタブバーに、それぞれのアイコンを載せたもの

  • dark(黒板に白い針):public/icons/dark/、ブラウザのタブ用。タブバーはブラウザの UI であり、アイコンは自分が明るいテーマに載るのか暗いテーマに載るのか知るすべがない。どちらでも通用する一案を選ぶしかない。黒い板は明るいタブバーでは明確な黒い正方形になり、暗いタブバーでも白い針が浮き上がる。
  • light(白板に黒い針):public/icons/light/、サイトのヘッダー、OG 画像、iOS のホーム画面用。これらの下地はこちらが制御できる。

その判断は src/lang/seo.ts の一行にある。

icons: { icon: '/icons/dark/favicon.ico', apple: '/icons/light/icon.svg' }

favicon は dark、apple-touch-icon は light。前者は未知の背景に単独で立ち向かい、後者は iOS ホーム画面の淡い壁紙の上に載る。

サイトのヘッダーはさらに第三の形を使う。clock_mark.tsx の plate は既定で false で、板を描かず、墨色の線をそのまま白いヘッダーに置く。同じ幾何、3 つの載せ方。濃い板、淡い板、板なし。

iOS 版にはもう一つある。透明にしてはいけない。 iOS はアイコン内の透明部分を黒で埋めるので、背景が透明なアイコンはホーム画面で黒い正方形になる。180 ピクセルの apple-touch-icon は板ごと描いている。宙に浮いた線の集まりではない。

VI. なぜスクリーンショットで済ませないのか

アイコンのスクリプトは Chrome を呼ばない。rsvg-convert も呼ばない。npm パッケージも入れていない。

自分で 2 つ実装している。ラスタライザと PNG エンコーダだ。

理由はスクリプト冒頭のコメントにある。

アイコンはピクセル単位で正確に、しかも毎回まったく同じに生成される必要がある。外部ツールの拡大縮小の方針、背景色、DPR はどれも結果に影響する。ここでは 4〜8 倍のスーパーサンプリングで自前でラスタライズし、Node 内蔵の zlib だけに依存する。別のマシンで走らせても PNG は 1 バイトも変わらない。

やり方はこうだ。まず 4〜8 倍の解像度でサンプリングする(16 ピクセルのアイコンなら 128×128)。各サブサンプル点がどの図形に入るかを判定して色を決め、8×8 のブロックでボックス平均して 16×16 に戻す。アンチエイリアスは計算されたもので、レンダラのヒューリスティクスではない。

const ss = Math.max(2, Math.min(8, Math.ceil(2048 / size)))

小さい図は多く(16 ピクセルで 8 倍)、大きい図は少なく(512 ピクセルで 4 倍)。絶対的なサンプル密度を 2048 前後に保つためだ。

エンコーダも手書き。PNG の IHDR / IDAT / IEND と CRC32、ICO の ICONDIR と埋め込み PNG。物々しく聞こえるが 100 行に満たない。見返りはサイズで、512×512 が 7 KB、16 ピクセルが 319 バイト。

本当の意味は依存を減らすことではない。アイコンをビルド成果物にすることだ。

コードが生成するアイコンは git に入り、差分が取れ、何度でも走らせられる。数字をひとつ変えれば、2 配色 28 ファイルがまとめて作り直される。デザイナーにもう一度 PNG を頼む必要も、書き出し時にチェックを入れ忘れたか心配する必要もない。アイコンは「一枚の画像」であることをやめ、「実行できる記述」になる。

VII. ひとつの JSON、ふたりの消費者

形の出どころはひとつだけ。src/constants/clock_icon.json。13 行、15 個の数値、1 個の真偽値、3 色。

{
  "canvas": 512,
  "R": 200,
  "plateRadius": 0.16,
  "tick": { "len": 24, "width": 10 },
  "redTick": { "len": 35, "width": 12 },
  "hour": { "len": 45, "width": 12, "angle": 0 },
  "minute": { "len": 65, "width": 10, "angle": 120 },
  "hub": { "r": 12, "width": 3, "fill": true },
  "ink": "#111827",
  "paper": "#ffffff",
  "red": "#ef4444"
}

これを読むものが 2 つある。

  1. scripts/icon/generate.mjs —— 28 個の静的ファイルを生成する
  2. src/app/[lang]/components/clock_mark.tsx —— ヘッダーとフッターの動くロゴ

これがこの仕組みで最も目立たず、最も重要な決まりだ。静的なアイコンとページ上のロゴは食い違えない。 同じ数値から計算しているからだ。

ヘッダーの版はもうひとつ余計なことをする。回るのだ。

ポインタを乗せると 2 本の針が回り始める。分針は毎秒 840 度(約 2.3 回転)、時針は 1:12 のギア比で正確に追従する。離しても急停止せず、「静止角まであと何度回れば戻るか」を計算し、三次イージングで 0.7 秒かけて 12:20 に着地する。

ここには避けられない妥協がある。本物の時計のギア比は 1:12 で、分針が 360° 回るあいだに時針は 30° しか動かない。つまり正確な比率のままでは、2 本の針が同時に元の位置へ戻ることはない。分針が 120° に戻ったとき、時針は 0° にはいない。だから戻る 0.7 秒のあいだだけギアを切り離し、それぞれが最短経路で帰る。見た目にはわからないが、2 本とも正確に元の角度へ収まる。

もうひとつ。システムが「視差効果を減らす」(prefers-reduced-motion: reduce)を要求している場合、針はまったく回らない。

VIII. いちばん小さいインターフェース

16 ピクセルのアイコンは、作れるインターフェースのうちで最小のものだ。

ツールチップも、ホバー状態も、読み込み失敗時の再試行ボタンも、空状態もない。チャンスは一度きり、見分けられた一瞬だけ。誰もじっくり見ないし、覚えもしない。凝った作りだからといって半秒長く留まることもない。十数個のタブから、正しいものを探しているだけだ。

この制約が、装飾的な選択肢をすべて消す。残ったものは自力で立たなければならない。12 本の目盛りが「文字盤」を、2 本の針が「動いている」ことを、赤い点ひとつが「隣のアイコンとは違うもの」を伝える。

あの柱時計の作り手も、たぶん同じことを考えていた。相手は 16 ピクセルではなく直径 50 センチの真鍮板だったから、花を彫り、数字を切り、針を透かす余裕があった。こちらには 16 ピクセルしかなかったので、線を 12 本残した。

同じもの、違う予算。

この線がまともなサイズでどう見えるかは、アナログ時計が同じ幾何で動く文字盤を描いている。時刻をまるごと数字に渡すとどうなるかは、パタパタ時計が別方向からの答えになっている。

ClockHub