Getting Started

Making a Controller

In this section, we are going to make something called Controller; a Javascript class that uses 3d-tour internally and provides useful fields and methods to other UI components.

INFO

This controller will also be a place where the data from server will be processed. We'll use the current pivo tour's server response already saved locally here so if the shape of your data is different, you have to make some adjustments.

But in general, it shouldn't be too hard to follow this guide even if your data is different.

Constructor

Let's go ahead and write the constructor first. We're going to receive HTMLCanvasElement as a parameter of Controller and pass it to Tour3D.Environment. Also here we're binding the data from server to _data property for further processing later.

import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  _tour3D;
  _tour;

  constructor(canvas: HTMLCanvasElement) {
    this._tour3D = new Tour3D.Environment(canvas);
    this._tour = tour;
  }
}

init

Next, we're writing Controller.init() which will be exposed and called from the UI component that imports this controller.

Calling init() will do three things:

  • add event listeners to 3d-tour
  • initialize Floors
  • create initial Cube
import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  _tour3D;
  _tour;

  constructor(canvas: HTMLCanvasElement) {
    this._tour3D = new Tour3D.Environment(canvas);
    this._tour = tour;
  }

  public async init() {
    this._addEventListeners();
    await this._initFloors();
    await this._createInitialCube();
  }

  ...
}

We're going to implement the methods in this order

  1. _initFloors()
  2. _createInitialCube()
  3. _addEventListeners()

_initFloors

Let's initialize Floors first.

In Pivo Tour, we define Section as a floor and Scene as each spot that panorama images were captured. So in our case, we are going to make a Floor for each Section and a FloorHotspot for each Scene and a Dollhouse if available.

import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  ...

  private async _initFloors() {
    this._tour.sections.forEach((section) => {
      // create a Floor for each section
      const floor = this._tour3D.createFloor(section.id);

      // create a FloorHotspot for each scene
      this._tour.scenes
        .filter((scene) => scene.sectionId === section.id)
        .forEach((scene) => {
          const floorHotspot = floor.createFloorHotspot(scene.id);
          // after we obtain a reference for `FloorHotspot`, we can set its position.
          floorHotspot.position = scene.position;
        });

      // create a dollhouse if you have one
      if (section.mesh.url) {
        const dollhouse = floor.createDollhouse();

        // currently only supports .obj and .mtl formats
        dollhouse.objURL = `${section.mesh.url}/out.obj`;
        dollhouse.mtlURL = `${section.mesh.url}/out.mtl`;

        // set position if any
        if (section.offset) {
          dollhouse.position = section.offset;
        }

        // set rotation if any
        if (section.rotation) {
          /**
           * for position and rotation, you can either give
           * {x: number, y: number, z: number} or
           * [number, number, number]
           */
          dollhouse.rotation = [0, -section.rotation, 0];
          dollhouse.rotationOrder = "YXZ";
        }
      }
    });

    // After we finish configuring the floors, we must call Tour3D.init()
    await this._tour3D.init();
  }
}

_createInitialCube

Now let's move on to writing _createInitialCube().

import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  ...

  private async _createInitialCube() {
    // get the initial scene
    const initSection = this._tour.sections[0];
    const initSceneId = initSection.scenesOrder[0];
    const initScene = this._tour.scenes.find((scene) => scene.id === initSceneId);

    if (initScene) {
      // We set the current floor as the section of the initial scene.
      /**
       * Tour3D.setCurrentFloor(floorOrId: Floor | string, animation?: boolean) returns
       * a Promise that resolves to a Floor because of the animation involved (dollhouse opacity change).
       *
       * You can set animation to false in cases where the visual effect is not needed.
       */
      const initFloor = await this._tour3D.setCurrentFloor(initScene.sectionId, false);

      const initCube = initFloor.createCube(initScene.id);
    }
  }
}

Now it's time to prepare images for the cube. There are a couple of rules that you need to follow when it comes to cube images.

First off, a little explanation about the hierarchy of cube images is needed.

A Cube can have multiple resolutions and each resolution is associated with particular image URLs for each Face of the cube (front, left, right, top, bottom, back). And in turn, each face can either have a single image or multiple images (like tiles). When a cube face of a particular resolution level has multiple images, they should be ordered row-major.

