lit-element を使用して Brick Viewer を作成する

1. はじめに

ウェブ コンポーネント

ウェブ コンポーネントは、デベロッパーがカスタム要素で HTML を拡張できるようにするウェブ標準の集合です。この Codelab では、ブロックモデルを表示できる <brick-viewer> 要素を定義します。

lit-element

カスタム要素 <brick-viewer> を定義するために、lit-element を使用します。lit-element は、ウェブ コンポーネント標準に構文糖衣を追加する軽量の基本クラスです。これにより、カスタム要素を簡単に起動して実行できるようになります。

使ってみる

オンラインの Stackblitz 環境でコーディングを行うため、次のリンクを新しいウィンドウで開きます。

stackblitz.com/edit/brick-viewer

では始めましょう。

2. カスタム要素を定義する

クラスの定義

カスタム要素を定義するには、LitElement を拡張するクラスを作成し、@customElement で修飾します。@customElement の引数は、カスタム要素の名前になります。

brick-viewer.ts に以下を配置します。

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
}

これで、<brick-viewer></brick-viewer> 要素を HTML で使用できるようになりました。しかし、試してみると、何もレンダリングされません。これを修正しましょう。

レンダリング メソッド

コンポーネントのビューを実装するには、render という名前のメソッドを定義します。このメソッドは、html 関数でタグ付けされたテンプレート リテラルを返す必要があります。タグ付きテンプレート リテラルに任意の HTML を配置します。これは、<brick-viewer> を使用するとレンダリングされます。

render メソッドを追加します。

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick viewer</div>`;
  }
}

3. LDraw ファイルの指定

プロパティを定義する

<brick-viewer> のユーザーが、次のように属性を使用して表示するブロックモデルを指定できると便利です。

<brick-viewer src="path/to/model.ldraw"></brick-viewer>

HTML 要素を構築しているため、宣言型 API を利用して、<img> タグや <video> タグと同様にソース属性を定義できます。lit-element では、クラス プロパティを @property でデコレートするだけで簡単に実現できます。type オプションを使用すると、lit-element が HTML 属性として使用するプロパティを解析する方法を指定できます。

src プロパティと属性を定義します。

export class BrickViewer extends LitElement {
  @property({type: String})
  src: string|null = null;
}

<brick-viewer>src 属性が追加され、HTML で設定できるようになりました。lit-element のおかげで、BrickViewer クラス内から値が読み取れるようになっています。

値を表示する

src 属性の値を表示するには、render メソッドのテンプレート リテラルで使用します。${value} 構文を使用して、テンプレート リテラルに値を補間します。

export class BrickViewer extends LitElement {
  render() {
    return html`<div>Brick model: ${this.src}</div>`;
  }
}

これで、ウィンドウの <brick-viewer> 要素に src 属性の値が表示されます。ブラウザのデベロッパー ツールを開き、src 属性を手動で変更してみてください。試してみましょう。

...要素内のテキストが自動的に更新されていることに気づきましたか?lit-element は @property で装飾されたクラス プロパティを監視し、ビューを再レンダリングします。lit-element が重い処理を行うため、ユーザーは何もする必要がありません。

4. Three.js でシーンを設定する

ライト、カメラ、レンダリング!

カスタム要素は three.js を使用して 3D レンガモデルをレンダリングします。three.js のシーン、カメラ、照明の設定など、<brick-viewer> 要素の各インスタンスに対して 1 回だけ実行したい処理があります。これらを BrickViewer クラスのコンストラクタに追加します。カメラ、シーン、コントロール、レンダラなどのオブジェクトは、後で使用できるようにクラス プロパティとして保持します。

three.js のシーン設定を追加します。

export class BrickViewer extends LitElement {

  private _camera: THREE.PerspectiveCamera;
  private _scene: THREE.Scene;
  private _controls: OrbitControls;
  private _renderer: THREE.WebGLRenderer;

  constructor() {
    super();

    this._camera = new THREE.PerspectiveCamera(45, this.clientWidth/this.clientHeight, 1, 10000);
    this._camera.position.set(150, 200, 250);

    this._scene = new THREE.Scene();
    this._scene.background = new THREE.Color(0xdeebed);

    const ambientLight = new THREE.AmbientLight(0xdeebed, 0.4);
    this._scene.add( ambientLight );

    const directionalLight = new THREE.DirectionalLight(0xffffff, 1);
    directionalLight.position.set(-1000, 1200, 1500);
    this._scene.add(directionalLight);

    this._renderer = new THREE.WebGLRenderer({antialias: true});
    this._renderer.setPixelRatio(window.devicePixelRatio);
    this._renderer.setSize(this.offsetWidth, this.offsetHeight);

    this._controls = new OrbitControls(this._camera, this._renderer.domElement);
    this._controls.addEventListener("change", () =>
      requestAnimationFrame(this._animate)
    );

    this._animate();

    const resizeObserver = new ResizeObserver(this._onResize);
    resizeObserver.observe(this);
  }

  private _onResize = (entries: ResizeObserverEntry[]) => {
    const { width, height } = entries[0].contentRect;
    this._renderer.setSize(width, height);
    this._camera.aspect = width / height;
    this._camera.updateProjectionMatrix();
    requestAnimationFrame(this._animate);
  };

  private _animate = () => {
    this._renderer.render(this._scene, this._camera);
  };
}

WebGLRenderer オブジェクトは、レンダリングされた three.js シーンを表示する DOM 要素を提供します。このプロパティには domElement プロパティを介してアクセスします。${value} 構文を使用して、この値をレンダリング テンプレート リテラルに補間できます。

テンプレートにあった src メッセージを削除し、レンダラの DOM 要素を挿入します。

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
    `;
  }
}

