エンジニアのソフトウェア的愛情

または私は如何にして心配するのを止めてプログラムを・愛する・ようになったか

コンソールにQRコードを表示したい

Webアプリケーションを開発しているとき、携帯端末での表示を確認したくなるときがあります。 ブラウザのレスポンシブ・デザイン・モードを利用すれば、デスクトップでも見た目の確認はできますが、やはり手のひらの中でどのように表示されるかを知るには、端末そのものに表示させるのが一番です。

そのようなときにURLを携帯端末に送るため、コンソールにQRコードを表示する簡単なスクリプトを書いてみました。

自分で書かなくても、完成度の高いツールは巷に溢れていると思いますが、今後QRコードをブラウザに表示したり、メールで送信したりする必要に迫られることもないとは言えないので、そのトレーニングの意味合いも込めて。

とは言え、QRコードエンコーディングをすべて書くのは大変なので、そこはパッケージを利用し、表現のところだけ自分で実装しています。

エンコーディングのパッケージは Hex に公開されている qr_code を利用しました。

hex.pm

Elixir 1.12 からは Mix.install/2 が実装されて、パッケージを簡単に利用できるようになりました。 これも、スクリプトを書くハードルを下げてくれた気がします。

# qr.exs

Mix.install([:qr_code])

defmodule QR do
  @white IO.ANSI.light_white_background()
  @black IO.ANSI.black_background()

  def show_as_qr_code(str) do
    IO.puts(str)

    {:ok, %{matrix: matrix}} =
      str
      |> QRCode.create()

    len = length(hd(matrix))

    edge = [@white, String.duplicate("  ", len + 2), @black]

    IO.puts(edge)

    matrix
    |> Enum.each(fn row ->
      IO.write([@white, "  ", @black])

      row
      |> Enum.map(fn
        1 -> [@black, "  "]
        0 -> [@white, "  "]
      end)
      |> IO.write()

      IO.puts([@white, "  ", @black])
    end)

    IO.puts(edge)
  end
end

System.argv()
|> Enum.each(&QR.show_as_qr_code/1)

スクリプトを書いたら elixir コマンドで実行です。

$ elixir qr.exs https://elixir-lang.org

初回だけ、パッケージのインストールが実行された後にQRコードが表示されます。

2回目以降は、すぐに結果を表示してくれるはずです。

Elixirのドキュメントでガードをグルーピングするときの覚書

ドキュメントを生成した時に、defguard で定義するガードをグルーピングするときの設定について、いつも忘れてしまい自分の以前のリポジトリを見返すことがたびたびなので、こちらの覚書として記録しておきます。

ガードと関数をモジュールに記述した場合、

defmodule FizzBuzz do
  defguard is_pos_integer(n) when is_integer(n) and n > 0
  defguard is_fizz(n) when is_pos_integer(n) and rem(n, 3) == 0
  defguard is_buzz(n) when is_pos_integer(n) and rem(n, 5) == 0

  def fizz_buzz(n) when is_fizz(n) and is_buzz(n), do: "Fizz Buzz"
  def fizz_buzz(n) when is_fizz(n), do: "Fizz"
  def fizz_buzz(n) when is_buzz(n), do: "Buzz"
  def fizz_buzz(n) when is_pos_integer(n), do: to_string(n)
end

何も指定せず ExDoc でドキュメントを生成すると、ガードも Functions にまとめられます。

ドキュメントを紐解くと、@doc 属性と :groups_for_functions を指定することで関数を任意のグループにグルーピングできると書かれています。

hexdocs.pm

ドキュメントにはガードへの言及はないのですが、defguard の実装を確認してみると、@doc guard: true のの設定が確認できます。

結論として、mix.exs で次のように :groups_for_functions を指定すると、ガードを独立したグループにグルーピングすることができるようになります。

defmodule FizzBuzz.MixProject do
  use Mix.Project

  def project do
    [
      app: :fizz_buzz,
      version: "0.1.0",
      elixir: "~> 1.13",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      docs: docs() # 追加
    ]
  end

  # ...中略...

  # 追加
  defp docs do
    [
      groups_for_functions: [
        Guards: & &1[:guard]
      ]
    ]
  end
end

Elixirでmutableなバイト列を扱う(Rustの力を借りて)

先日のブログに書いた通り、いつ以来かのセルラオートマトンに手を出しています。 そのときの記事の実装では、更新処理に Rust を利用してはいるものの、世代ごとにimmutableなバイト列を生成していました。

Erlang のアロケータを利用するので、確保したメモリの解放忘れのような心配はありませんが、効率を考えるとあまりうれしくない。

というわけで。 今回は、バイト列は Rust で確保し、関数の呼び出しを介して Elixir からそのバイト列を操作してみよう、という回です。

ハンドルあるいはリファレンス