You can specify each image URLs for each resolution level in three ways:

A Single Image for an Entire Cube

export declare type CubeFacePosition = "front" | "left" | "right" | "top" | "bottom" | "back";
export declare type CubeSingleImage = {
  url: string;
  faceOrder: [
    CubeFacePosition,
    CubeFacePosition,
    CubeFacePosition,
    CubeFacePosition,
    CubeFacePosition,
    CubeFacePosition
  ];
};

This is an object with two properties: url and faceOrder. This is used when one image contains all images for 6 faces like the figure below.

There's currently one restriction about the shape of the image though. They have to be placed vertically like above; one on top of each other.

But the order of the faces can be altered through faceOrder property of CubeSingleImage.

A Single Image for Each Cube Face

export type CubeEachFaceSingleImage = [string, string, string, string, string, string];

This shape is used when each face has only single image to show. The default order of faces is front, left, right, top, bottom, back but it can be altered through EnvOptions.

import * as Tour3D from "@3i-web/3d-tour";

const envOptions: Tour3D.EnvOptions = {
  cube: {
    facePositionArray: ["back", "bottom", "top", "right", "left", "front"],
  },
};

// Get a reference of <canvas> element you are going to use.
// The line below is just an example.
const canvas = document.getElementById("my-canvas");

const tour3D = new Tour3D.Environment(canvas, envOptions);

Multiple Images for Each Cube Face

export type CubeEachFaceMultipleImages = [
  string[],
  string[],
  string[],
  string[],
  string[],
  string[]
];

This last form is used when each face of the cube consists of several images.

When you specify mulitple images for a cube face, you need to make sure the images are in row-major order in your image array.

With this knowledge, we can write a helper class that returns the images for a Cube:

_getCubeImages

import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  ...

  private _getCubeImages(sceneId: string) {
    const scene = this._tour.scenes.find((scene) => scene.id === sceneId);

    if (scene) {
      /**
       * Tour3D.CubeSingleImage
       *
       * This type helps you define the preview image containing images for all 6 faces.
       * Currently only support vertically aligned image that looks like this:
       * ----------
       * |        |
       * | front  |
       * |        |
       * ----------
       * |        |
       * |  back  |
       * |        |
       * ----------
       * |        |
       * |  left  |
       * |        |
       * ----------
       * |        |
       * | right  |
       * |        |
       * ----------
       * |        |
       * |  top   |
       * |        |
       * ----------
       * |        |
       * | bottom |
       * |        |
       * ----------
       */
      const previewURL: Tour3D.CubeSingleImage = {
        url: `${scene.tilesUrl}/preview.jpg`,
        faceOrder: ["front", "left", "right", "top", "bottom", "back"],
      };

      /**
       * Each image array for a particular face must be ordered 'row-major`
       * ref: https://en.wikipedia.org/wiki/Row-_and_column-major_order
       *
       * such as:
       * 1 -> 2 -> 3
       * 4 -> 5 -> 6
       * 7 -> 8 -> 9
       *
       * nested array is in this order: resolution -> face position -> image list
       */
      const urlsByResolution: string[][][] = [];

      for (const [resIndex, segments] of scene.resolutions.entries()) {
        const urlsByFacePosition: string[][] = [];

        for (const position of ["f", "l", "r", "u", "d", "b"]) {
          const imageList: string[] = [];
          for (let i = 0; i < segments; i++) {
            for (let j = 0; j < segments; j++) {
              // row-major order
              const imageURL = [
                scene.tilesUrl,
                position,
                `l${resIndex + 1}`,
                i + 1,
                `l${resIndex + 1}_${position}_${i + 1}_${j + 1}.jpg`,
              ].join("/");

              imageList.push(imageURL);
            }
          }
          urlsByFacePosition.push(imageList);
        }
        urlsByResolution.push(urlsByFacePosition);
      }

      /**
       * Tour3D.CubeImageURLs
       *
       * Use this type definition before giving the array to `Tour3D.Cube.init()`.
       */
      return [previewURL, ...urlsByResolution] as Tour3D.CubeImageURLs;
    }
  }
}

