Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

プロパティが一つもないオブジェクト型

ちょっとした小ネタ。

type Empty1 = {}; // これはLinterに怒られる
type Empty2 = {[key in string | number | symbol]: never}; // こっちはOK

使用例

APIレスポンスの返り値の型など、JSON周りでの使用パターンが多そうです。

const json = fetch('https://example.com');
const response = JSON.parse(json) as Empty;

esbuild だと新しいJSXトランスフォームに対応できなそう

新しいJSXトランスフォームは若干引数が異なっています。

ReactDOM.render(<App/>, document.querySelector('#main'));
// ReactDOM.render(React.createElement(App, null), document.querySelector('#main'));

これが下のようになります。

ReactDOM.render(<App/>, document.querySelector('#main'));
// ReactDOM.render(_jsx(App, {}, null), document.querySelector('#main'));

第二引数が異なるようになってしまったので、単純な以下のようなことはできません。

$ npx esbuild src/*.tsx --bundle '--define:process.env.NODE_ENV="development"' --jsx-factory=_jsx --jsx-fragment=Fragment --inject:src/shim.ts --outdir=dist

引数の与え方が異なっているのですから、JSX ファクトリーをすり替えるだけでは当然うまくいきません。

現在の状況

そもそも、 import React from 'react';がなくしたいだけなら以下でもいけます。

// src/shim.ts
export * as React from 'react';
$ npx esbuild src/*.tsx --bundle '--define:process.env.NODE_ENV="development"' --inject:src/shim.ts --outdir=dist

ただ、これでは今回新しくJSXトランスフォームが導入された意義の半分も満たせていません。

現在はissueが立っており、そこで議論されている最中です。どうやらプラグインとしてのサポートとなりそうです。

esbuild はすぐに導入できてプレイグラウンドとしてかなり優秀なので、対応してくれることを祈っています。

デフォルト引数を悪用して変数宣言を減らす

const state = reactive({
  count: 42,
  flag: true,
  str: 'hello world',
  computed: computed(() => {
    const {count, flag, str} = state;
    return flag ? count : str;
  }),
});

このような Vue3 のコードがあったとします。このとき、わざわざconst文を使わず、デフォルト引数に収める荒業があります。

const state = reactive({
  count: 42,
  flag: true,
  str: 'hello world',
  computed: computed(({count, flag, str} = state) =>
    flag ? count : str
  ),
});

もちろん、今後引数が渡されるようになったら修正しなければなりません。しかし、その場合も TypeScript を使っていれば型エラーになってくれるので、そこまで大惨事を引き起こしはしないと思います。

最も、他のひとが読むときにコードの意図が全くわからなくなるので、業務コードに混ぜるのはやめましょう。

型レベルでPermutationする

こんな感じの型です

type Fuyu = Permutation<'あんたは' | 'ここで' | 'ふゆと' | '死ぬのよ'>;
// ['あんたは', 'ここで', 'ふゆと', '死ぬのよ'] | ['あんたは', 'ここで', '死ぬのよ', 'ふゆと'] | ['あんたは', 'ふゆと', 'ここで', '死ぬのよ'] | ...

※本記事ではTypeScript beta版の機能を使用しています

作り方

けっこう愚直に書いてます。そこまで説明することもないですね。

type Permutation<T, U = T> =
    U extends string ?
        Exclude<T, U> extends never ?
            [U] :
        [U, ...Permutation<Exclude<T, U>>] :
    [U];

ポイントはTとUの2変数を使っている点です。こうしないと、conditional types でバラされたあとに元々の型がわからなくなってしまい、Exclude<T, U>の部分が実現できません。

気をつけるべきはベータバージョンでしか動かないことです(2020年10月現在)。頑張れば下位バージョンでもできそうですが、まだ思いついていないです。

実例

type Permutation<T, U = T> =
    U extends string ?
        Exclude<T, U> extends never ?
            [U] :
        [U, ...Permutation<Exclude<T, U>>] :
    [U];

type ArrayToString<T extends string[]> =
  T extends [infer head, ...infer tail] ?
    head extends string ?
      tail extends string[] ?
        `${head}${ArrayToString<tail>}` :
      '' :
    '' :
  '';
type Fuyu = ArrayToString<Permutation<'あんたは' | 'ここで' | 'ふゆと' | '死ぬのよ'>>;
const str: Fuyu[] = ['あんたはここでふゆと死ぬのよ', 'あんたはふゆとここで死ぬのよ', 'ここであんたはふゆと死ぬのよ'];

const normalize = (str: Fuyu) => 'あんたはここでふゆと死ぬのよ' as const;

おまけ: 不要なものを除外する

上記の例では「死ぬのよここでふゆとあんたは」のようなものまで含んでしまっています。不要なものを除外するときはExcludeを使います。 

type Fuyu = Exclude<
    ArrayToString<
      Permutation<'あんたは' | 'ここで' | 'ふゆと' | '死ぬのよ'>
    >,
  '死ぬのよここでふゆとあんたは'>;

ドラッグカメラぐるぐるをCSSでする

名前がよくわかりませんが、動作を見てもらえれば一発でわかると思います。

youtu.be

ドラッグによるカメラコントロールというのでしょうか?

やり方

方針

ドラッグ開始地点からの差分にもとづいてX軸、Y軸の回転を計算します。

f:id:panda_noir:20201003094634p:plain

このような感じです。asin(移動したぶん)で角度が求まります。

回転行列について

CSS transform には matrix3d という関数があり、行列を渡すと回転させることができます。

行列の基本的な部分については既知として、回転行列から話していきます。

まず、なにも変形しないときは 4x4 の単位行列になります。

[
  [1, 0, 0, 0],
  [0, 1, 0, 0],
  [0, 0, 1, 0],
  [0, 0, 0, 1],
]

X軸まわりでの回転を表す回転行列はこのようになります。

[
  [1, 0, 0, 0],
  [0, cos(theta), -sin(theta), 0],
  [0, sin(theta), cos(theta), 0],
  [0, 0, 0, 1],
]

(Y軸、Z軸についてはwikiを参照ください。)

回転行列をかけてやると、回転後の行列が求まります。とても簡単ですね。

しかし、今回やりたいことはこれだけでは実現できません。なぜなら、回転させてやると軸も一緒に回転してしまいます。例えばY軸まわりに90度回転させると、もともとのX軸のところにはZ軸、Z軸のところにはX軸がきます。

f:id:panda_noir:20201003101841p:plain
回転前

f:id:panda_noir:20201003101859p:plain
回転後

そのため、1度目の回転はそれぞれの軸まわりでの回転行列をつかえば良いですが、2回目以降はみかけ上の3軸を計算し、それらの軸での回転行列を使う必要があります。

まず、みかけ上の3軸は簡単に求まります。単純にそれぞれの軸を回転させるだけです。

次に、それぞれの軸での回転行列ですが、これもwikiにあるロドリゲスの回転公式がそのまま使えます。

というわけで、道具はそろったので実際にプログラムにしてみます。

実際のプログラム

// ロドリゲスの回転公式
const rot = ([nx, ny, nz], theta) => { /* ... */ };