まず一般的な話として。 一方で確保したリソースを、もう一方で利用するばあい、ハンドルを渡すことが常套手段として考えられます。

今回のケースであれば、Rust でメモリ領域を確保し、その情報をハンドルとして Elixir に渡し、Elixir は受け取ったハンドルとその他のパラメータを引数にして Rust の関数を呼び出す、という手順です。

Rustler にはちょうど、Rust でファイルのオープンと読み込みを実装し、それを Elixir から利用するというサンプルが公開されています。 ファイルハンドルを Rust と Elixir の間でやり取りすることで、Elixir から Rust のファイル操作を利用することができるようになっています。

github.com

ちなみに。 ここまでハンドルという言葉を使いましたが、このような用途の値はElixirではリファンレンス型の値として扱われますので、以下リファレンスという言葉で話を進めることにします。

MutableBinary.NIF モジュール

今回も Rust との接続には Rustler を使います。

hex.pm

コード量はそれほど多いわけではありませんが、Elixir と Rust の両方にまたがって実装するので、設定などのためにファイルの数が増えてしまいます。 コード全体は GitHub に上げましたのでそちらを参照ください。

github.com

GitHubリポジトリは、依存パッケージに直接指定することができます。 もし興味がありましたら、新しくプロジェクトを作成して試してみてください。

# mix.exs

  defp deps do
    [
      {:mutable_binary, github: "mattsan/mutable_binary"}
    ]
  end

以降では、注目するコードを抜粋して見ていきたいと思います。

Rust の実装

Rust の実装から見てゆきましょう。

ファイルは、リポジトリnative/mutable_binary_nif/src/lib.rs です。

構造体定義

まず可変なバイト列を格納する構造体を定義します。

後述する初期化のコードで、マクロを利用して Elixir との間で値をやり取りするために必要な種々のコードを生成する…ようです。 ドキュメントの記述が少ないのと、わたしが Rust を学んでいる途中ということで、深いツッコミは今回のところはご容赦を。

struct MutableBinaryResource {
    pub stream: Mutex<Vec<u8>>,
}

バイト列を生成する

先に定義した構造体を利用してバイト列を生成します。

このとき、構造体の値を rustler::resource::ResourceArc という構造体で包みます。 これは、ドキュメントに「std::sync::Arc のようなもの」とあるように、リファレンスカウンタでリソースを管理する構造体のようです。 Drop トレイトが実装されていてガベージコレクションによって解放されるとのこと。

Elixir で扱える形式に変換できる値には rustler::types::Encoder トレイトが実装されていて、encode で変換できるようになっています。

ここでは Rust のタプルを Elixir のタプルに変換するために利用しています。

#[rustler::nif]
fn new(env: Env, size: usize) -> Term {
    if size > 0 {
        let resource = ResourceArc::new(MutableBinaryResource {
            stream: Mutex::new(vec![0; size]),
        });

        (atoms::ok(), resource).encode(env)
    } else {
        (atoms::error(), atoms::out_of_range()).encode(env)
    }
}

リソースを確保できたばあい、アトム :ok とリソースのタプルを返しますが、これを Elixir では アトム :ok とリファレンスの値のタプルとして受け取ります。 確保したバイト列の操作をするときに、このリファレンスを関数の引数として渡します。

指定した位置のバイト値を読み出す

リファレンスとインデクスを引数に受け取り、インデクスの位置のバイトの値を返します。

Elixir が渡したリファレンスは、Rust では元のリソースの形式である ResourceArc<MutableBinaryResource> として受け取ることになります。

この値をロックして元々のバイト列の構造体を取り出します。

インデクスがバイト列の範囲内のばあいは、アトム :ok とバイト列のインデクスで指定されたバイトの値のタプルを encode して返します。

#[rustler::nif]
fn get(env: Env, resource: ResourceArc<MutableBinaryResource>, index: usize) -> Term {
    let resource_struct = resource.stream.try_lock().unwrap();

    if index < resource_struct.len() {
        (atoms::ok(), resource_struct[index]).encode(env)
    } else {
        (atoms::error(), atoms::out_of_range()).encode(env)
    }
}

書き込む、長さを取得する、バイナリとして取得する

これ以外の操作も、基本的に手順になります。

Elixir 側でリファレンスとして渡される ResourceArc<MutableBinaryResource> を受け取り、ロックして Rust の値として取り出し、操作する。

リポジトリに置いたコードには、get の他に set, length, to_string という関数を定義しています。 これらの詳細はリポジトリを参照してみてください。

初期化する

構造体の定義のところで書いたように、マクロを使って必要なコードを生成します。 そしてそのコードを実装に持つ関数を定義します。

fn load(env: Env, _: Term) -> bool {
    rustler::resource!(MutableBinaryResource, env);
    true
}

この関数は、公開する関数と一緒に NIF を初期化する関数に渡されます。