Now we can resume the initial cube creation in _createInitialCube().

import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  ...

  private async _createInitialCube() {
    // get the initial scene
    const initSection = this._tour.sections[0];
    const initSceneId = initSection.scenesOrder[0];
    const initScene = this._tour.scenes.find((scene) => scene.id === initSceneId);

    if (initScene) {
      // We set the current floor as the section of the initial scene.
      /**
       * Tour3D.setCurrentFloor(floorOrId: Floor | string, animation?: boolean) returns
       * a Promise that resolves to a Floor because of the animation involved (dollhouse opacity change).
       *
       * You can set animation to false in cases where the visual effect is not needed.
       */
      const initFloor = await this._tour3D.setCurrentFloor(initScene.sectionId, false);

      const initCube = initFloor.createCube(initScene.id);

      // Get images from `_getCubeImages()`
      const cubeImages = this._getCubeImages(initScene.id);
      if (cubeImages) {
        const [preview, ...images] = cubeImages;

        /**
         * The default camera position is (0, 0, 1)
         * So after setting the position of the the cube,
         * move the camera as well.
         *
         * This will probably only be used for the first cube.
         *
         * Note that it is placed before calling `Cube.init()`.
         *
         */
        initCube.getCameraFocus();

        /**
         * `Cube.addToFloor()`
         *
         * This should also be called before `Cube.init()`.
         * You must manually call this for the initial cube.
         */
        initCube.addToFloor();

        /**
         * `Cube.init()`
         *
         * This will first load the preview image if provided,
         * and automatically download the first resolution images for the cube faces
         * that the camera currently sees.
         */
        await initCube.init({
          preview,
          images,
        });

        /**
         * `Floor.addNearestFloorHotspots()`
         *
         * Each scene has a list of closest scenes that are within a certain perimeter.
         * You can tweak this radius value with Tour3D.EnvOptions.floorHotspot.lazyLoad.radiusScale (default 3)
         *
         * It's actually calculated when `Tour3D.init()` is called.
         */
        initFloor.addNearestFloorHotspots(initCube.id);
      }
    }
  }
}

There are a couple of things to note about the order of method calls after we get the cube images.

Specifically, it's recommended to call Cube.getCameraFocus() and Cube.addToFloor() before calling Cube.init() because of the image update mechanism.

To reduce network requests, the current logic for updating images involves asking the camera what it currently sees in the scene and only fetches the images for the cube faces in front of the camera; that is, in the camera's frustum.

For this to work correctly, we need to make sure the camrea is in the right position by calling Cube.getCameraFocus() and make sure the cube is in the scene by calling Cube.addToFloor() before Cube.init().

Now let's move on to handling FloorHotspot's click event.

_addEventListeners

There are many exposed events you can attach your listener to but for now, we will focus on the event that gets fired when the FloorHotspot is clicked.

import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  ...

  private _addEventListeners() {
    this._tour3D.addEventListener("ON_FLOOR_HOTSPOT_CLICK", (event) => {
        // We'll fill this in
    });
  }
}

First off, let's explore the argument of the callback, event.

The type definition for ON_FLOOR_HOTSPOT_CLICK event looks like the following:

export interface OnFloorHotspotClick extends THREE.Event {
  type: "ON_FLOOR_HOTSPOT_CLICK";
  payload: FloorHotspot;
}

This means we will have the properties type and payload in the argument of the listener, event.

The value of payload property is, in fact, the entire FloorHotspot object that is clicked. There are a lot of exposed properties and methods in FloorHotspot but for now, we will only be using its id.

The behavior we want when one of the floor hotspots is clicked is to send the camera to the next cube. This can be achieved by creating a cube for the clicked floor hotspot and using the transition APIs afterwards.

The process of creating the next cube is pretty much the same as creating any cube:

import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  ...

  private _addEventListeners() {
    this._tour3D.addEventListener("ON_FLOOR_HOTSPOT_CLICK", (event) => {
      const nextSceneId = event.payload.id;
      const nextScene = this._tour.scenes.find((scene) => scene.id === nextSceneId);

      if (nextScene) {
        /**
         * Get the floor reference from the return value of `Tour3D.setCurrentFloor()`.
         * If the next floor id is the same as the current, it will just return the current floor.
         *
         * To remove the delay caused by animation from `Tour3D.setCurrentFloor()`,
         * set the second argument to false.
         */
        const floor = await this._tour3D.setCurrentFloor(nextScene.sectionId, false);

        const nextCube = floor.createCube(nextScene.id);
        nextCube.position = nextScene.position;
        nextCube.rotation = nextScene.rotation;

        const cubeImages = this._getCubeImages(nextScene.id);
        if (cubeImages) {
          const [preview, ...images] = cubeImages;

          /**
           * Note that there's no `nextCube.getCameraFocus()` and `nextCube.addToFloor()`
           *
           * The reason they are not present is that
           * the change of the camera position and adding the cube to the scene
           * will be taken care of by the transition APIs.
           */

          nextCube.init({ preview, images });
        }
      }
    });
  }
}

One thing to note here is that unlike we did in _createInitialCube() , there's no nextCube.getCameraFocus() and nextCube.addToFloor() before initializing the cube.

They are going to be taken care of by the transition API which we're getting into, now.

import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  ...

  private _addEventListeners() {
    this._tour3D.addEventListener("ON_FLOOR_HOTSPOT_CLICK", (event) => {
      const nextSceneId = event.payload.id;
      const nextScene = this._tour.scenes.find((scene) => scene.id === nextSceneId);

      if (nextScene) {
        /**
         * Get the floor reference from the return value of `Tour3D.setCurrentFloor()`.
         * If the next floor id is the same as the current, it will just return the current floor.
         *
         * To remove the delay caused by animation from `Tour3D.setCurrentFloor()`,
         * set the second argument to false.
         */
        const floor = await this._tour3D.setCurrentFloor(nextScene.sectionId, false);

        const nextCube = floor.createCube(nextScene.id);
        nextCube.position = nextScene.position;
        nextCube.rotation = nextScene.rotation;

        const cubeImages = this._getCubeImages(nextScene.id);
        if (cubeImages) {
          const [preview, ...images] = cubeImages;

          /**
           * Note that there's no `nextCube.getCameraFocus()` and `nextCube.addToFloor()`
           *
           * The reason they are not present is that
           * the change of the camera position and adding the cube to the scene
           * will be taken care of by the transition APIs.
           */

          nextCube.init({ preview, images });

          /**
           * `Tour3D.transitionFromCubeToCube(params?: CubeToCube)`
           *
           * This method takes an optional parameter `CubeToCube`,
           * which you specify as an object with properties `onStart` and `onComplete`
           */
          this._tour3D.transitionFromCubeToCube();
        }
      }
    });
  }
}

Nothing much has added actually; only one additional line at the end. You don't even need to provide the cube transitioning from and the cube transitioning to.

The reason we can do this is because Tour3D internally keeps track of the current cube and the previous cube whenever a cube is created.

You can optionally provide an object with properties onStart and onComplete as the argument.

As the names suggest, onStart will be called right before the transition animation begins and onComplete right after it ends.

Final Demo

Congratulations! If you've followed this far, you should have a properly functioning 3d virtual tour.




Code Reference


Controller.ts
import * as Tour3D from "@3i-web/3d-tour";
import tour from "@/data/tour.json";

export class Controller {
  _tour3D: Tour3D.Environment;
  _tour;

  constructor(canvas: HTMLCanvasElement) {
    /**
     * Tour3D.Environment
     *
     * Initializing this module will set up the necessary environment to create 3D objects
     * that are going to be rendered in the provided <canvas> element.
     */
    this._tour3D = new Tour3D.Environment(canvas);
    this._tour = tour;
  }

