無駄と文化

実用的ブログ

色記法を雑に正規化する (#000 とか rgb() とか hsl() とか)

CSS での色記法には様々な記法が認められています。

例えば私が好きなコーンフラワーブルーは、

  • cornflowerblue
  • #6495ed
  • rgb(100, 149, 237)
  • hsl(218.54, 79%, 66%)
  • hwb(218.54 39% 7%)
  • lab(61.2% 2.4% -40.2%)

これらどの記法でも同じ色が表示されます。

ぜんぶ同じ色!

mdn の <color> のページを見ると、他にも lch(), oklab(), oklch() などの記法があるようです。

とはいえ手っ取り早く RGB で知りたいよ

とはいえプログラムから色を扱っていると「なんでもいいから RGB で教えてくれ」と思いますよね?
これらの色記法をどうにかして RGB 形式に変換してみます。

どの色記法であれブラウザが受け入れてくれてるということはブラウザの API で正規化できるってことです、たぶん。

style 属性に書き込んで読み出す (ボツ案)

手始めに適当な要素の style 属性に書き込んで、それを読み出してみます。

const div = document.createElement('div');

div.style.color = '#6495ed';
console.log(div.style.color); // => rgb(100, 149, 237)

div.style.color = 'hsl(218.54, 79%, 66%)';
console.log(div.style.color); // => rgb(100, 149, 237)

やった!

遊んでたらできちゃった!

が、このやり方では <named-color> には対応できません。

const div = document.createElement('div');

div.style.color = 'cornflowerblue';
console.log(div.style.color); // => cornflowerblue

ダメだ。もうちょっと頑張ってみましょう。

 

style 属性に書き込んで getComputedStyle() で読み出す (ボツ案)

getComputedStyle() ってやつを使います。ブラウザの開発者ツールで見られる "Computed Style" を返してくれる関数です。

const div = document.createElement('div');
document.body.appendChild(div);

div.style.color = '#6495ed';
console.log(getComputedStyle(div).color); // => rgb(100, 149, 237)

div.style.color = 'cornflowerblue';
console.log(getComputedStyle(div).color); // => rgb(100, 149, 237)

やるじゃん!ちゃんと <named-color> に対応できました。
ただし document.body.appendChild(div) しているのでページに影響を与えます。丁寧にやるならこんな感じかな。

const div = document.createElement('div');
document.body.appendChild(div);
div.style.display = 'none';

div.style.color = 'cornflowerblue';
console.log(getComputedStyle(div).color); // => rgb(100, 149, 237)

document.body.removeChild(div);

実はこれでもまだ color() 関数記法 ってやつに対応していないんです。

const div = document.createElement('div');
document.body.appendChild(div);

div.style.color = 'color(srgb 0.392 0.584 0.929)';
console.log(getComputedStyle(div).color); // => color(srgb 0.392 0.584 0.929)

表示はできるけど色記法はそのまま

おそらく色を決定するためのプロファイルが環境依存で、プロファイルを定めないと color() 記法と RGB の値が一意にならないからだろうね。
うーむ残念。次いきましょう。

 

canvas を fillRect で塗って getImageData で読み出す

1px × 1px の canvas を作って塗ったり読んだりします。

const canvas = document.createElement('canvas');
canvas.width = canvas.height = 1;
const ctx = canvas.getContext('2d');

ctx.fillStyle = 'color(srgb 0.392 0.584 0.929)';
ctx.fillRect(0, 0, 1, 1);

const [r, g, b, a] = ctx.getImageData(0, 0, 1, 1).data;
console.log(a === 255
  ? `rgb(${r}, ${g}, ${b})`
  : `rgba(${r}, ${g}, ${b}, ${a / 255})`);
// => rgb(100, 149, 237)

結局 canvas かー。

関数化する

今回試した3つのやり方をそれぞれ関数化したものがここにあります。
どれも DOM API を前提としているのでブラウザ以外のランタイム (Node.js, Deno) などでは動きません。
あと、Chrome 以外のブラウザでの動作確認もしていません。

gist.github.com

まとめ

結局 canvas かー。style 属性でうまいこといけると思ったんだけどな。

 

 

私からは以上です。

要素を入れ替えたときにスクロール位置が追従することがあるみたい

JavaScript を使って画面上の要素を入れ替えたとき、スクロール位置が勝手に動いて画面がカクつくという現象に遭遇しました。調査して分かったことをまとめます。

まずはデモをご覧ください

todays-mitsui.github.io

カラフルな <div> が並んでいて、右上のボタンを押すとすぐ上の要素と位置が入れ替わるデモを作りました。

ボタンを押すとすぐ上の要素と入れ替わる

ここまではなんの変哲もありません。ところが、まったく同じコードでも要素が大きくなると違う挙動になります。

すぐ上の要素と入れ替わる、と同時にスクロール位置がシフトする

ボタンを押すと同時にスクロール位置がシフトしているのが分かります。背景に敷いてある「01, 02, 03, 04, ...」の数字に着目すると分かりやすいかも。

この記事ではどのような条件でスクロールシフトするのか調べてみます。

目次

3行まとめ

  • 要素の高さは無関係
  • 移動した後の要素の上端がビューポートからはみ出すか、ビューポートの上端に近いとスクロールが追従する
  • すぐ下の要素と入れ替える処理ではもっと不可解な挙動になる
  • CSS の overflow-anchor プロパティ で制御できる

解決策だけ見たい人は スクロール調整 (スクロールアンカリング) を無効にする方法 を見てください。

デモのすべてのコードは GitHub に置いています。 → https://github.com/todays-mitsui/swap-element-lab

要素入れ替えのコード

今回のデモは React で組んでいます。要素のレンダリングはのような感じです。

function App() {
  const [colors, setColors] = useState(['#ff6e61', '#4a8a98', '#fff2c2', ...]);

  return (
    <>
      {colors.map((color, index) => (
        <div style={{ backgroundColor: color }} key={color}>
          <h3>{color}</h3>
          <button type="button">↑</button>
        </div>
      ))}
    </>
  );
}

要素に背景色 (background-color) をつけているのは入れ替わりをわかりやすくするためで、スクロール位置がずれる挙動には直接関係ありません。

ここに要素入れ替えの処理を追加します。

const handleSwap = (i: number) => {
  const newColors = [...colors];
  [newColors[i], newColors[i-1]] = [newColors[i-1], newColors[i]];
  setColors(newColors);
};

素朴に i 番目 と i-1 番目 を入れ替えてから setColors しているだけです。

二つを合わせるとこんな感じになります。

function App() {
  const [colors, setColors] = useState(['#ff6e61', '#4a8a98', '#fff2c2', ...]);

  const handleSwap = (i: number) => {
    const newColors = [...colors];
    [newColors[i], newColors[i-1]] = [newColors[i-1], newColors[i]];
    setColors(newColors);
  };

  return (
    <>
      {colors.map((color, index) => (
        <div style={{ backgroundColor: color }} key={color}>
          <h3>{color}</h3>
          <button type="button" onClick={() => handleSwap(index)}>↑</button>
        </div>
      ))}
    </>
  );
}

入れ替え後の要素の top の位置が条件になっているみたい

いろいろと実験してみました。入れ替え後の要素の上端 (top) がビューポートに対してどの位置にあるかによってレイアウトシフトが 起こる/起こらない が変わるようです。

 

要素が高くてもスクロールシフトが起こらないことがある

要素が高くてもスクロールシフトが起こらない場合があります。入れ替え後に要素がビューポートにおさまる位置にあればスクロールシフトは起こりません。

https://todays-mitsui.github.io/swap-element-lab/?height=500&scroll=2100

ビューポートの下のほうにある要素を入れ替え、ビューポートの真ん中に持ってくる

 

要素が低くてもスクロールシフトが起こることがある

逆に、要素が低くても入れ替え後にビューポートの上端付近に来る場合にはレイアウトシフトが起こります。
入れ替え後にも要素が確実にビューポートに入るように調整される、と考えると分かりやすいです。

ビューポート上端に近いと、ビューポートの中ほどにスクロール調整される

 

ここまでのまとめ

  • 入れ替え後にビューポートにおさまっていればスクロールシフトはおこらない
  • 入れ替え後にビューポートからはみ出しているか端すぎればスクロール位置が調整される

このように考えると理解しやすいですね。

「下の要素と入れ替える」という動作にするともっと不可解なことが起こる

先ほどまではボタン押下で上の要素と入れ替えるという動作のもと実験していました。これをボタン押下で 下の要素 と入れ替えるにすると、もっと理解しがたい挙動になります。

 

入れ替え後に要素がビューポートからはみ出していてもスクロールシフトしない

大きめの要素をすぐしたの要素と入れ替えます。入れ替え後に要素はビューポートの下のほうにわずかに見えるばかりですがスクロール調整は起こりません。

https://todays-mitsui.github.io/swap-element-lab/?height=500&scroll=2000&direction=down

入れ替え後に要素がビューポート下部に置かれるがレイアウトは調整されない

 

スクロールシフトの結果、要素が下のほうに吹っ飛ぶ

元の要素を見失ってしまいそうなスクロールシフトが起こることもあります。

元の要素がビューポートより下に吹っ飛んでいるように見える

整理します。

  • 入れ替えられた2要素のうち、入れ替え後に上にあるものに着目する
  • 上の要素の上端がビューポートの上端に近ければ、ビューポートに充分におさまるように下にスクロール調整する
  • 結果として入れ替え後に下にある要素はより下に移動しビューポートから完全に見えなくなる

スクロール調整 (スクロールアンカリング) を無効にする方法

このような挙動は スクロールアンカリング という機能によるものだそうです。この挙動を無効にする方法があります。
CSS の overflow-anchor プロパティ を使います。{ overflow-anchor: none; } を指定した要素は入れ替えによって位置が変わってもスクロール調整の対象になりません。

 function App() {
   const [colors, setColors] = useState(['#ff6e61', '#4a8a98', '#fff2c2', ...]);

   return (
     <>
       {colors.map((color, index) => (
-         <div style={{ backgroundColor: color }} key={color}>
-         <div style={{ backgroundColor: color, overflowAnchor: "none" }} key={color}>
           <h3>{color}</h3>
           <button type="button">↑</button>
         </div>
       ))}
     </>
   );
 }

※ コード例示の都合で style タグを使っています。普通に CSS で設定すれば OK です

今回のデモでは「スクロール位置固定」というオプションに ✅ を入れることでこの挙動を体験できます。

https://todays-mitsui.github.io/swap-element-lab/?height=550&scroll=6000&fixed=true

まとめ

  • 入れ替えられた2要素のうち、入れ替え後に上にあるものに着目する
  • 上の要素の上端がビューポートの上端に近ければスクロール調整されることがある
  • overflow-anchor プロパティでこの挙動を無効化することは可能

こんな感じかなー。たぶん。

 

この記事の公開後すぐに id:nanto_vi さんから「それってスクロールアンカリングの影響では?」と情報をもらったので スクロール調整 (スクロールアンカリング) を無効にする方法 セクションを加筆修正しました。ズバリの情報ありがとうございました。

 

 

私からは以上です。

wasm から JavaScript に文字列を受け取る with TinyGo

JavaScript から wasm の関数を呼び出して結果の文字列を受け取る方法を書き残します。

この記事は二本立てです

前編はこちら。

blog.mudatobunka.org

前後編のコードを全部まとめたリポジトリがここにあります。

github.com

前提知識

まずは 前の記事 にも挙げた前提知識をおさらいしましょう。

  • wasm に文字列型は無い
  • 文字列のバイト列表現は言語によってさまざま
  • コンパイル元の言語と文字列のバイト列表現を揃える必要がある

詳しくはこちらを参照してください。

wasm から JavaScript に文字列を受け取る

まずは wasm のコンパイル前のコードを見てみましょう。
今回は TinyGo で wasm をコンパイルします。なぜ TinyGo かと云うと //go:wasmimport ディレクティブが使えるからです。

Go のコード

gist.github.com

wasm には文字列型が無いために Go から string をそのまま return することはできません。代わりにポインタとバイト列の長さの組みを返して、JavaScript 側で指定した領域の線形メモリの読むことで文字列を受け渡します。
ところが2025年1月現在は wasm にコンパイル可能な関数にかなりの制限があります。なんと複数の値を返すことができません。苦肉の策としてポインタを返す関数とバイト列の長さを返す関数を別々に定義します。

// 複数の関数 `returnString()`, `getBufSize()` で状態を共有するためのグローバル変数
var bufSize uint32

//go:wasmexport returnString
func returnString() unsafe.Pointer {
    str := "やっほー From WebAssembly!🤟"

    // 文字列をバイト列に変換
    buf := []byte(str)

    // バイト列のサイズを取得
    // getBufSize() から参照できるようにグローバル変数に保存
    bufSize = uint32(len(buf))

    // バイト列の先頭のポインタを取得して返す
    // 本当は多値 `uint32(ptr), bufSize` を返したいが
    // 2025年1月現在 TinyGo は単一の uint32 しか返せない
    return unsafe.Pointer(&buf[0])
}

//go:wasmexport getBufSize
func getBufSize() uint32 {
    return bufSize
}

returnString がポンタを返します。getBufSize はバイト列の長さを返します。
二つの関数の間で状態を共有するためにグローバル変数 bufSize を定義しておいて、都度書き込んだり読み出したりします。くそダサい実装ですね。仕方ない。

JavaScript のコード

続いて JavaScript から文字列を読み出しましょう。

gist.github.com

// wasm から文字列をメモリに書き込み、その開始位置のポインタを受け取る
const ptr = wasm.exports.returnString();

// wasm から文字列の長さを受け取る
const len = wasm.exports.getBufSize();

前述したとおり TinyGo では一度に複数の値を返せないので、二つの関数を立て続けに呼び出して1つずつ値を受け取ります。
ちなみにこれは wasm の制約ではなく TinyGo コンパイラの制約です。wasm では (result u32 u32) のようにして多値を返すことは可能です。

// wasm モジュールは線形メモリを exports.memory として露出している
// このメモリをやりとりに使う
const memory = new Uint8Array(wasm.exports.memory.buffer);

wasm モジュールは外界とデータをやりとりするために線形メモリを exports.memory としてモジュールの外に露出しています。このメモリから文字列を読み取りたいのでまずは Uint8Array に変換します。

// ptr, len で指定された範囲のバイト列を読み取る
const utf8str: Uint8Array = memory.slice(ptr, ptr + len);

// メモリには UTF-8 形式で書き込まれているので
// デコードして JavaScript の文字列 (UTF-16 形式) に変換する
return new TextDecoder().decode(utf8str);

memory のうち ptr ~ ptr + len の範囲を切り取り、TextDecoder でデコードします。
Go では文字列が UTF-8 形式でエンコードされています。一方で JavaScript では UTF-16 形式で文字列を扱っているためにエンコード変換が必要です。

実行結果

デモコードは deno で実行することを想定して書きました。deno で実行してみます。

$ deno run --allow-read receiveStringFromWasm.ts
やっほー From WebAssembly!🤟

いい感じです。

注意点

今回のデモコードにはメモリの取り扱いの不備があります。
Go からローカル変数のポインタを返していますが、このメモリは Go の GC によって (JavaScript 側で参照する前に) 回収されてしまう恐れがあります。このあたりは Go の GC と wasm の仕様の噛み合わせが悪い部分ですね。

まとめ

wasm から JavaScript へ文字列を受け取る方法を見てきました。
Go 側で多値を返せないせいでとてもダサいコードになっていましたね。今後に期待。

 

 

私からは以上です。