rustler::init!(
    "Elixir.MutableBinary.NIF",
    [new, length, set, get, to_string],
    load = load
);

これで Rust 側の実装は終わりました。

Elixir の実装

次に Elixir の実装です。

ファイルはリポジトリlib/mutable_binary/nif.ex です。

Rust の実装との接続は Rustler が面倒を見てくれるので、Elixir の実装は多くはありません。 Rustleruse しエントリとなる関数を定義するだけで終わってしまいます。 エントリの関数の定義も、単に NIF をロードできなかったばあいのためにエラーを返すだけのシンプルなものです。

defmodule MutableBinary.NIF do
  use Rustler, otp_app: :mutable_binary, crate: :mutable_binary_nif

  def new(_), do: err()
  def length(_), do: err()
  def get(_, _), do: err()
  def set(_, _, _), do: err()
  def to_string(_), do: err()

  defp err, do: :erlang.nif_error(:nif_not_loaded)
end

実演

簡単なスクリプトを書いて実行してみます。

# sample.exs

{:ok, ref} = MutableBinary.NIF.new(16)

MutableBinary.NIF.length(ref) |> IO.inspect(label: "length")
MutableBinary.NIF.to_string(ref) |> IO.inspect(label: "to_string")

MutableBinary.NIF.get(ref, 0) |> IO.inspect(label: "get [0]")
MutableBinary.NIF.set(ref, 0, 123) |> IO.inspect(label: "set [0] <- 123")
MutableBinary.NIF.get(ref, 0) |> IO.inspect(label: "get [0]")
MutableBinary.NIF.get(ref, 16) |> IO.inspect(label: "get [16]")

MutableBinary.NIF.set(ref, 0, 69)
MutableBinary.NIF.set(ref, 1, 108)
MutableBinary.NIF.set(ref, 2, 105)
MutableBinary.NIF.set(ref, 3, 120)
MutableBinary.NIF.set(ref, 4, 105)
MutableBinary.NIF.set(ref, 5, 114)
MutableBinary.NIF.set(ref, 6, 33)
MutableBinary.NIF.set(ref, 7, 32)
MutableBinary.NIF.set(ref, 8, 38)
MutableBinary.NIF.set(ref, 9, 32)
MutableBinary.NIF.set(ref, 10, 82)
MutableBinary.NIF.set(ref, 11, 117)
MutableBinary.NIF.set(ref, 12, 115)
MutableBinary.NIF.set(ref, 13, 116)
MutableBinary.NIF.set(ref, 14, 33)
MutableBinary.NIF.set(ref, 15, 33)

MutableBinary.NIF.to_string(ref) |> IO.inspect(label: "to_string")

実行に先立って、パッケージの取得とコンパイル

$ mix do deps.get, compile

mix runスクリプトを実行します。

$ mix run sample.exs
length: 16
to_string: <<0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0>>
get [0]: {:ok, 0}
set [0] <- 123: :ok
get [0]: {:ok, 123}
get [16]: {:error, :out_of_range}
to_string: "Elixir! & Rust!!"

何も不思議なところはないはずなのですが、Elixir で mutable な値を利用できるというだけで、ちょっと新鮮な感じがしてくるので不思議です。

ちなみに。 GitHub に上げたコードには、MutableBinary.NIF モジュールを背後に隠して、外部とインタフェースする MutableBinary モジュールを用意しています。

いつか読むはずっと読まない:素数が香り、形がきこえる

ライフゲイム Conway's Game of Life で知られる J.H.コンウェイの著作。

新装改題され昨年末に出版されたもの。 書店で偶然見つけ、何かの縁を感じて購入。

Rustlerでコンウェイの Game of Life を書く

かつてセルラオートマトンに魅了されていた時期がありまして。

熱を上げていた時期はそれほど長くはなかったのですが、その後も伴奏のように背景でずっと鳴り続けていました。

最近、セルラオートマトンの新しい本を手に入れて、再び熱が上がってきています。

まずは手始めに。 このところ気に入って使っているElixirと、高速化のためのRustを使って、コンウェイGame of Lifeを再実装してみることにしました。

心臓部をRustで実装する

「Elixirと、高速化のためのRust」、と言っているそばから心臓部はRustでの実装です。

ElixirからRustの実装を利用するにならrustler。 というわけで、rustlerで生成した雛形にGame of Lifeの心臓部、現在の状態から次の状態を生成する部分をRustで実装します。

hex.pm

// native/game_of_life_nif/src/lib.rs

use rustler::Binary;
use rustler::OwnedBinary;

