3Dをエンジンを作りたい(その2)

前回のコードから一部を修正したものがこちらになります。(修正部分周辺のみ載せます)

function project(v: Vertex): Point {
  return { x: v.x, y: v.y };
}

function render() {
  clear();
  cube.forEach(face => draw(
    project(face.v1),
    project(face.v2),
    project(face.v3),
  ));
  requestAnimationFrame(render);
}

projectというのは「投影」という意味で、3次元の点を2次元に投影しているわけですね。前回と同様に奥行きを捨てているだけです。

奥行きを考慮した描画にする

奥行きを考慮するというのは、要するに、カメラの位置から奥に(zが大きく)移動すれば移動するほど画面の中央に描画されるということです(消失点が画面中央にあれば)。それを踏まえて、奥行きを考慮した描画にしてみましょう。

// 箱の位置を微修正
const w = 10, h = 10, d = 10, p = { x: 0, y: 0, z: 50 };

// zが大きくなればなるほど、ratioが小さくなる
function project(v: Vertex): Point {
  const ratio = 200 / v.z;
  return { x: v.x * ratio, y: v.y * ratio };
}

奥行きがある

ご覧ください。奥行きがあります。奥のほうにある頂点は、より画面中央に寄っていますね。

まとめ

奥行きを考慮した描画にすると、とても迫力のある映像になりました。次は、カメラを移動できるようにしてみましょう。

以上です。よろしくお願いします。

3Dエンジンを作りたい

ゲーム作りたいですか?作りたいですよね。 私はゲームを作りたいとは思っていませんが、ゲームを作るための技術を学びたいと思っています。

そこで、まずは3Dをいちから描画できるようになるための技術をやってみることにしました。

データ構造を考える

3Dなので、縦横奥行きの三次元を表すことができるデータが必要です。3Dデータは基本的に頂点の集まりであるため、頂点をデータとして表す必要があります。x, y, z をもつデータがあればよいでしょう。また、頂点同士を繋いで面を表現します。これもデータとして必要です。面を表すにはは最低でも3点必要ですね。アプリケーションによっては4点以上で面を構成する場合もあるようですが、場合分けをしたりするのが面倒なので、3点しかないものとします。

type Vertex = { x: number, y: number, z: number };
type Face = { v1: Vertex, v2: Vertex, v3: Vertex };

とりあえず、箱を用意する

表示する前に、データを用意する必要があります。箱を用意しましょう。見づらくて申し訳ありませんが、これが箱を表すデータです。w, h, dがそれぞれ幅、高さ、奥行きを表します。pは中心点です。箱には6枚の面がありますが、1枚の面は4つの頂点で構成されています。上でも書きましたが、今回のプログラムでは、3点で1枚の面を表現します。したがって、四角形の面を表現するには、2枚の面が必要になるため、箱を表現するには12枚の面が必要です。

const w = 10, h = 10, d = 10, p = { x: 0, y: 0, z: 0 };
const cube: Face[] = [
  { v1: { x: p.x - w, y: p.y - h, z: p.z - d }, v2: { x: p.x + w, y: p.y - h, z: p.z - d}, v3: { x: p.x - w, y: p.y - h, z: p.z + d } },
  { v1: { x: p.x + w, y: p.y - h, z: p.z - d }, v2: { x: p.x + w, y: p.y - h, z: p.z + d}, v3: { x: p.x - w, y: p.y - h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y - h, z: p.z + d }, v2: { x: p.x + w, y: p.y - h, z: p.z + d}, v3: { x: p.x - w, y: p.y + h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y + h, z: p.z + d }, v2: { x: p.x + w, y: p.y - h, z: p.z + d}, v3: { x: p.x + w, y: p.y + h, z: p.z + d } },
  { v1: { x: p.x + w, y: p.y - h, z: p.z - d }, v2: { x: p.x + w, y: p.y + h, z: p.z - d}, v3: { x: p.x + w, y: p.y - h, z: p.z + d } },
  { v1: { x: p.x + w, y: p.y + h, z: p.z - d }, v2: { x: p.x + w, y: p.y + h, z: p.z + d}, v3: { x: p.x + w, y: p.y - h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y + h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z + d}, v3: { x: p.x + w, y: p.y + h, z: p.z - d } },
  { v1: { x: p.x + w, y: p.y + h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z + d}, v3: { x: p.x + w, y: p.y + h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y - h, z: p.z - d }, v2: { x: p.x - w, y: p.y - h, z: p.z + d}, v3: { x: p.x - w, y: p.y + h, z: p.z + d } },
  { v1: { x: p.x - w, y: p.y - h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z + d}, v3: { x: p.x - w, y: p.y + h, z: p.z - d } },
  { v1: { x: p.x - w, y: p.y - h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z - d}, v3: { x: p.x + w, y: p.y - h, z: p.z - d } },
  { v1: { x: p.x + w, y: p.y - h, z: p.z - d }, v2: { x: p.x - w, y: p.y + h, z: p.z - d}, v3: { x: p.x + w, y: p.y + h, z: p.z - d } },
];