  async init() {
    /**
     * Initializing Tour3D.Environment won't show anything on the THREE.Scene it interanlly manages,
     * so let's add THREE.AxesHelper to the scene to see if everything works properly.
     *
     * AxesHelper shows only the positive directions.
     *
     * X axis: red (left-right)
     * Y axis: green (up-down)
     * Z axis: blue (out-in)
     *
     * The default camera position is (0,0,1)
     */
    // this._tour3D.addAxesHelper();
    /**
     * We can also add some test objects to the scene.
     * To see more clearly how the THREE.OrbitControls works.
     *
     * What you see on the screen is what a THREE.js's virtual camera sees in the scene.
     * `OrbitControls` moves around this virtual camera 360 degree horizontal and vertical
     * so it gives the viewer a feeling that the objects in the scene rotate according to their mouse actions.
     *
     * But remember, in fact, what's rotating is the camera, not the objects!
     */
    // this._tour3D.addTestSphere();
    // this._tour3D.addTestCube();

    this._addEventListeners();
    await this._initFloors();
    this._createInitialCube();
  }

  private async _initFloors() {
    this._tour.sections.forEach((section) => {
      // create a Floor for each section
      const floor = this._tour3D.createFloor(section.id);

      // create a FloorHotspot for each scene
      this._tour.scenes
        .filter((scene) => scene.sectionId === section.id)
        .forEach((scene) => {
          const floorHotspot = floor.createFloorHotspot(scene.id);
          // after we obtain a reference for `FloorHotspot`, we can set its position.
          floorHotspot.position = scene.position;
        });

      // create a dollhouse if you have one
      if (section.mesh.url) {
        const dollhouse = floor.createDollhouse();

        // currently only supports .obj and .mtl formats
        dollhouse.objURL = `${section.mesh.url}/out.obj`;
        dollhouse.mtlURL = `${section.mesh.url}/out.mtl`;

        // set position if any
        if (section.offset) {
          dollhouse.position = section.offset;
        }

        // set rotation if any
        if (section.rotation) {
          /**
           * for position and rotation, you can either give
           * {x: number, y: number, z: number} or
           * [number, number, number]
           */
          dollhouse.rotation = [0, -section.rotation, 0];
          dollhouse.rotationOrder = "YXZ";
        }
      }
    });

    // After we finish configuring the floors, we must call Tour3D.init()
    await this._tour3D.init();
  }

  private async _createInitialCube() {
    // get the initial scene
    const initSection = this._tour.sections[0];
    const initSceneId = initSection.scenesOrder[0];
    const initScene = this._tour.scenes.find((scene) => scene.id === initSceneId);

    if (initScene) {
      // We set the current floor as the section of the initial scene.
      /**
       * Tour3D.setCurrentFloor(floorOrId: Floor | string, animation?: boolean) returns
       * a Promise that resolves to a Floor because of the animation involved (dollhouse opacity change).
       *
       * You can set animation to false in cases where the visual effect is not needed.
       */
      const initFloor = await this._tour3D.setCurrentFloor(initScene.sectionId, false);

      const initCube = initFloor.createCube(initScene.id);

      // after we get a reference to the cube, we can set its position and rotation.
      initCube.position = initScene.position;
      initCube.rotation = initScene.rotation;

      const cubeImages = this._getCubeImages(initScene.id);
      if (cubeImages) {
        const [preview, ...images] = cubeImages;

        /**
         * The default camera position is (0, 0, 1)
         * So after setting the position of the the cube,
         * move the camera as well.
         *
         * This will probably only be used for the first cube.
         *
         * Note that it is placed before calling `Cube.init()`.
         *
         */
        initCube.getCameraFocus();

        /**
         * `Cube.addToFloor()`
         *
         * This should also be called before `Cube.init()`.
         * You must manually call this for the initial cube.
         */
        initCube.addToFloor();

        /**
         * `Cube.init()`
         *
         * This will first load the preview image if provided,
         * and automatically download the first resolution images for the cube faces
         * that the camera currently sees.
         */
        await initCube.init({
          preview,
          images,
        });

        /**
         * `Floor.addNearestFloorHotspots()`
         *
         * Each scene has a list of closest scenes that are within a certain perimeter.
         * You can tweak this radius value with Tour3D.EnvOptions.floorHotspot.lazyLoad.radiusScale (default 3)
         *
         * It's actually calculated when `Tour3D.init()` is called.
         */
        initFloor.addNearestFloorHotspots(initCube.id);
      }
    }
  }