#[rustler::nif]
fn next(field: Binary, width: usize, height: usize) -> OwnedBinary {
    let mut next_field = OwnedBinary::new(width * height).unwrap();

    next_field.fill(0);
    for y in 0..height {
        for x in 0..width {
            let mut count = 0u8;
            let left = (x + width - 1) % width;
            let right = (x + 1) % width;
            let top = (y + height - 1) % height;
            let bottom = (y + 1) % height;

            count += field[top * width + left];
            count += field[top * width + x];
            count += field[top * width + right];

            count += field[y * width + left];
            count += field[y * width + right];

            count += field[bottom * width + left];
            count += field[bottom * width + x];
            count += field[bottom * width + right];

            if field[y * width + x] == 0 {
                next_field[y * width + x] = if count == 3 { 1 } else { 0 };
            } else {
                next_field[y * width + x] = if count == 2 || count == 3 { 1 } else { 0 };
            }
        }
    }

    next_field
}

rustler::init!("Elixir.GameOfLife.Nif", [next]);

現在の状態をあらわすバイナリと幅と高さを受け取り、次の状態をあらわすバイナリを返します。

右と左、上と下が接続している、いわゆるトーラスとしてあつかっています。 Rustも学習途上で、naiveな実装になっていますが、そこはご容赦を。

次にRustの実装を読み込み、関数を呼び出せるようにするためのElixirのモジュールを実装します。

# lib/game_of_life/nif.ex

defmodule GameOfLife.Nif do
  @moduledoc false
  use Rustler, otp_app: :game_of_life, crate: :game_of_life_nif

  def next(_field, _width, _height), do: :erlang.nif_error(:nif_not_loaded)
end

きちんと機能するか試してみます。

次のようなスクリプトを用意します。 見ての通りblinkerの実装です。

# blinker.exs

~w(
  0 0 0 0 0
  0 0 1 0 0
  0 0 1 0 0
  0 0 1 0 0
  0 0 0 0 0
)
|> Enum.into(<<>>, &<<String.to_integer(&1)>>)
|> GameOfLife.Nif.next(5, 5)
|> String.to_charlist()
|> Enum.chunk_every(5)
|> IO.inspect()

これを実行すると、次の状態をえられることが確認できました。

$ mix run blinker.exs
[
  [0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0],
  [0, 1, 1, 1, 0],
  [0, 0, 0, 0, 0],
  [0, 0, 0, 0, 0]
]

入力しやすくする、出力を見やすくする

肝となる部分は GameOfLife.Nif.next/3 ですべてなので、結果のバイナリを次の段の入力にすれば、繰り返した分だけの世代の状態をえることができます。

とはいえ入力や出力を、もっと人が扱いやす形にしたい。

と、いうわけで。 文字の並びから入力となるバイナリを生成するシジルと、結果を文字の並びとして出力する関数を追加してみました。

ついでに GameOfLife.Nif.next/3 へ移譲して GameOfLife.next/3 という自然な記述で呼び出せるようにしました。

defmodule GameOfLife do
  defdelegate next(field, width, height), to: GameOfLife.Nif

  defmacro sigil_F({:<<>>, _meta, [string]}, _) do
    quote bind_quoted: [string: string] do
      for <<c <- string>>, c not in ' \r\n\t', into: <<>> do
        case c do
          ?@ -> <<1>>
          _ -> <<0>>
        end
      end
    end
  end

  def show(field, width, height) do
    for <<c <- field>> do
      case c do
        1 -> "@ "
        _ -> '. '
      end
    end
    |> Stream.chunk_every(width)
    |> Enum.each(&IO.puts/1)

    field
  end
end

先ほどの blinker を次のように書き換えます。

~F は、@1 に、それ以外の文字を 0 としてバイナリを生成します。 空白文字(空白、改行、タブ)は無視するので、自由にスペーシングや改行を含めることができます。

show/3 は、バイナリの内容の 0.1@ で、指定された幅と高さで出力します。 また入力のバイナリをそのまま戻り値とするので、パイプで繋げて次の段の入力にすることができます。

# blinker.exs

import GameOfLife

~F(
  . . . . .
  . . @ . .
  . . @ . .
  . . @ . .
  . . . . .
)
|> show(5, 5)
|> GameOfLife.next(5, 5)
|> show(5, 5)
|> GameOfLife.next(5, 5)
|> show(5, 5)

実行。

$ mix run blinker.exs
. . . . . 
. . @ . . 
. . @ . . 
. . @ . . 
. . . . . 
. . . . . 
. . . . . 
. @ @ @ . 
. . . . . 
. . . . . 
. . . . . 
. . @ . . 
. . @ . . 
. . @ . . 
. . . . . 

定番のgliderも。

# glider.exs

import GameOfLife

~F(
  . @ . . .
  . . @ . .
  @ @ @ . .
  . . . . .
  . . . . .
)
|> show(5, 5)
|> GameOfLife.next(5, 5)
|> show(5, 5)
|> GameOfLife.next(5, 5)
|> show(5, 5)
|> GameOfLife.next(5, 5)
|> show(5, 5)
|> GameOfLife.next(5, 5)
|> show(5, 5)
|> GameOfLife.next(5, 5)
|> show(5, 5)
|> GameOfLife.next(5, 5)
|> show(5, 5)