これを、canvasに描画する

それでは、箱をcanvasに描画しましょう。canvasに線を描画する方法などは説明しませんが、まあ雰囲気でわかると思います。まずは、とにかくなにかを描画したいという意志のもと、z座標をオミットして、x, y座標のデータのみをcanvasに表現してみるという手法です。width/2とかheight/2とかやっているのは、座標の原点(x = 0, y = 0)をcanvasの中央にしたかったからです。画面の中央に箱が表示されることを目指しています。

const canvas = document.querySelector("canvas")!;
const context = canvas.getContext("2d")!;
const width = canvas.width;
const height = canvas.height;

cube.forEach(face => {
  context.beginPath();
  context.strokeStyle = "black";
  context.moveTo((width / 2) + face.v1.x, (height / 2) - face.v1.y);
  context.lineTo((width / 2) + face.v2.x, (height / 2) - face.v2.y);
  context.lineTo((width / 2) + face.v3.x, (height / 2) - face.v3.y);
  context.closePath();
  context.stroke();
});

描画したぞ

わかりますかね・・・?黒い線で四角に斜め線が入っていますね。失敗ではありません。これで成功です。やりました。3Dのキューブを真横から見た映像を描画できました。

奥行きが感じられませんね。それもそのはず、Orthographic cameraと呼ばれる、奥行き情報を捨てたカメラで撮影しているからです。(z座標のデータを捨てたのは、つまり奥行き情報を捨てているのです)

ほんとに箱を描画しているのか?そう思う気持ちもわかります。先を急ぎましょう。

箱を回転させてみる

ただの四角でないことを理解するためには、箱を回転させればよいでしょう。回転を見れば3Dっぽいのがわかると思います。まずは、箱を回転させるための計算を書きましょう。これです。targetは回転させたい点、centerは回転の中心、thetaとphiは回転角です。説明はできません。インターネットに書いてあったものなのでさっぱりです。まあ、ともかくとして、回転させてみましょう。

// 回転関数
function rotateVertex(target: Vertex, center: Vertex, theta: number, phi: number) {
  const ct = Math.cos(theta), st = Math.sin(theta), cp = Math.cos(phi), sp = Math.sin(phi);

  const x = target.x - center.x,
        y = target.y - center.y,
        z = target.z - center.z;

  target.x = ct * x - st * cp * z + st * sp * y + center.x;
  target.z = st * x + ct * cp * z - ct * sp * y + center.z;
  target.y = sp * z + cp * y + center.y;
}

// cubeを回転させるには、すべての頂点を座標0, 0, 0を中心に回転させればよい。
cube.forEach(face => {
  rotateVertex(face.v1, { x: 0, y: 0, z: 0 }, Math.PI / 180, Math.PI / 300);
  rotateVertex(face.v2, { x: 0, y: 0, z: 0 }, Math.PI / 180, Math.PI / 300);
  rotateVertex(face.v3, { x: 0, y: 0, z: 0 }, Math.PI / 180, Math.PI / 300);
});

アニメーションさせる

と、いっても、アニメーションさせないと見た目がわからないですよね。雑に書くとこうです。

// 回転も、setTimeoutなどを使って、定期的に行いましょう。

function clear() {
  context.fillStyle = "#fff";
  context.fillRect(0, 0, width, height);
}

type Point = {x: number, y: number}
function draw(v1: Point, v2: Point, v3: Point) {
  context.beginPath();
  context.strokeStyle = "black";
  context.moveTo((width / 2) + v1.x, (height / 2) - v1.y);
  context.lineTo((width / 2) + v2.x, (height / 2) - v2.y);
  context.lineTo((width / 2) + v3.x, (height / 2) - v3.y);
  context.closePath();
  context.stroke();
}

function render() {
  clear();
  cube.forEach(face => draw(face.v1, face.v2, face.v3));
  requestAnimationFrame(render);
}

render();