レンダラの DOM 要素を完全に表示するには、<brick-viewer> 要素自体を display: block に設定する必要があります。スタイルは、css テンプレート リテラルに設定された styles という静的プロパティで指定できます。

このスタイル設定をクラスに追加します。

export class BrickViewer extends LitElement {
  static styles = css`
    /* The :host selector styles the brick-viewer itself! */
    :host {
      display: block;
    }
  `;
}

<brick-viewer> にレンダリングされた three.js のシーンが表示されます。

レンダリングされた空のシーンを表示する brick-viewer 要素。

でも、空っぽです。モデルを提供しましょう。

レンガローダー

先ほど定義した src プロパティを、three.js に付属している LDrawLoader に渡します。

LDraw ファイルでは、ブロックモデルを個別の組み立て手順に分割できます。LDrawLoader API を使用して、ステップの合計数と個々のブロックの可視性にアクセスできます。

これらのプロパティ、新しい _loadModel メソッド、コンストラクタの新しい行をコピーします。

@customElement('brick-viewer')
export class BrickViewer extends LitElement {
  private _loader = new LDrawLoader();
  private _model: any;
  private _numConstructionSteps?: number;
  step?: number;

  constructor() {
    // ...
    // Add this line right before this._animate();
    (this._loader as any).separateObjects = true;
    this._animate();
  }

  private _loadModel() {
    if (this.src === null) {
      return;
    }
    this._loader
        .setPath('')
        // Using our src property!
        .load(this.src, (newModel) => {

          if (this._model !== undefined) {
            this._scene.remove(this._model);
            this._model = undefined;
          }

          this._model = newModel;

          // Convert from LDraw coordinates: rotate 180 degrees around OX
          this._model.rotation.x = Math.PI;
          this._scene.add(this._model);

          this._numConstructionSteps = this._model.userData.numConstructionSteps;
          this.step = this._numConstructionSteps!;

          const bbox = new THREE.Box3().setFromObject(this._model);
          this._controls.target.copy(bbox.getCenter(new THREE.Vector3()));
          this._controls.update();
          this._controls.saveState();
        });
  }
}

_loadModel はいつ呼び出すべきですか?src 属性が変更されるたびに呼び出す必要があります。src プロパティを @property でデコレートすることで、プロパティを lit-element の更新ライフサイクルにオプトインしました。これらのデコレートされたプロパティの値が変更されるたびに、プロパティの新旧の値にアクセスできる一連のメソッドが呼び出されます。ここで注目するライフサイクル メソッドは update です。update メソッドは、変更されたプロパティに関する情報を含む PropertyValues 引数を取ります。_loadModel を呼び出すのに最適な場所です。

update メソッドを追加します。

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    super.update(changedProperties);
  }
}

これで、<brick-viewer> 要素で src 属性で指定されたレンガ ファイルを表示できるようになりました。

車のモデルを表示するブロックビューア要素。

5. 部分モデルの表示

次に、現在の構築ステップを構成可能にします。<brick-viewer step="5"></brick-viewer> を指定できるようにし、5 番目の構築ステップでレンガモデルがどのように見えるかを確認します。そのためには、@property で装飾して step プロパティを監視対象のプロパティにします。

step プロパティにパラメータを付与します。

export class BrickViewer extends LitElement {
  @property({type: Number})
  step?: number;
}

次に、現在のビルドステップまでのブロックのみを表示するヘルパー メソッドを追加します。step プロパティが変更されるたびに実行されるように、更新メソッドでヘルパーを呼び出します。

update メソッドを更新し、新しい _updateBricksVisibility メソッドを追加します。

