Help us understand the problem. What is going on with this article?

[SPA]TypeScriptで作成したサーバレスの地図ベースのホテル検索システム(GoogleMap+RakutenTravel)の開発

More than 1 year has passed since last update.

SPA(SinglePageApplication)で作る地図ベースのホテル検索システム

1.作成目標、地図ベースのホテル検索

 旅行等でホテルを検索を必要とする時、立地条件を重視したいときがあります。そういうときは地図を基準として、そこからホテルが選べれば便利なのですが、なかなかそういうものが無かったので自分で作ることにしました。さらに検索の度にページが切り替わってしまうのも煩わしいので、実装は当然のごとくSPAです。

2.作ったもの

 空雲ホテルマップ
image.png

 最終的にindex.htmlとbundle.jsの2ファイルがあれば動作します。当初はバックエンド側のプログラムもスタンバイさせていたのですが、作っていくうちにサーバレスで全部作れるという結論になりました。

3.開発環境

 ちなみにJavaScriptWindowFrameworkは自作のフロントエンドフレームワークです。npmに登録してありますが、たぶん私以外だれも使ってないです。

4.使ったWebAPI

 楽天トラベルAPIは開発者登録をすれば、ほぼ無制限で利用できます。
 GoogleMapは基本的に有料ですが、2万円/月のクレジットがもらえるので、アクセスがそれなりに増えない限り無料枠でなんとかなりそうです。なお、geocodeによる住所検索も2万円に含まれます。一回の検索が0.5円なのは高いのか安いのか、なんとも言えません。

5.JavaScriptによるGoogleMapの表示

 アプリケーションキーの発行を行えば、とても簡単にGoogleMapを表示できます。縮尺を表すルーラが見にくかったり、マーカーのカスタマイズ機能が弱かったりするので、そのあたりだけ自分で作り直しました。

 TypeScriptで組むと、ネイティブアプリを作っている感覚でプログラムが作成できます。以下、例としてカスタムマーカーのソースコードです。

CustomMarker.ts
/*global google*/
/**
 *マーカ用オプション
 *
 * @export
 * @interface CustomMarkerOptions
 * @extends {google.maps.MarkerOptions}
 */
export interface CustomMarkerOptions extends google.maps.MarkerOptions {
  info?: unknown; //アプリケーション用データ保存用
  click?: (marker: CustomMarker) => void; //クリックイベント処理用
}

/**
 *GoogleMap用カスタムマーカー
 *
 * @export
 * @class CustomMarker
 * @extends {google.maps.OverlayView}
 */
export class CustomMarker extends google.maps.OverlayView {
  private marker: HTMLDivElement;
  private options: CustomMarkerOptions;
  public constructor(options: CustomMarkerOptions) {
    super();
    this.options = options;
    if (options.map) this.setMap(options.map);

    const marker = document.createElement("div");
    this.marker = marker;
    marker.dataset.type = "marker";

    //マーカー用イメージの作成
    const image = document.createElement("img");
    marker.appendChild(image);
    //デフォルトイメージ
    let iconUrl = "//maps.gstatic.com/intl/en_us/mapfiles/drag_cross_67_16.png";
    if (options.icon) {
      if (typeof options.icon === "string") iconUrl = options.icon;
      else if ("url" in options.icon && options.icon.url) {
        iconUrl = options.icon.url;
      }
    }
    image.src = iconUrl;
    //ラベルの作成
    const label = document.createElement("div");
    label.dataset.type = "label";
    marker.appendChild(label);
    if (typeof options.label === "string") label.textContent = options.label;
    //クリックイベントの処理
    marker.addEventListener(
      "click",
      (e): void => {
        if (options.click) options.click(this);
        e.stopPropagation();
      }
    );
  }
  public draw(): void {
    // 緯度、軽度の情報を、Pixel(google.maps.Point)に変換
    var point = this.getProjection().fromLatLngToDivPixel(this.options
      .position as google.maps.LatLng);

    // 取得したPixel情報の座標に、要素の位置を設定
    this.marker.style.left = point.x + "px";
    this.marker.style.top = point.y + "px";
  }
  public onAdd(): void {
    var panes = this.getPanes();
    panes.overlayMouseTarget.appendChild(this.marker);
  }
  public onRemove(): void {
    if (this.marker.parentNode) this.marker.parentNode.removeChild(this.marker);
  }
  public getOptions(): CustomMarkerOptions {
    return this.options;
  }
  public getMarkerNode(): HTMLDivElement {
    return this.marker;
  }
}