回転したぞ

できました。

いったんまとめ

雑ではありますが、これが3Dを描画する方法です。やる気が続けば、次は奥行きをもつ箱も描画できるようにしていければと思います。

ここまでのコードは以下のコミットにあります。

github.com

以上です。よろしくお願いいたします。

地図上の「指定した地点から n km以内にある何か」を検索する機能を作ってみた

なんのことだかわからないと思われる方もいらっしゃると思いますので、以下のリンクを張っておきます。試してみてください。 (ドメインも、SSLも面倒でまだやっておりません・・・。)

http://152.69.199.50/ リポジトリこちらです。

こちらの記事は、作ったときにやったことやハマったことの覚え書きです。

モチベーション

家探しアプリで地図の中心から n km 以内にある物件を探す、みたいな機能がありますよね。あの機能ってどうやって高速に検索してるんだろうなーと思ったわけです。Elasticsearchの使い方も少しずつわかってきたし、それを使ったアプリを作ってみたいというのもありました。

なので、 GoogleMapを使って、地図の中心から指定した半径にある「なにか」を検索し、地図上に表示する。 というアプリケーションを作ってみることにしました。

なにか を決めた

ぶっちゃけ、地図上でどんなデータを検索するのでもよかったわけです。やりたいのはESを使って高速に地図上の範囲を検索することですから、サンプルデータはなんでもよかったのです。ただ、個数は欲しい。100個や200個ではESの高速さを実感できないと思うので、データの量は多ければ多いほど嬉しい。また、座標で検索するので、座標データは必須でした。Google MapにあるGeocoding APIを使えば住所から座標を引いてくることができるようなのですが、課金が怖いのでやめました。

色々調べてみた結果、このデータを使うことにしました。ある程度大量(日本全国を網羅して27万件)のデータであれば見た目も面白くなるだろうという考えもありました。もっと面白そうなデータがあれば置き換えたいですね。

ローカルでESを立てた

Dockerを使ってローカルでESを立てました。特にいうことありません。

ESにデータを入れた

データをぶちこむ処理をスクリプトにしました。

並列処理で実行するようにしていて、ローカルでは特に問題なしでした。が、本番環境としてOracle Cloudの無料枠にサーバー立ててたんですが(メモリ1GB)データ投入しようとすると、メモリが足りず・・・。ということで、並列処理しつつ、一度に処理する件数に制限を設けるようにしました。

データを入れる際にESのクライアントも自前で作りました。どういうリクエストをすることで検索やインデックスの作成ができるのか理解を深めることができました。

注意したこととしては、データを入れる前に refresh_interval を -1(リフレッシュしない)に設定し、入れ終えたらデフォルト値に戻す、という処理をいれたところです。これをしないと、データを入れる際に結構処理時間がかかるというのがありました。いちいちリフレッシュ処理が挟まって1リクエスト1秒くらいかかってたかも。気のせいかもしれませんが。

また、本番環境はメモリが1GBしかなく、ESに256MBしか分けてあげられませんでした。このため、リフレッシュをせずにデータを投入しまくると、メモリが足りなくて死んじゃう、という問題がありました。詳しくはわかってないのですが、リフレッシュするまではオンメモリにデータを持っているのかなと思いました。で、リフレッシュすると、ディスクに保持するのかなと。

検索の方法を考えた

まあ、単純に座標の周辺を範囲として設定して検索しただけです。ここが工夫ポイントなのかなーとか思っていたのですが、特に問題なく。ESでなく、DBでも似たようなことができたかもしれないなと思っています。最初は、座標の ±0.01° とかで計算していましたが、特に問題ありませんでした。

GoogleMapで表示できるようにした。

検索するしくみがある程度できたので、GoogleMapの中心座標を検索できるようにしてみました。今回、@react-google-maps/apiを使ったのですが、メチャ簡単でとてもよいです。驚くほど簡単にマーカーを表示したり範囲を示したりすることができました。中心座標を検索するにあたり、スクロールイベントが走る度に検索してると負荷がすごいので、debounceを使って負荷を低減しています。

ただ、ひとつ困っていることとしては、検索してマーカーを更新しようとすると、GoogleMapそのものも再レンダリングすることになるため、スクロール途中でもドラッグがキャンセルされてしまうところがあります。 まだ真面目に追っかけていませんが、スクロールしようとすると結構な頻度で引っ掛かりが発生して、けっこうウザいです。これはなんとかしたい。

検索の範囲をメートル単位で指定できるようにした