実行。

$ mix run glider.exs
. @ . . . 
. . @ . . 
@ @ @ . . 
. . . . . 
. . . . . 
. . . . . 
@ . @ . . 
. @ @ . . 
. @ . . . 
. . . . . 
. . . . . 
. . @ . . 
@ . @ . . 
. @ @ . . 
. . . . . 
. . . . . 
. @ . . . 
. . @ @ . 
. @ @ . . 
. . . . . 
. . . . . 
. . @ . . 
. . . @ . 
. @ @ @ . 
. . . . . 
. . . . . 
. . . . . 
. @ . @ . 
. . @ @ . 
. . @ . . 
. . . . . 
. . . . . 
. . . @ . 
. @ . @ . 
. . @ @ . 

悪くない感じ。

最初に書いたようにnaiveな実装なので、ElixirやRustの利点を活かした実装 − 複数のプロセスで処理するとか − をしてみたいと考えています。

いつか読むはずっと読まない:ライフゲイム

en.wikipedia.org

John Horton Conway FRS (26 December 1937 – 11 April 2020) was an English mathematician active in the theory of finite groups, knot theory, number theory, combinatorial game theory and coding theory.

...

On 11 April 2020, at age 82, he died of complications from COVID-19.

熱を上げるきっかけの一つになった本(の新装版)。

自己の責任と能力を的確に把握し…

先日、仕事中で。よいプログラマであるために心に留めていることをプロジェクトのメンバに話す機会がありました。

よくあるためにと意識するものは多々ありますが、かつて伺ったこの話がいつもついて回っています。

  • 自己の責任と能力を的確に認識し…個人として責任を持つ
  • 継続的な学習、能力開発

blog.emattsan.org

当時のブログにも書いたように、これはプログラマのことではなく、看護師の倫理綱領です。

話をした機会に元の綱領を読み返してみました。

www.nurse.or.jp

人々の権利を尊重し、人々が自らの意向や価値観にそった選択ができるよう支援する。

自己の責任と能力を的確に把握し、実施した看護について個人としての責任をもつ。

常に、個人の責任として継続学習による能力の開発・維持・向上に努める。

より質の高い看護を行うため、看護職自身のウェルビーイングの向上に努める。

分野は違えど、規範として心に留めておきたいと、改めて感じます。

省略されたテキストの全体をポップアップ表示する、CSSで

動機

表示領域に対して、表示したいテキストが長い場合に、はみ出す分を省略表示するスタイルを指定する方法が CSS にはあります。

こんな感じ。

これを、省略表示されたばあいに、マウスオーバーで全体をポップアップ表示しよう、という試みです。

1. はみ出た部分を切り詰めて省略記号を表示する

text-overflow の仕様にもあるように、text-overflow, overflow, white-space を組み合わせることで、表示領域を超える場合に省略表示することができます。

developer.mozilla.org

/* 表示幅を指定(カラムごとに幅を変えています) */
.foo .truncateable { width: 5rem; }
.bar .truncateable { width: 7rem; }
.baz .truncateable { width: 10rem; }

.truncateable {
  overflow: hidden;        /* はみ出た部分を表示しない */
  white-space: nowrap;     /* 行を折り返さない */
  text-overflow: ellipsis; /* 省略記号を表示する */
}

ここがスタート地点。

ここからポップアップ表示を実装していきます。

2. マウスオーバーで全体を表示する

行の折り返し禁止を解除すれば文字列全体が表示されます。

:hover 擬似クラスを使って、マウスオーバー時に white-space のスタイルを変更するようにします。

.truncateable:hover {
  white-space: normal;
}

ただし、行を折り返して表示するということは、行方向に表示領域を広げることになるので、その方向にある要素を押しのけてしまうことになります。

3. 複数行をその場所に表示する

他の要素を押しのけないように position: absolute を指定します。 コンテナ要素との相対位置で配置したいので、コンテナ要素に position: relative を指定します。

表示位置の調整はあとで行うことにして、ここではコンテナ要素の上端に合わせて表示するため top: 0 を指定しておきます。

.foo, .bar, .baz { position: relative; }

.truncateable:hover {
  white-space: normal;

  position: absolute;
  top: 0;
}

4. 他の要素よりも“上”に表示する

他の要素よりも上に表示されるように z-index を指定します。 ここでは大雑把な数値を指定しています。 この値は表示内容に合わせて適切に設定することになると思います。

また、下の要素が透過して見えてしまわないように、background-color を設定しておきます。

.truncateable:hover {
  white-space: normal;

  position: absolute;
  top: 0;

  z-index: 10;
  background-color: #fff;
}

5. 枠線を表示する