// 行列の掛け算
const multiple = (a, b) => { /* ... */ };

// 行列の転置
const transpose = (a) => { /* ... */ };

const state = {
  prevMatrix: [
    [1, 0, 0, 0],
    [0, 1, 0, 0],
    [0, 0, 1, 0],
    [0, 0, 0, 1],
  ],
  theta: 0,
  phi: 0,
  xAxis: [1, 0, 0, 0],
  yAxis: [0, 1, 0, 0],
  get matrix() {
    return multiple(
      multiple(rot(this.xAxis, this.phi), rot(this.yAxis, this.theta)),
      this.prevMatrix
    );
  },
};
let isDragging = false;
let initialX = 0,
  initialY = 0; // ドラッグ開始地点

// 回転終了時の処理
const mouseup = () => {
  const matrix = state.matrix.map((arr) => arr.concat());

  // いまの行列を次の初期値とする
  isDragging = false;
  for (let i = 0; i < 4; i++)
    for (let j = 0; j < 4; j++) {
      state.prevMatrix[i][j] = matrix[i][j];
    }

  const rotation = multiple(
      rot(state.xAxis, state.phi),
      rot(state.yAxis, state.theta)
    ),
    transpose = (value) => value.reduce((acc, item) => [...acc, [item]], []);

  // X軸、Y軸を回転させる
  // phi と theta は回転中の値なので、回転してないときは0
  Object.assign(state, {
    xAxis: multiple(rotation, transpose([state.xAxis])).map(([value]) => value),
    yAxis: multiple(rotation, transpose([state.yAxis])).map(([value]) => value),
    phi: 0,
    theta: 0,
  });
};
const mousemove = ({
  clientX,
  clientY,
  view: { innerWidth: width, innerHeight: height },
}) => {
  if (!isDragging) {
    return;
  }
  // マウスの移動量から回転角を計算する
  // 円の直径は画面の大きさとした
  const wid = width / 2,
    hei = height / 2;
  Object.assign(state, {
    theta: Math.asin((clientX - wid) / wid) - Math.asin((initialX - wid) / wid),
    phi: Math.asin((clientY - hei) / hei) - Math.asin((initialY - hei) / hei),
  });
};

window.addEventListener('mouseleave', mouseup);
window.addEventListener('mouseup', mouseup);
window.addEventListener('mousemove', mousemove);
window.addEventListener('mousedown', ({ clientX, clientY }) => {
  isDragging = true;
  initialX = clientX;
  initialY = clientY;
});