ブログに書きたかったのはここからです。これがなかなか難しく、面白かったポイントでもあります。ただまあ、数学が苦手だったのであまり深くは考えられておらず、ググるなどで解決しました。

ちょっとだけ理解したことを書いておくと、まず10メートル北に進む、10メートル東に進む、というのはそれぞれ緯度(latitude)・経度(longitude)の計算になりますが、計算式が異なります。緯度、経度の図を見てもらうとわかりますが、北極に近づくと、経度の線同士は近づきます。つまり、赤道周辺と、北極・南極周辺の経度1°あたりの距離は異なるわけです。一方で、緯度の計算は地球の丸さを考慮しなければいけないのはともかくとして、それ以外は考慮しなくてよいので、どこにいても1°は 約111.111...m というふうに計算できます。

まずは、中央の座標と一辺の長さを指定した正方形から、四隅の座標を計算するところから始めました。四隅が計算できれば、ESに範囲を食わせて検索を行います。

計算は(このあたり)https://github.com/naoki-tomita/map-searcher/blob/master/api/Domain.ts#L85-L92でやっているのですが、この計算式をどうやって導出したかというと、もうChatGPTに教えてもらいました。ほんとにすごいですねChatGPT。こちらの拙い説明でよくその回答が出せたなと感心しました。

ChatGPTに教えてもらう

続いて、範囲を正方形から円に変えることを考えました。四隅の座標で検索したあと、中央からマーカーへの距離を計算して、その距離が半径よりも大きければ範囲外(円の外にある)と考えられます。半径よりも小さければ円の内側にあると言えます。では、地球のとある座標からとある座標までの距離はどのように計算できるでしょうか?私はさっぱりわからなかったので、ChatGPTに教えてもらいました。

座標間の距離を計算する方法

ハーバーサインの公式を使うそうです。ハーバーサインさんはすごいですね。そして、この回答が出せるChatGPTもすごいです。少し苦労しましたが、結果として座標と半径を指定した範囲内に含まれる場所のリストを高速に検索できるようになりました。

私がここで工夫したことといえば、検索APIをDDDで表すことができないかと思って色々やってみたことです。ただ、数学の基礎知識が弱く、うまくドメインに落とし込めたかというとそうでもない気がします。計算処理などは、ドメインを使わず素のデータで計算しちゃってますし。でもまあ、これをドメインに落とし込むと、さらに複雑な形になって、複雑度が増すので、やらない方がよさそうだとも思いました。

Oracle Cloundにデプロイした

Oracle Cloud Computingを使うと、AMD CPUを積んだ仮想マシンを2台まで無料で利用できます。1台あたりメモリは1GBと貧弱ですが、1台でも十分にdocker composeで今回のアプリケーションをデプロイできる程度の性能はあります。 ただ、ESを使うにあたり、いくつか対処したことがあるので、書いておきます。

デフォルトではメモリが足りず(おそらく)、起動しない

おそらくというのは、何も設定せずに起動すると、一行警告ログを吐いて、黙ってアプリケーションが落ちてしまうからです。警告ログもメモリに関することでもなかったため、トラブルシューティングはなかなか難航しました。マシンのメモリがしょぼいのはわかっていたので、メモリに関係あるだろうということで、ESが使用できるメモリを大幅に制限したところ、起動するようになりました。

データを投入する際にもメモリが足りない場合があった

まず、データ投入するプログラムの実行でメモリが足りず死にました。これはあまり何も考えずにプログラムを書いたのも良くなかったのですが、並列でESにリクエストを送るという処理を書く際に、データの数だけラムダ関数を生成するような実装になっていました。データが20万件あるので、同じ数だけラムダ関数が生成されることになります。関数を使えばメモリが解放されるとはいえ、これはメモリを食う書き方だったようで、メモリが足りなくなってしまいました。対処としては、ラムダ関数の生成の上限が10000件になるようにプログラムを書きました。10000件ずつ処理するようにしたわけです。

次に、ESそのものがメモリが足りず死にました。#ESにデータを入れた でも書きましたが、リフレッシュを行わずにデータを溜め込んでいるとよくないようで、途中でメモリが足りなくなり、ESが死にました。回避する方法として、10000件ごとに明示的にリフレッシュを入れるようにしました。

ESのデータを保存するため、Volumeマウントしていたが、なんかうまくいかなかった