ポップアップした範囲がわかるように、枠線を表示します。 ここで先に保留した表示位置の調整も行います。

枠線や余白の幅を考慮して表示位置を調整し、元の表示と同じ位置に表示されるようにします。

.truncateable:hover {
  white-space: normal;

  position: absolute;

  background-color: #fff;
  z-index: 10;

  border: solid 1px #ccc;
  border-radius: 5px;
  padding: 5px 10px;
  top: -3px;
  left: -8px;
}

これでポップアップ表示の目的は果たせたのですが、このままでだと省略されていないばあいでもポップアップ表示されてしまいます。 ポップアップとは言っても、表示されるテキストの内容は変化しないので、マウスオーバーで枠線が表示されるように見えてしまいます。

次に、この表示を抑制する方法を考えます。

6. 省略表示されていないテキストをポップアップ表示しない

CSS を使ったこの省略表示は、表示に必要なテキスト幅と表示領域の幅の関係で省略するか否かが決まるため、レンダリングされないと省略されるかされないか決まりません。 そのため事前に静的に省略の要否を決めておくことができません。

そのため、この部分だけ JavaScript を使って動的に解決することにします。

はみ出た領域があるか調べる

表示領域の幅を知るには offsetWidth が、表示に必要なテキストの幅を知るには scrollWidth が利用できます。

developer.mozilla.org

developer.mozilla.org

テキストの幅が表示領域の幅以下のばあい(= 省略表示されていないばあい)、つまり

el.scrollWidth <= el.offsetWidth

のばあいにポップアップ表示をやめればよさそうです。

はみ出た領域がないばあいにポップアップ表示を停止する

ここまで .truncateable にスタイルを適用することで省略表示やポップアップ表示を実現してきました。 逆に言うと、省略表示されていない要素には .truncateable に適用したスタイルは不要ということになります。

ということで。 .truncateable を持つ要素のうち、省略表示されていないものから .truncateable を削除することで、ポップアップ表示停止を実現します。

document.querySelectorAll('.truncateable').forEach((el) => {
  if (el.scrollWidth <= el.offsetWidth) {
    el.classList.remove('truncateable')
  }
})

これで不要なポップアップを停止することができました。

7. コード全容

キャプチャした内容を表示するために利用したコードです。