  private _getCubeImages(sceneId: string) {
    const scene = this._tour.scenes.find((scene) => scene.id === sceneId);

    if (scene) {
      /**
       * Tour3D.CubeSingleImage
       *
       * This type helps you define the preview image containing images for all 6 faces.
       * Currently only support vertically aligned image that looks like this:
       * ----------
       * |        |
       * | front  |
       * |        |
       * ----------
       * |        |
       * |  back  |
       * |        |
       * ----------
       * |        |
       * |  left  |
       * |        |
       * ----------
       * |        |
       * | right  |
       * |        |
       * ----------
       * |        |
       * |  top   |
       * |        |
       * ----------
       * |        |
       * | bottom |
       * |        |
       * ----------
       */
      const previewURL: Tour3D.CubeSingleImage = {
        url: `${scene.tilesUrl}/preview.jpg`,
        faceOrder: ["front", "left", "right", "top", "bottom", "back"],
      };

      /**
       * Each image array for a particular face must be ordered 'row-major`
       * ref: https://en.wikipedia.org/wiki/Row-_and_column-major_order
       *
       * such as:
       * 1 -> 2 -> 3
       * 4 -> 5 -> 6
       * 7 -> 8 -> 9
       *
       * nested array is in this order: resolution -> face position -> image list
       */
      const urlsByResolution: string[][][] = [];

      for (const [resIndex, segments] of scene.resolutions.entries()) {
        const urlsByFacePosition: string[][] = [];

        for (const position of ["f", "l", "r", "u", "d", "b"]) {
          const imageList: string[] = [];
          for (let i = 0; i < segments; i++) {
            for (let j = 0; j < segments; j++) {
              // row-major order
              const imageURL = [
                scene.tilesUrl,
                position,
                `l${resIndex + 1}`,
                i + 1,
                `l${resIndex + 1}_${position}_${i + 1}_${j + 1}.jpg`,
              ].join("/");

              imageList.push(imageURL);
            }
          }
          urlsByFacePosition.push(imageList);
        }
        urlsByResolution.push(urlsByFacePosition);
      }

      /**
       * Tour3D.CubeImageURLs
       *
       * Use this type definition before giving the array to `Tour3D.Cube.init()`.
       */
      return [previewURL, ...urlsByResolution] as Tour3D.CubeImageURLs;
    }
  }

  private _addEventListeners() {
    this._tour3D.addEventListener("ON_FLOOR_HOTSPOT_CLICK", async (event) => {
      const nextSceneId = event.payload.id;
      const nextScene = this._tour.scenes.find((scene) => scene.id === nextSceneId);

      if (nextScene) {
        /**
         * Get the floor reference from the return value of `Tour3D.setCurrentFloor()`.
         * If the next floor id is the same as the current, it will just return the current floor.
         *
         * To remove the delay caused by animation from `Tour3D.setCurrentFloor()`,
         * set the second argument to false.
         */
        const floor = await this._tour3D.setCurrentFloor(nextScene.sectionId, false);

        const nextCube = floor.createCube(nextScene.id);
        nextCube.position = nextScene.position;
        nextCube.rotation = nextScene.rotation;

        const cubeImages = this._getCubeImages(nextScene.id);
        if (cubeImages) {
          const [preview, ...images] = cubeImages;

          /**
           * Note that there's no `nextCube.getCameraFocus()` and `nextCube.addToFloor()`
           *
           * The reason they are not present is that
           * the change of the camera position and adding the cube to the scene
           * will be taken care of by the transition APIs.
           */

          nextCube.init({ preview, images });

          /**
           * `Tour3D.transitionFromCubeToCube(params?: CubeToCube)`
           *
           * This method takes an optional parameter `CubeToCube`,
           * which you specify as an object with properties `onStart` and `onComplete`
           */
          this._tour3D.transitionFromCubeToCube();
        }
      }
    });
  }
}

App.vue
<template>
  <canvas id="canvas" ref="canvas"></canvas>
</template>

<script setup lang="ts">
import { ref, onMounted } from "vue";
import { Controller } from "@/Controller";

const canvas = ref<HTMLCanvasElement>();
let controller: Controller | undefined;

onMounted(async () => {
  if (canvas.value) {
    controller = new Controller(canvas.value);
    await controller.init();
  }
});
</script>

<style>
#canvas {
  height: 100%;
  width: 100%;
}
</style>