なぜか、VolumeマウントしたディレクトリにESのユーザーがアクセスできず、死んでいました。ググってみると、起動時にchownするといいよ!みたいなことが書いてあったのですが、そこまでするのは面倒だなと思い、Volumeマウントをやめました。ここはもう少し調べたほうがいいなと思いました。

今後やりたいこと/issue

上限1000件を取得できるようにしているが、なんか1000件取れないことが多い

ESの理解が浅いからかもしれませんが、上限1000件と設定して、検索範囲を広げても、1000件もデータが取得できないことが多いです。1000件以上あるはずなのになーと思うんですが、900件前後しか取得できないことが多いです。さらには範囲を途方もなく広げた場合になぜか検索結果が0件になってしまうことがあります(北海道だけなぜか取れたりするのも謎)。このあたりも是非とも調べて解決したいところです。

データ投入でダウンタイムが発生しないようにする

今は初回のデータ投入でおしまいなのであまり気にしていませんが、データの更新は仕組み上、データを消してから入れる方式にしています。データが入りきるまで4000秒程度かかります。つまり、ユーザーにとっては1時間程度のサービス停止になるわけです。これはよろしくありません。ESの運用を正しく知るためにも、エイリアスを張ってエイリアスの付け替えでダウンタイムゼロでデータを投入できるようにしたいと考えています。

ESの _source を使わない

DBを持たないアプリケーションであるため、座標や表示名をESから取得した _source から取り出して使っています。しかし、そもそも _source を使うのは邪道であり、ESから取得する検索結果はidのみを使うのが正式とされています(という認識)。ここはきちんとSQLiteでよいのでデータを用意して、 _source をつかうのをやめたいです。

緯度、経度が0度をまたぐところでも検索できるようにする

データがないのでできませんが、0度をまたぐところで、うまく検索ができるのか見ものです。数値がマイナスになったときに、うまく検索できるのか(いまの範囲検索だと無理)というところは、グローバルなアプリケーションを作るためにも対応が必須でしょう。今回は不要ですが、やる必要があると思います。

以上

ESの勉強のために色々とやってみましたが、なかなか勉強になったなと思います。(どちらかというと、地図上の検索という部分の知識がついた) 今後もこのアプリを改良できればと思っています。

以上です。よろしくお願いいたします。

CSSをパースしよう

ここでは、自作のCSS in JSライブラリを作るにあたりCSSをパースする必要が出てきたため、CSSのパースを実装したことについて記載しています。

モチベーション

以前、 zheleznaya というフロントエンドライブラリを作りました。実用性には欠けるものですが、自分で作ったツールというのはかわいいものです。

ところで、このライブラリはReactのようにJSXを使って書くことができます。しかし、スタイルを当てるには、一般的なcssを書いてアプリケーションでclassを指定するか、直接styleを書くかの2択しかありませんでした。そこで、CSS in JSができる何らかの仕組みを取り入れよう、と考えたわけです。

どんなライブラリにするか?

パッと思いつくのはstyled-componentsのようなライブラリでした。

大昔にhyperappのstyled-componentのようなライブラリ( hyper-styled )を作ったことがあったので、何となく作り方がイメージできました。当時は、SCSSのようなネストされたスタイルを扱えず、 &::before などの擬似クラスに対応していただけでした。これを正規表現で取り出していたので、まあなんというかちゃんとしたユースケースには対応できない代物だったわけです。そのような中途半端なライブラリにするのは嫌でした。

今回は以下のように方針を決めました。

  • styled-components みたいなライブラリとする
  • ある程度、styled-componentsのような形でネストされたスタイルにも対応する
  • 速度は無視してもよい
  • 外部ライブラリは使わない

作る

まずは、CSSをパースするところから始めました。一文字一文字読んで、大まかにパースする、という方針です。対応するのは、通常のスタイル、ネストされたスタイル、メディアクエリの3種類としました。以下のようなCSSをパースできるものとしています。

display: flex;
justify-content: center;

div.app {
  color: red;

  h1.title {
    font-size: 3em;
  }
}

@media (min-width: 1024px) {
  div.app {
    ...
  }
}

これを紐解いて、ネストしたスタイルを分解した上でユニークなセレクターをつけることでブラウザー上で利用できるスタイルに変換をかけられます。

ちなみに、大まかにパースする、というのは、要は display: flex とか justify-content: center; とか一個いっこのスタイルには注目せず、まとまりをブロックとして扱える程度にパースするということです。 そう考えると、上記のスタイル定義は以下のような形にパースできます。

display: flex;
justify-content: center;