export class BrickViewer extends LitElement {
  update(changedProperties: PropertyValues) {
    if (changedProperties.has('src')) {
      this._loadModel();
    }
    if (changedProperties.has('step')) {
      this._updateBricksVisibility();
    }
    super.update(changedProperties);
  }

  private _updateBricksVisibility() {
    this._model && this._model.traverse((c: any) => {
      if (c.isGroup && this.step) {
        c.visible = c.userData.constructionStep <= this.step;
      }
    });
    requestAnimationFrame(this._animate);
  }
}

ブラウザのデベロッパー ツールを開き、<brick-viewer> 要素を検査します。次のように、step 属性を追加します。

step 属性が 10 に設定された brick-viewer 要素の HTML コード。

レンダリングされたモデルがどうなるか見てみましょう。step 属性を使用して、モデルの表示量を制御できます。step 属性が "10" に設定されている場合は、次のようになります。

10 個の組み立て手順のみが完了したブロックモデル。

6. Brick Set Navigation

mwc-icon-button

<brick-viewer> のエンドユーザーは、UI を介してビルドステップをナビゲートできる必要があります。次のステップ、前のステップ、最初のステップに移動するためのボタンを追加しましょう。マテリアル デザインのボタン ウェブ コンポーネントを使用して、簡単に作成します。@material/mwc-icon-button はすでにインポートされているため、<mwc-icon-button></mwc-icon-button> をドロップインする準備が整っています。使用するアイコンは、<mwc-icon-button icon="thumb_up"></mwc-icon-button> のように icon 属性で指定できます。使用可能なすべてのアイコンは、material.io/resources/icons で確認できます。

render メソッドにアイコンボタンを追加しましょう。

export class BrickViewer extends LitElement {
  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button icon="replay"></mwc-icon-button>
        <mwc-icon-button icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

ウェブ コンポーネントのおかげで、ページでマテリアル デザインを使用するのはとても簡単です。

イベント バインディング

これらのボタンは実際に機能する必要があります。[返信] ボタンを押すと、構築ステップが 1 にリセットされるはずです。「navigate_before」ボタンは構築ステップを減らし、「navigate_next」ボタンは構築ステップを増やす必要があります。lit-element では、イベント バインディングを使用してこの機能を簡単に追加できます。html テンプレート リテラルで、要素属性として @eventname=${eventHandler} 構文を使用します。これで、その要素で eventname イベントが検出されると、eventHandler が実行されるようになります。たとえば、3 つのアイコンボタンにクリック イベント ハンドラを追加してみましょう。

export class BrickViewer extends LitElement {
  private _restart() {
    this.step! = 1;
  }

  private _stepBack() {
    this.step! -= 1;
  }

  private _stepForward() {
    this.step! += 1;
  }

  render() {
    return html`
      ${this._renderer.domElement}
      <div id="controls">
        <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
        <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
      </div>
    `;
  }
}

今すぐボタンをクリックしてみてください。よくできました。

スタイル

ボタンは機能しますが、見た目がよくありません。すべて下部に集まっています。シーンに重ねて表示されるようにスタイルを設定しましょう。

これらのボタンにスタイルを適用するには、static styles プロパティに戻ります。これらのスタイルはスコープ設定されているため、このウェブ コンポーネント内の要素にのみ適用されます。これはウェブ コンポーネントの利点の 1 つです。セレクタがシンプルになり、CSS の読み書きが容易になります。さようなら、BEM

スタイルを次のように更新します。

export class BrickViewer extends LitElement {
  static styles = css`
    :host {
      display: block;
      position: relative;
    }
    #controls {
      position: absolute;
      bottom: 0;
      width: 100%;
      display: flex;
    }
  `;
}

再起動ボタン、戻るボタン、進むボタンを備えたブロックビューア要素。

カメラのリセットボタン

<brick-viewer> のエンドユーザーは、マウス コントロールを使用してシーンを回転させることができます。ボタンを追加するついでに、カメラをデフォルトの位置にリセットするボタンも追加しましょう。クリック イベント バインディングを含む別の <mwc-icon-button> を使用すると、この処理が完了します。

export class BrickViewer extends LitElement {
  private _resetCamera() {
    this._controls.reset();
  }

