index.js

import * as itowns from 'itowns';
import * as THREE from 'three';

/**
 * @typedef {object} Step
 * @property {number} previous - Index of the previous step. If this is the first step, it should be the index of the step
 * @property {number} next - Index of the next step. If this is the last step, it should be the index of the step
 * @property {Array<string>} layers - IDs of the layers to display
 * @property {Array<string>} media - IDs of the media to display
 * @property {object} position - Camera position (vec3 with x,y,z coordinates)
 * @property {object} rotation - Camera rotation (quaternion with x,y,z,w coordinates)
 */

/**
 * @typedef {object} Media
 * @property {string} id - ID of the media
 * @property {string} type - The type of the media (text, image, video or audio)
 * @property {string} value - Value of the media. It can be either an URL or raw text
 */

/**
 * @example
 * Config Example
 * {
  "steps": [
    {
      "previous": 0,
      "next": 1,
      "layers": ["layer_1", "layer_2"],
      "media": [],
      "position": {"x": 10, "y": 20, "z": 30},
      "rotation": {"x": 0.5, "y": 0, "z": 0.24, "w": 0}
    },
    {
      "previous": 0,
      "next": 1,
      "layers": ["layer_3"],
      "media": ["media_1", "media_2"]
    }
  ],
 "name": "Example",
 "description": "This is an example of GuidedTour config",
 "startIndex": 0,
 "endIndex": 1
}
 * @classdesc GuidedTour Widget class
 */
export class GuidedTour {
  /**
   * It initializes the widget.
   *
   *
   * @param {itowns.PlanarView} itownsView - The itowns view.
   * @param {object} tourConfig - The configuration of the widget
   * @param {string} tourConfig.name - Name of the GuidedTour
   * @param {string} tourConfig.description - Description of the GuidedTour
   * @param {number} tourConfig.startIndex - Index of the first step of the tour
   * @param {number} tourConfig.endIndex - Index of the last step of the tour
   * @param {Array<Step>} tourConfig.steps - Array of steps
   * @param {Array<Media>} mediaConfig - All media of the tour
   */
  constructor(itownsView, tourConfig, mediaConfig) {
    /** @type {import('itowns').PlanarView} */
    this.itownsView = itownsView;

    /**
     * Name of the GuidedTour
     * 
      @type {string}*/
    this.name = tourConfig.name || 'GuidedTour';

    /**
     * Description of the GuidedTour
     * 
      @type {string}*/
    this.description = tourConfig.description || '';

    /**
     * Index of the first step of the GuidedTour
     * 
      @type {number}*/
    this.startIndex = tourConfig.startIndex;

    /**
     * Index of the last step of the GuidedTour
     * 
      @type {number}*/
    this.endIndex = tourConfig.endIndex;

    /**
     * Array of steps
     * 
      @type {Array<object>}*/
    this.steps = tourConfig.steps;

    /**
     * Index of the current step
     * 
      @type {number}*/
    this.currentIndex = this.startIndex;

    /**
     * Config of all media of the tour
     *
     @type {Array<object>}*/
    this.mediaConfig = mediaConfig;

    /**
     * Root html of GuidedTour view 
     *
      @type {HTMLElement} */
    this.domElement = null;

    /**
     * Html div containing media of the step 
     *
      @type {HTMLElement} */
    this.mediaContainer = null;

    /**
     * Button to go to previous step 
     *
      @type {HTMLElement} */
    this.previousButton = null;

    /**
     * Button to go to next step 
     *
      @type {HTMLElement} */
    this.nextButton = null;

    this.initHtml();
  }

  /**
   * Creates the HTML of the GuidedTour
   */
  initHtml() {
    this.domElement = document.createElement('div');
    this.mediaContainer = document.createElement('div');
    this.domElement.appendChild(this.mediaContainer);

    this.previousButton = document.createElement('button');
    this.previousButton.addEventListener(
      'click',
      function () {
        const previousIndex = this.getCurrentStep().previous;
        this.goToStep(previousIndex);
      }.bind(this)
    );
    this.domElement.appendChild(this.previousButton);

    this.nextButton = document.createElement('button');
    this.nextButton.addEventListener(
      'click',
      function () {
        const nextIndex = this.getCurrentStep().next;
        this.goToStep(nextIndex);
      }.bind(this)
    );
    this.domElement.appendChild(this.nextButton);
  }

  /**
   * Go to the step corresponding to the index
   *
   * @param {number} index Index of the step
   */
  goToStep(index) {
    this.currentIndex = index;
    const step = this.getCurrentStep();
    if (step.position && step.rotation)
      this.travelToPosition(step.position, step.rotation);
    if (step.layers && step.layers.length > 0) this.filterLayers(step.layers);
    this.addMedia(step.media);
  }

  /**
   * Travel to the targeted position and rotation
   *
   * @param {object} position Target postion
   * @param {object} rotation Target rotation
   */
  travelToPosition(position, rotation) {
    const pos = new THREE.Vector3(...Object.values(position));
    const quat = new THREE.Quaternion(...Object.values(rotation));
    this.itownsView.controls.initiateTravel(pos, 'auto', quat, true);
  }

  /**
   * Filters layers, displaying only those whose ID appears in the list
   *
   * @param {Array<string>} layerIds Array of layer IDs
   */
  filterLayers(layerIds) {
    for (const layer of this.itownsView.getLayers())
      layer.visible = layerIds.includes(layer.id);
    this.itownsView.notifyChange();
  }

  /**
   * Add media in the media container
   *
   * @param {Array<string>} mediaIds The list of media IDs
   */
  addMedia(mediaIds) {
    const mediaDivs = [];
    for (const mediaId of mediaIds) {
      const media = this.getMediaById(mediaId);
      mediaDivs.push(this.createMediaDiv(media));
    }
    this.mediaContainer.replaceChildren(...mediaDivs);
  }

  /**
   * Creates a HTML element from a media config
   *
   * @param {Media} media The media config
   * @returns {HTMLElement} The media as a HTML element
   */
  createMediaDiv(media) {
    let mediaDiv = null;
    switch (media.type) {
      case 'text':
        mediaDiv = document.createElement('p');
        mediaDiv.innerText = media.value;
        break;
      case 'video':
        mediaDiv = document.createElement('video');
        mediaDiv.src = media.value;
        mediaDiv.controls = true;
        mediaDiv.muted = false;
        break;
      case 'image':
        mediaDiv = document.createElement('img');
        mediaDiv.src = media.value;
        break;
      case 'audio':
        mediaDiv = document.createElement('audio');
        mediaDiv.src = media.value;
        mediaDiv.controls = true;
        mediaDiv.muted = false;
        break;
      default:
        console.log('Unkown media type');
    }
    return mediaDiv;
  }

  /**
   * Dispose the DOM element
   */
  dispose() {
    this.domElement.remove();
    for (const layer of this.itownsView.getLayers()) layer.visible = true;
    this.itownsView.notifyChange();
  }

  /**
   * Returns the current step of the tour
   *
   * @returns {Step} The current step of the tour
   */
  getCurrentStep() {
    return this.steps[this.currentIndex];
  }

  /**
   * Returns the media config with the matching ID
   *
   * @param {string} mediaId The ID of the media
   * @returns {Media|null} The media config
   */
  getMediaById(mediaId) {
    for (const media of this.mediaConfig) {
      if (media.id == mediaId) return media;
    }
    return null;
  }
}