div.app {
  // このブロックの中に注目すると、このブロックの中も一つのスタイル定義と見ることができる。
  color: red;

  h1.title {
    // この中もスタイル定義としてパースできる
    font-size: 3em;
  }
}

// つまり、以下のようなブロックを取り出すことができ、これを再度パースできる
color: red;
h1.title {
  font-size: 3em;
}

@media (min-width: 1024px) {
  // このブロックの中は、やはり上の例と同様に一つのスタイル定義としてパースできる
  color: blue;
  div.app {
    // この中もスタイル定義としてパースできる
    ...
  }
}

なるほど。つまり、ベタ書きされたスタイルと <selector> { ... }, @media(<style>) { ... } をうまく抽出する処理を書き、{ ... } の内部を再帰的にパースすれば、きれいにパースできそうですね。

書きました。 パーサーと言いながら、パースして変換までかけてしまっているので、微妙な感じですね。

やっていることは、メディアクエリの中身をパースして取り出す、取り出したものを再帰的にパースする、ネストされたスタイルの中身をパースして取り出す、再帰的にパースする、パースが完了した部分を削除して、残ったものがベタ書きされたスタイルと判定する。という感じです。随分乱暴ですが、これでも割と動きます。ただ、例えば、@keyframesに対応するには、それ用のパース処理を書き直さなければならず、中の実装は似たような感じなのに共通化が難しいといった問題があり、微妙だなと感じていました。

パース方法を変える

文字を一文字ずつ読んでやるやり方では、なかなかうまく実装できませんでした。(もう一回作り直せばもう少しうまく作れる気はしますが)

styled-componentsがCSS変換くんとして利用しているライブラリ stylis ではこのような実装となっており、まあ、自分には作れる気がしませんでした。

ところで、テキストをパースして何か別の形に変換するというのはプログラミング言語も同じですね。プログラミング言語だと コード -> トークン -> Ast -> (機械語, 別の形のコード) のような流れで処理が行われたりします。TypeScriptをJavaScriptに変換するというのも、TypeScriptコード -> トークン -> Ast -> JavaScriptコード という流れで処理が行われています(たぶん)。

もとのパース処理だとトークン分割処理を端折って、Astを直接作って、別の形のCSSに変換するまでをCSSParserが担っていたわけです。Astの構築が微妙だったので、煩雑な実装になってしまったわけですね。このAstを構築する処理をもっといい感じにやれる方法があります。

BNF記法を使います。

BNF記法とは、以下のような人間に読みやすく作られた文法を定義するためのメタ言語です(ググりました)。

記法は色々ある(方言が色々あります)と思うのですが、正しく記述することは重要ではない(ただの設計書なので)ので、オレオレ記法でいきます。以下のようなものがBNF記法で記述されたCSSの定義の一部です。

// StyleはLocalStyle, NestedStyle, MediaStyle, KeyframesStyleのいずれかが0個以上並んでいる。
Style ::= (LocalStyle | NestedStyle | MediaStyle | KeyframesStyle)*

// LocalStyleとは Identifier、コロン、Identifiers、セミコロンが並んだものを指す
LocalStyle ::= Identifier ":" Identifiers ";"

// Identifierは正規表現で .... と表すことができる文字列である。
Identifier ::= /a-zA-Z0-9.../

// IdentifiersはIdentifierが一つ以上スペースをあけて連なるものである
Identifiers ::= Identifier | (Identifier " " Identifiers)

// NestedStyleはSelectorとBlockが連なるものである
NestedStyle ::= Selector Block

// Blockは { } に囲まれてAnyが0個以上並んでいる
Block ::= "{" Any* "}"

// AnyはLocalStyleかNestedStyleである
Any ::= LocalStyle | NestedStyle

...

とまあ、こんな感じで書けます。あとは、一つ一つの定義をパースしてAstを吐き出す関数を実装していくだけでよろしい。どう実装するかはアレですが、以下のように書けるでしょう。

function parseIdentifier(text: string): Ast {
  const result = text.match(/よい正規表現/);
  return {
    type: "Identifier",
    value: result[0]
  }
}

function parseLocalStyle(text: string): Ast {
  const styleNameAst = parseIdentifier(text);
  // identifierの次の文字が ":" であることをチェック
  // ":" より後ろの文字列を perseIdentifiers でパースする
  // Identifiersの次の文字が ";" であることをチェック
  return {
    type: "LocalStyle",
    name: styleNameAst,
    values: parseIdentifiersResult,
  }
}