  render() {
    return html`
      <div id="controls">
        <!-- ... -->
        <!-- Add this button: -->
        <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
      </div>
    `;
  }
}

簡単な操作

一部のブロック セットには多くのステップがあります。特定のステップにスキップしたい場合もあります。ステップ番号付きのスライダーを追加すると、すばやく移動できます。これには <mwc-slider> 要素を使用します。

mwc-slider

スライダー要素には、スライダーの最小値と最大値など、いくつかの重要なデータが必要です。スライダーの最小値は常に「1」にできます。モデルが読み込まれた場合、スライダーの最大値は this._numConstructionSteps にする必要があります。<mwc-slider> には、属性を介してこれらの値を伝えることができます。ifDefined lit-html ディレクティブを使用して、_numConstructionSteps プロパティが定義されていない場合に max 属性が設定されないようにすることもできます。

[戻る] ボタンと [進む] ボタンの間に <mwc-slider> を追加します。

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ... backwards button -->
        <!-- Add this slider: -->
        <mwc-slider
            step="1"
            pin
            markers
            min="1"
            max=${ifDefined(this._numConstructionSteps)}
        ></mwc-slider>
        <!-- ... forwards button -->
      </div>
    `;
  }
}

データ「アップ」

ユーザーがスライダーを動かすと、現在の組み立て手順が変更され、モデルの可視性がそれに応じて更新されます。スライダー要素は、スライダーがドラッグされるたびに入力イベントを生成します。スライダー自体にイベント バインディングを追加してこのイベントをキャッチし、構築ステップを変更します。

イベント バインディングを追加します。

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the @input event binding: -->
        <mwc-slider
            ...
            @input=${(e: CustomEvent) => this.step = e.detail.value}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

おめでとうございます!スライダーを使用して、表示するステップを変更できます。

データ「ダウン」

もう 1 つあります。[戻る] ボタンと [次へ] ボタンを使用してステップを変更する場合は、スライダー ハンドルを更新する必要があります。<mwc-slider> の value 属性を this.step にバインドします。

value バインディングを追加します。

export class BrickViewer extends LitElement {
  render() {
    return html`
      <div id="controls">
        <!-- ...  -->
        <!-- Add the value property binding: -->
        <mwc-slider
            ...
            value=${ifDefined(this.step)}
        ></mwc-slider>
        <!-- ... -->
      </div>
    `;
  }
}

スライダーはほぼ完成しました。他のコントロールと連携するように、フレックス スタイルを追加します。

export class BrickViewer extends LitElement {
  static styles = css`
    /* ... */
    mwc-slider {
      flex-grow: 1;
    }
  `;
}

また、スライダー要素自体で layout を呼び出す必要があります。これは、DOM が最初にレイアウトされたときに 1 回呼び出される firstUpdated ライフサイクル メソッドで行います。query デコレータを使用すると、テンプレート内のスライダー要素への参照を取得できます。

export class BrickViewer extends LitElement {
  @query('mwc-slider')
  slider!: Slider|null;

  async firstUpdated() {
    if (this.slider) {
      await this.slider.updateComplete
      this.slider.layout();
    }
  }
}

スライダーの追加をすべてまとめたコードは次のとおりです(スライダーに pin 属性と markers 属性を追加して、見栄えをよくしています)。

export class BrickViewer extends LitElement {
 @query('mwc-slider')
 slider!: Slider|null;

 static styles = css`
   /* ... */
   mwc-slider {
     flex-grow: 1;
   }
 `;

 async firstUpdated() {
   if (this.slider) {
     await this.slider.updateComplete
     this.slider.layout();
   }
 }

 render() {
   return html`
     ${this._renderer.domElement}
     <div id="controls">
       <mwc-icon-button @click=${this._restart} icon="replay"></mwc-icon-button>
       <mwc-icon-button @click=${this._stepBack} icon="navigate_before"></mwc-icon-button>
       <mwc-slider
         step="1"
         pin
         markers
         min="1"
         max=${ifDefined(this._numConstructionSteps)}
         ?disabled=${this._numConstructionSteps === undefined}
         value=${ifDefined(this.step)}
         @input=${(e: CustomEvent) => this.constructionStep = e.detail.value}
       ></mwc-slider>
       <mwc-icon-button @click=${this._stepForward} icon="navigate_next"></mwc-icon-button>
       <mwc-icon-button @click=${this._resetCamera} icon="center_focus_strong"></mwc-icon-button>
     </div>
   `;
 }
}

こちらが最終的な製品です。

brick-viewer 要素を使用して車のブロックモデルを操作する

7. まとめ

lit-element を使用して独自の HTML 要素を構築する方法について、多くのことを学びました。以下について学びました。

  • カスタム要素を定義する
  • 属性 API を宣言する
  • カスタム要素のビューをレンダリングする
  • スタイルをカプセル化する
  • イベントとプロパティを使用してデータを渡す

lit-element について詳しくは、公式サイトをご覧ください。

完成した brick-viewer 要素は、stackblitz.com/edit/brick-viewer-complete で確認できます。

brick-viewer は NPM でも提供されています。ソースは Github リポジトリで確認できます。