<!DOCTYPE html>
<html lang="ja">
  <head>
    <style>
      table { border-collapse: collapse; }
      tr { border-top: solid thin #ccc; }
      tr:last-child { border-bottom: solid thin #ccc; }
      th, td { padding: .2rem; }

      .foo--header { width: 5rem; }
      .bar--header { width: 7rem; }
      .baz--header { width: 10rem; }

      .foo .truncateable { width: 5rem; }
      .bar .truncateable { width: 7rem; }
      .baz .truncateable { width: 10rem; }

      .foo, .bar, .baz { position: relative; }

      .truncateable {
        overflow: hidden;
        white-space: nowrap;
        text-overflow: ellipsis;
      }

      .truncateable:hover {
        white-space: normal;

        position: absolute;

        background-color: #fff;
        z-index: 10;

        border: solid 1px #ccc;
        border-radius: 5px;
        padding: 5px 10px;
        top: -3px;
        left: -8px;
      }
    </style>
    <script>
      document.addEventListener('DOMContentLoaded', () => {
        document.querySelectorAll('.truncateable').forEach((el) => {
          if (el.scrollWidth <= el.offsetWidth) {
            el.classList.remove('truncateable')
          }
        })
      })
    </script>
  </head>
  <body>
    <table class="table">
      <thead>
        <tr>
          <th class="foo--header">FOO</th>
          <th class="bar--header">BAR</th>
          <th class="baz--header">BAZ</th>
        </tr>
      </thead>
      <tbody>
        <tr>
          <td class="foo">
            <div class="truncateable">あああああ</div>
          </td>
          <td class="bar">
            <div class="truncateable">いいいいいいい</div>
          </td>
          <td class="baz">
            <div class="truncateable">うううううううううう</div>
          </td>
        </tr>
        <tr>
          <td class="foo">
            <div class="truncateable">ええええええええええ</div>
          </td>
          <td class="bar">
            <div class="truncateable">おおおおおおおおおお</div>
          </td>
          <td class="baz">
            <div class="truncateable">かかかかかかかかかか</div>
          </td>
        </tr>
        <tr>
          <td class="foo">
            <div class="truncateable">きききききききききき</div>
          </td>
          <td class="bar">
            <div class="truncateable">くくくくくくくくくく</div>
          </td>
          <td class="baz">
            <div class="truncateable">けけけけけけけけけけ</div>
          </td>
        </tr>
      </tbody>
    </table>
  </body>
</html>

8. 留意点

コンテナ要素がつぶれるばあいがある

次の画像の表の 2 段目のように、省略されている要素が一つだけのばあい、

ポップアップ表示になると、その要素を格納している要素がつぶれて表示されてしまいます。

今の状態では、コンテナ要素の高さは、内側の要素の高さによって決まっているのですが、 position: absolute が機能することでコンテナ要素の領域を超えて表示されるようになることで、内側の「支え」を失ってコンテナ要素の高さのみで表示されるようになるためです。

これを回避するためには、コンテナ要素のスタイルに height を指定して、常に一定の高さを維持するようにします。

省略表示されていないのに offsetWidth < scrollWidth になるばあいがある

わたしが確認したかぎりの話ですが、計算上小数点以下の端数が発生するばあい、Internet Explorer では省略表示されていないにもかかわらず offsetWidth よりも scrollWidth の方が大きくなることがあるようです。

明確な根拠となる情報を見つけることができなかったのですが、offsetWidth の代わりに getBoundingClientRect().width で小数部を含めた値を取得し、Math.ceil() で切り上げた整数値を利用することで回避することができるようです。

el.scrollWidth <= Math.ceil(el.getBoundingClientRect().width)

これで今のところ不都合は確認できていないのですが、ばあいによってはこのわずかの差が表示に影響を与えることがあるかもしれません。

動的に追加される要素があるばあい、そのつどポップアップ停止の処理を適用する必要がある

ポップアップ停止を実現するために、一旦レンダリングされた後にクラス属性を削除するという手段を取りました。 当然なのですが、動的に要素が追加されるようなばあいには、そのつどこのコードを実行しなければなりません。

動的に追加する要素を組み立てる時点で、省略表示されるかどうかの判定ができれば、クラス属性を付加しない要素を最初から組み立てればできそうな気がしますが、可能かどうかまだ確認できていません。

いつか読むはずっと読まない:私たちは何もしていないのに

傍観者でいることはできない。

ETS (Erlang Term Storage)を利用する(今後は、たぶん)

ETS とは

Erlang/OTP には ETS というストレージライブラリが標準で用意されています。 当然 Elixir でも利用できます。

www.erlang.org elixirschool.com

ただ、ETS の問い合わせには match specification と呼ばれる構造を利用する必要があります。

www.erlang.org

たとえば。 「三つ組のタプルの、3 番目の値が 2 より大きいものの、1 番目のデータを集める」 という問い合わせをしたい場合、問い合わせには次のようなデータを用意しなければなりません。

[{{:"$1", :_, :"$2"}, [{:>, {:length, :"$2"}, 2}], [:"$1"]}]

これは構造としては次のような関数と同じです。

fn {a1, _, a2} when a2 > 2 -> a1 end

こうして見せられればなるほどとわかるのですが、最初から迷わずに書ける自信がありません。

個人的にはこの記述をするのがおっくうで、 データの格納先に ETS を利用することはあまり考えていませんでした。

それが最近になって ets:fun2ms/1 という関数の存在を知りました。 昔からある関数なのですが、ETS を利用する頭がなかったので、そのような便利なものがあることも知らず過ごしていた始末。

iex(1)> :ets.fun2ms(fn {a1, _, a2} when a2 > 2 -> a1 end)
[{{:"$1", :_, :"$2"}, [{:>, :"$2", 2}], [:"$1"]}]

よし、これで使える! …と思ったのも束の間。

IEx 上では利用できるものの、モジュールに組み込んで実行するとエラーになってしまいます。

defmodule Foo do
  def run do
    :ets.fun2ms(fn {a1, _, a2} when a2 > 2 -> a1 end)
  end
end

Foo.run() |> IO.inspect()
$ elixir foo.exs
** (exit) exited in: :ets.fun2ms(:function, :called, :with, :real, :fun, :should, :be, :transformed, :with, :parse_transform, :or, :called, :with, :a, :fun, :generated, :in, :the, :s
hell)
    ** (EXIT) :badarg
    (stdlib 3.17.1) ets.erl:608: :ets.fun2ms/1
    foo.exs:7: (file)
    (elixir 1.13.3) lib/code.ex:1183: Code.require_file/2

詳しい理由とかは、この記事を読んでいただくとして。

elixirforum.com

悲しい気分で記事を読み進んでいたわけですが。

実は関数から match specification へ変換する Elixir 製のパッケージが提供されているとのこと。 これもけっこう昔から。

Ex2ms

hex.pm

Ex2ms には fun/1 というマクロが 1 つ定義されています。 import すれば、ほぼ関数と同じ書き方で match specification を取得することができます。

iex(1)> Mix.install([:ex2ms])
:ok
iex(2)> import Ex2ms
Ex2ms
iex(3)> fun do {a1, _, a2} when a2 > 2 -> a1 end
[{{:"$1", :_, :"$2"}, [{:>, :"$2", 2}], [:"$1"]}]

match specification を簡単に取得する方法が見つかった以上、ETS を使わない理由がなくなりました。

と、いうわけで、さっそく書いてみました。

ETS を Ex2ms で使う

気象庁の過去の気象データ検索を利用して、東京の 2022 年 2 月の日々の気温データを取得しました。

www.data.jma.go.jp

このデータを ETS のテーブルに格納して、いろいろ検索してみます。

Mix.install([:ex2ms])

defmodule JMA do
  import Ex2ms


  # [気象庁|過去の気象データ検索](https://www.data.jma.go.jp/obd/stats/etrn/index.php) で取得した
  # 東京の 2022年2月の1日ごとの平均気温,最高気温,最低気温
  @temperatures [
    {~D[2022-02-01], 5.6, 11.2,  1.1},
    {~D[2022-02-02], 5.5, 11.1,  0.9},
    {~D[2022-02-03], 5.8, 11.8,  0.6},
    {~D[2022-02-04], 4.9,  8.5,  2.4},
    {~D[2022-02-05], 3.5,  9.2,  0.3},
    {~D[2022-02-06], 2.3,  8.2, -1.9},
    {~D[2022-02-07], 4.5,  9.7, -0.5},
    {~D[2022-02-08], 5.3,  9.1,  2.2},
    {~D[2022-02-09], 6.2, 10.8,  1.8},
    {~D[2022-02-10], 2.3,  6.1,  0.6},
    {~D[2022-02-11], 4.1,  9.2,  0.7},
    {~D[2022-02-12], 4.7,  9.9,  0.2},
    {~D[2022-02-13], 3.1,  5.0,  0.8},
    {~D[2022-02-14], 3.9,  8.0,  0.8},
    {~D[2022-02-15], 5.5, 11.6,  1.1},
    {~D[2022-02-16], 6.0, 11.8,  1.3},
    {~D[2022-02-17], 4.6,  9.7,  0.5},
    {~D[2022-02-18], 5.7, 11.4, -0.2},
    {~D[2022-02-19], 5.0,  8.9,  1.5},
    {~D[2022-02-20], 5.9, 10.0,  2.4},
    {~D[2022-02-21], 4.1,  8.7,  1.2},
    {~D[2022-02-22], 4.4, 10.0, -0.5},
    {~D[2022-02-23], 4.4, 10.3,  0.1},
    {~D[2022-02-24], 4.8, 10.1,  1.7},
    {~D[2022-02-25], 6.5, 13.4, -0.1},
    {~D[2022-02-26], 8.6, 14.7,  3.0},
    {~D[2022-02-27], 9.5, 18.5,  3.2},
    {~D[2022-02-28], 9.8, 15.9,  4.5}
  ]

  def run do
    # テーブルを作成する
    tid = :ets.new(:jma, [])

    # 気温データを投入する
    @temperatures
    |> Enum.each(&:ets.insert(tid, &1))

    IO.puts("\n最低気温が氷点下の日のデータ")
    :ets.select(tid, fun do {_date, _avg, _max, min} = t when min < 0 -> t end)
    |> IO.inspect()

    IO.puts("\n最高気温と最低気温の差が15度を超える日のデータ")
    :ets.select(tid, fun do {date, _avg, max, min} when (max - min) > 15 -> {date, max, min, max - min} end)
    |> IO.inspect()

    IO.puts("\n最高気温と最低気温の差が15度を超える日のデータ(結果をキーワードリストで取得する)")
    :ets.select(tid, fun do {date, _avg, max, min} when (max - min) > 15 -> [date: date, max: max, min: min, diff: max - min] end)
    |> IO.inspect()
  end
end

JMA.run()

実行。

$ elixir jma.exs

最低気温が氷点下の日のデータ
[
  {~D[2022-02-06], 2.3, 8.2, -1.9},
  {~D[2022-02-22], 4.4, 10.0, -0.5},
  {~D[2022-02-07], 4.5, 9.7, -0.5},
  {~D[2022-02-18], 5.7, 11.4, -0.2},
  {~D[2022-02-25], 6.5, 13.4, -0.1}
]

最高気温と最低気温の差が15度を超える日のデータ
[{~D[2022-02-27], 18.5, 3.2, 15.3}]

最高気温と最低気温の差が15度を超える日のデータ(結果をキーワードリストで取得する)
[[date: ~D[2022-02-27], max: 18.5, min: 3.2, diff: 15.3]]

なかなかいい感じ。

いまさらながら ETS の便利さを感じたできごとでした。

いつか読むはずっと読まない:先入観

ETS を使わなければならない状況にならなかったというのもあってか、「問い合わせを書くのが面倒だなぁ」という思い込みを持ったままずっと来てしまいました。

そう感じるのは、たいていは、自分だけではないはずなので、それを解消する方法がすでにあるはず、と思い至ればよかったのですが。 一度思い込んでしまうと、なかなかそれを払拭できないものですね。