こんな感じです。パースに失敗したらどうするんじゃい、とか、今文字列のどの位置を見ているのかわからんのでは?など色々と思うところはあると思いますが、その辺は個別に工夫するポイントだと思います。ちなみに、私は以下のような形とすることにしました。

type ParsedResult<T extends Ast> = {
  ast?: T;
  remaining: string;
}

要は、パースできればastにAstをセットして、remainingにパース後の残った文字列をセットします。パースできなければ、remainingに引数で渡されたテキストをそのまま渡す感じです。パース後の残った文字列ですからね。 このへんなどはわかりやすいのではないかと思います。 パースできなかった場合は引数をそのまま返す、というのは、バックトラック法を実現するための一つのアイデアだと思います。

"@media" で始まる文字列かをチェック。次に "(" がくるかをチェック。次に、Identifierがパースできるかチェック・・・といった感じで実装しています。ですが、以前の実装と比べると、随分わかりやすいのではないでしょうか。また、記述を見るだけで、この実装だと、正しくメディアクエリをパースできないことにもすぐに気づけるはずです。メディアクエリに出てくる media-type が存在しないものとして、実装しています。BNFにもmedia-typeの存在はどこにも記載されていません。個人的にはあまり使わないからいいか、ということで落としています。実装がよりシンプルになるので、よいのです。

まとめ

CSSをパースすることができるような気がしてきたのではないでしょうか。この方法がいいかは知りませんが(多分無駄な処理が多くあるので遅いのでしょう)、自前でパーサーを書く必要が出てきたらまずは使える方法だと覚えておくとよいでしょう。 ぜひ、使ってみてください。

以上です。よろしくお願いいたします。

JSXとお友達になろう

JSX書いてますか?ReactとともにJSXを書くことがあるかと思いますが、ReactなどのライブラリなしでJSXを使ってなにかすることは少ないのではないかと思います。

今回は、JSXを依存ライブラリなしで動かして、HTMLを出力させてみようと思います。以下の流れで説明していきます。

  1. Reactを使ってHTMLを出力する
  2. Reactを使ったときのトランスパイル後のコードを見る
  3. Reactを捨てて、自分でReactの穴を埋める
  4. JSXと仲良くなれる

Reactを使ってHTMLを出力する

まずは、Reactを使ってHTMLを出力できるようにしてみましょう。早速で恐縮ですが、今回はJSXではなくTSXで説明をしていきます。TypeScriptはTSXに対応しており、細かいことを考えなくてよくて楽なので。JSXでやりたい場合は、Babelなどを使っていただければと思います。

さて、まずは以下のコードをご覧ください。React + ReactDOMServerでHTMLを文字列としてconsole.logに吐くというプログラムです。簡単ですね。ブラウザの開発者ツールからconsole.logにどんなHTMLが出力されているか見てみるとよいでしょう。

Reactを使ったときのトランスパイル後のコードを見る

ここからの説明でもさきほどのサンプルプロジェクトをまだ使います。

CodePenの下に View Compiled というボタンがありますので、そちらを押してどのようなJSが吐かれているか見ることができます。見ていただくとわかるのですが、以下のような変換が行われています。

<div>
  <span>foo</span>
</div>

// ↓

React.createElement("div", null, 
  React.createElement("span", null, 
    "foo"
  )
)

コード中にHTMLタグのようなもの(JSX)を見つけたら、それを React.createElement という関数に食わせた形でJSに変換する。これこそがJSXの本質と言えます。

FacebookによるJSXのプロポーザルがありますので、読むとより理解が深まるでしょう。

Reactを捨てて、自分でReactの穴を埋める

勘のいい方はもうお気づきかと思いますが、本物のReactではなく、自分で定義したReactを置けば、Reactの代わりのフレームワークを作ることができそうですね。やってみましょう。

以下のように、Reactを入れていないのに(設定を見ないとわからないですが)、TSXを書いてもエラーが出ていませんね。例えば、Reactという変数定義を消せば、エラーになりますので、試してみてください。

上記のコードで、Reactの穴を埋める準備ができました。 では、HTMLを吐き出すコードを書いていきましょう。

書きました。

要は、 React.createElement という関数がオブジェクト(VNode)を生成し、 renderToString はVNodeを受け取って文字列に変換するようになっているわけです。これだけで ライブラリに依存せずにJSXを使ったアプリケーションを書けるようになりました(TypeScriptは必要ですが・・・)。

JSXと仲良くなれた

