lit-element로 브릭 뷰어 빌드

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 {
}

이제 HTML에서 <brick-viewer></brick-viewer> 요소를 사용할 수 있습니다. 하지만 시도해 보면 아무것도 렌더링되지 않습니다. 이 문제를 해결해보겠습니다.

렌더링 메서드

구성요소의 뷰를 구현하려면 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 벽돌 모델을 렌더링합니다. 3.js 장면, 카메라, 조명 설정과 같이 <brick-viewer> 요소의 각 인스턴스에 한 번만 실행하려는 작업이 있습니다. 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 객체는 렌더링된 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>가 렌더링된 two.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 속성으로 지정된 브릭 파일을 표시할 수 있습니다.

자동차 모델을 표시하는 brick-viewer 요소

5. 부분 모델 표시

이제 현재 구성 단계를 구성 가능하도록 만들어 보겠습니다. <brick-viewer step="5"></brick-viewer>를 지정할 수 있으려면 5번째 생성 단계에서 벽돌 모델이 어떻게 표시되는지 확인해야 합니다. 이렇게 하려면 step 속성을 @property로 장식하여 관찰된 속성으로 만들어 보겠습니다.

step 속성을 장식합니다.

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

이제 현재 빌드 단계의 블록만 표시되도록 도우미 메서드를 추가합니다. step 속성이 변경될 때마다 실행되도록 update 메서드에서 도우미를 호출합니다.

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 속성을 추가합니다.

step 속성이 10으로 설정된 brick-viewer 요소의 HTML 코드입니다.

렌더링된 모델에 어떤 일이 일어나는지 확인하세요. step 속성을 사용하여 모델이 표시되는 정도를 제어할 수 있습니다. step 속성이 "10"로 설정되면 다음과 같이 표시됩니다.

10개의 건설 계단만 있는 벽돌 모형입니다.

6. 벽돌 세트 탐색

Mwc-아이콘-버튼

<brick-viewer>의 최종 사용자도 UI를 통해 빌드 단계를 탐색할 수 있어야 합니다. 다음 단계, 이전 단계, 첫 번째 단계로 이동하는 버튼을 추가해 보겠습니다. Material Design의 버튼 웹 구성요소를 사용하여 쉽게 구현해 보겠습니다. @material/mwc-icon-button를 이미 가져왔으므로 <mwc-icon-button></mwc-icon-button>에 드롭할 준비가 되었습니다. <mwc-icon-button icon="thumb_up"></mwc-icon-button>와 같이 icon 속성과 함께 사용할 아이콘을 지정할 수 있습니다. 가능한 모든 아이콘은 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>
    `;
  }
}

웹 구성요소 덕분에 페이지에서 Material Design을 아주 쉽게 사용할 수 있습니다.

이벤트 결합

이 버튼은 실제로 어떤 작업을 실행해야 합니다. '답장' 버튼을 누르면 건설 단계가 1로 재설정됩니다. 'navigation_before' 버튼은 구성 단계를 감소시켜야 하며 'navigation_next' 버튼은 단계를 증가시켜야 합니다. lit-element를 사용하면 이벤트 결합과 함께 이 기능을 쉽게 추가할 수 있습니다. HTML 템플릿 리터럴에서 @eventname=${eventHandler} 구문을 요소 속성으로 사용합니다. 이제 해당 요소에서 eventname 이벤트가 감지되면 eventHandler가 실행됩니다. 예를 들어 세 개의 아이콘 버튼에 클릭 이벤트 핸들러를 추가해 보겠습니다.

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 속성으로 돌아갑니다. 이러한 스타일은 범위가 지정되므로 이 웹 구성요소 내의 요소에만 적용됩니다. 이는 웹 구성요소를 작성할 때의 장점 중 하나입니다. 선택기를 더 간단하게 만들 수 있고 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>에 이러한 값을 알릴 수 있습니다. _numConstructionSteps 속성이 정의되지 않은 경우 ifDefined lit-html directive를 사용하여 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>
    `;
  }
}

와! 슬라이더를 사용하여 표시되는 단계를 변경할 수 있습니다.

데이터 '다운'

한 가지 더 있습니다. '뒤로' 및 '나아가기' 버튼을 사용하여 단계를 변경하는 경우 슬라이더 핸들을 업데이트해야 합니다. <mwc-slider>의 값 속성을 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>
    `;
  }
}

슬라이더가 거의 완성되었습니다. 다른 컨트롤과 잘 작동하도록 flex 스타일을 추가합니다.

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();
    }
  }
}

다음은 추가한 모든 슬라이더의 조합입니다. 멋지게 보이도록 슬라이더에 pinmarkers 속성을 추가했습니다.

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에 관해 자세히 알아보려면 관련 공식 사이트를 참고하세요.

stackblitz.com/edit/brick-viewer-complete에서 완료된 brick-viewer 요소를 확인할 수 있습니다.

brick-viewer는 NPM에서도 제공되며 GitHub 저장소에서 소스를 확인할 수 있습니다.