6.楽天トラベル系APIによるホテル情報の取得

 楽天トラベルAPIは経度と緯度を指定して、該当するホテルを検索する機能があります。これがあれば表示中の位置を元にホテル表示できると軽く考えていたのですが、そうは問屋が卸しませんでした。座標指定による検索は3kmという範囲制限があり、さらに楽天のサーバにそうとうな負荷がかかるようです。東京駅などのホテル数の多い場所でやると、応答に途轍もない時間がかかったり、サーバエラーをたびたび返すようになります。

 ということで負荷の大きい座標指定はやめて、地区コードを指定するようにしました。しかし地区を検索条件から手動で選ぶようにしてしまうと、既存のWebサービスと同じになってしまいます。考えた結果、GoogleMap表示中の住所を取得して、それ元に地区コードを選ぶようにしました。これによって、いちいち自分で地区を選択する必要がなくなりました。

 ちなみにこの楽天トラベル系APIは、データをjson形式で取得できるので、当初は利用するのは簡単だと思っていました。しかしこのAPIで返してくるデータは、あまりにも特殊な形状をしています。

 ホテル情報の戻り値の型をTypeScriptのinterfaceにしたものを提示しておきます。この形式の凄まじいところを紹介しましょう。

 「あ...ありのまま 今 起こった事を話すぜ!」

 例えば複数件のホテル情報が戻ってくるhotelsが二次元配列になっています。普通の感覚なら一次元配列で十分だと考えるでしょう。なのに何故二次元になっているか分かりますか?分かるわけがありませんね。なんとhotelsの子データであるhotelBasicInfo、hotelFacilitiesInfoなどが、わざわざ配列に分けてバラバラに格納されているのです。

「な… 何を言っているのか わからねーと思うが おれも 何をされたのか わからなかった… 」

 もしかしてundefinedの状態を作りたくなかったのでしょうか?他にも同じような部分が多数あり、あまりに凄まじい形式だったので、まともな形式にコンバートしてから利用しています。

hotelinfo.ts
export interface RTravelInfoSrc {
  pagingInfo: {
    recordCount: number;
    pageCount: number;
    page: number;
    first: number;
    last: number;
  };
  hotels: {
    hotelBasicInfo?: {
      hotelNo: number;
      hotelName: string;
      hotelInformationUrl: string;
      planListUrl: string;
      dpPlanListUrl: string;
      reviewUrl: string;
      hotelKanaName: string;
      hotelSpecial: string;
      hotelMinCharge: number;
      latitude: number;
      longitude: number;
      postalCode: string;
      address1: string;
      address2: string;
      telephoneNo: string;
      faxNo: string;
      access: string;
      parkingInformation: string;
      nearestStation: string;
      hotelImageUrl: string;
      hotelThumbnailUrl: string;
      roomImageUrl: string;
      roomThumbnailUrl: string;
      hotelMapImageUrl: string;
      reviewCount: number;
      reviewAverage: number;
      userReview: string;
    };
    hotelFacilitiesInfo?: {
      hotelRoomNum: number;
      roomFacilities: { item: string }[];
      hotelFacilities: { item: string }[];
      aboutMealPlace: {
        breakfastPlace?: string;
        dinnerPlace?: string;
      }[];
      aboutBath: {
        bathType?: string;
        bathQuality?: string;
      }[];
      aboutLeisure: null | string;
      handicappedFacilities: { item: string }[];
      linguisticLevel: null | string;
    };
    hotelReserveInfo?: {
      reserveRecordCount: number;
      lowestCharge: number;
      highestCharge: number;
    };
    roomInfo?: {
      dailyCharge: {
        stayDate: string;
        rakutenCharge: number;
        total: number;
        chargeFlag: number;
      };
      roomBasicInfo: {
        roomClass: string;
        roomName: string;
        planId: number;
        planName: string;
        pointRate: number;
        withDinnerFlag: number;
        dinnerSelectFlag: number;
        withBreakfastFlag: number;
        breakfastSelectFlag: number;
        payment: string;
        reserveUrl: string;
        salesformFlag: number;
        planContents: string;
      };
    }[];
  }[][];
  error: string;
}

 これをまともに直すのが、けっこう面倒でした。

7.検索結果の保存

 検索結果はやマップの位置情報などは、全てブラウザ内にキャッシュしています。ネイティブアプリのような感覚で使ってもらうため、SPA(SinglePageApplication)のお約束、URLの疑似ルーティングなどはあえて付けていません。この機能によって、更新ボタンを押しても検索結果は消えません。

8.まとめ

 私が最初に作ったWebアプリはC++で作成した掲示板でした。その後PerlやPHPに手を出し、Javaから一周回ってPHPに戻ってきて、現在はバックエンドとフロントエンド共にTypeScriptへ移行しました。色々使ってきた感想としては、たぶん気軽にWebプログラムを作るのならPHPが一番のような気がします。

 そして今回開発に選んだTypeScriptは、Node.jsのおかげでエコシステムが充実しており、WebPackと関連プラグインなどの力もあって、こういう機能があればいいのにと思ったことが大抵実現できてしまいます。その代わり選択肢や手法が多すぎて、自分なりの設定を見つけるまでが大変です。いきなりTypeScriptでモジュールバンドラ込みのフルセット開発をやったら、確実に地獄へたたき落とされることでしょう。

 そのかわり、一度軌道に乗った後の開発効率は凄まじいものがあります。フロントエンドのTypeScript関連コードはそれなりに蓄積されてきたので、この流れで今後の開発に生かしていこうと思っています。

SoraKumo
TypeScriptでフロントエンドフレームワーク JWF(JavaScript-Window-Framework)を開発しています 世の中のWebシステムをSPA化するため、活動を続けています
https://ttis.croud.jp/
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away