これまでのところでJSXとある程度仲良くなれたかと思います。思い思いのコードを書いていただいて自分だけのオリジナルのフレームワークを作ったり、あるいはフロントエンドという文脈から離れた何かを作るというのも手かもしれません。HTMLやJSONのような親子構造を構築するための構文として使えると思います。

したがって、ここから先は自分の目で確かめてみてくれということで、以上にします。

また、今回はCodePenを使用するため、React.createElementをオレオレ関数にしましたが、通常はそのような処理は不要で、例えばTypeScriptならtsconfig.jsonの設定で好きな名前の関数を設定することができますので、Reactというライブラリでなくても大丈夫です。

今回は説明しませんでしたが、俺の作った最強のReactを作るためにはさらに何段階かの壁を越える必要があります。 コンポーネント化や、状態管理、DOMの構築と再利用などです。ぜひやってみていただければと思います。よろしくお願いします。

Google Photosの写真がうまく並び替えられない問題

Google Photosに写真アップロード、してますか? 私は、デジカメで写真を撮ることがあり、撮ったものをGoogle Photosにアップロードしています。

共有もしやすいし、Googleアカウントはみんな持ってますから、比較的使いやすいのです。バックアップとして使っている感じですね。

ソートがおかしくなって困る

ただ、困ったことがあって、iPhoneとデジカメの写真を混ぜてアップロードすると、ソート順がおかしくなります。具体的には、デジカメのほうが時間が進んでいるような振る舞いをします。

で、普通に考えたら、撮影時間のタイムゾーンがおかしいんだろうなーと推測できるのですが、実際合ってて、デジカメの写真はUTCiPhoneのはJSTになっていました。

この違いはなんだろうと思って、いろいろ調べている過程で、アップロードするためのアプリケーション バックアップと同期 を使うと問題が発生し、PCからアップロードすると発生しないということに気付きました。

バックアップと同期がバグっているのではと推測

バックアップと同期がアップロード時に画像を縮小しているのですが、そのときに撮影日のタイムゾーンを吹っ飛ばしているのではないかと推測し、自分でCLIを作りました。これです。

作ったのは良かったのですが、やっぱりダメでした。

結論

結論としては、ここにある通り、写真のタイムゾーンは写真のGPSデータから推測されているようです。PCからアップロードする場合は、PCの場所から推測しているっぽいですね。 そのような理由で、作ったCLIも同じく動かないということがわかりましたので、お知らせいたします。

以上です。

nzxtのご紹介

お世話になっております。本日は昨日予告いたしましたとおり、 nzxt の紹介をいたします。

よろしくお願いします。

nzxt?

nzxt(んずくすと)

Next.jsやNuxt.jsのようなサーバーサイドフレームワークです。Next.jsのことしか存じ上げませんので、Next.jsを踏襲したスタイルになっています。

development?

以下のようなフォルダ構成にしていただき、 pages 下にファイルを作っていただくことで、アプリのルーティングも同時に可能です。

package.jsonのscriptsに "start": "nzxt" と記述して、 yarn start すれば8080で起動します。

├── package.json
├── pages
│   └── documents
│       ├── _id_.tsx
│       └── index.tsx
├── tsconfig.json
└── yarn.lock

上記例では、 /documents, /documents/xxxx というふたつのパスが生成されます。

documentation?

こちらです。 nzxt を使って作っています。さっき作りました。

なお、Herokuにデプロイしてあるため、初回は遅いです。ご注意ください。 Oracle Cloudに移行したので早いです。

concept?

Next.jsを真似て、ZheleznayaのSSRフレームワークを作ってみたかっただけです。

feature?

  • No config(嘘。tsconfig.jsonが必要。いつか、ファイルがなければ作る、をやりたい。)
  • Server Side Rendering + SPAとしても動く。
  • 型定義付き

sample?

nzxt-docsをご覧ください。 fsを使ってサーバーサイドでProps(ドキュメント本文)を取得しています。

technology?

ReactのSSRを自力で実現してみたときはWebpackを使ったのですが、今回はparcelを使いました。

parcelのいいところは、ブラウザ向けのバンドルでは自動的にNode.js用のパッケージが落とされるところです(よくわかんないけど、勝手に落とされたからそう思っている)。

parcelの悪いところは、JS用のAPIの説明がどこにもないことです。

JS Bundlerもいつか自力で作って、自給自足のフレームワークにしたいところですが、JS Bundlerだけはなかなかヘビーだと思うので、いつかきっと、という気持ちです。

以上です。よろしくお願いします。