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> に、HTML で設定可能な src 属性が追加されました。lit-element のおかげで、その値は BrickViewer クラス内からすでに読み取れます。

値の表示

src 属性の値を表示するには、レンダリング メソッドのテンプレート文字列で使用します。${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 クラスのコンストラクタに追加します。カメラ、シーン、コントロール、レンダラは、後で使用できるようにクラス プロパティとして保持します。

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

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

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

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

クラスに次のスタイルを追加します。

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

これで、<brick-viewer> がレンダリングされた 3.js シーンを表示できるようになりました。

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

でも... 空っぽです。モデルを指定しましょう。

ブリック ローダー

前に定義した src プロパティを LDrawLoader に渡します。このローダーは、3.js で提供されます。

LDraw ファイルでは、Brick モデルを別々の作成ステップに分割できます。ステップの総数と個々のブロックの表示は、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 属性で指定したレンガファイルを表示できるようになりました。

車のモデルを表示している brick-viewer 要素。

5. 部分モデルの表示

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

step プロパティを装飾します。

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

次に、現在のビルドステップまでのブロックのみを表示するヘルパー メソッドを追加します。update メソッドでヘルパーを呼び出して、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);
  }
}

では、ブラウザの devtools を開き、<brick-viewer> 要素を調べます。以下のように step 属性を追加します。

ステップ属性が 10 に設定された、brick-viewer 要素の HTML コード。

レンダリングされたモデルに何が起こるかをご覧ください。step 属性を使用して、モデルの表示範囲を制御できます。step 属性が "10" に設定されている場合は次のようになります。

10 ステップの組み立てで完成するレンガモデル。

6. ブロックセット ナビゲーション

MWC アイコンボタン

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

レンダリング メソッドにアイコンボタンを追加しましょう。

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 にリセットされます。「nav_before」ボタンでステップ数が減少し、「nav_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 要素。

[カメラをリセット] ボタン

<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 が最初に配置されたときに呼び出される 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 リポジトリでソースを確認できます。