AKARI Tech Blog

燈株式会社のエンジニア・開発メンバーによる技術ブログです

GASとThree.jsで社内の会議室の利用状況を3Dリアルタイム可視化してみた

こんにちは!今週のAKARI Tech Blogは、DX Solution事業部の田川が担当します!

燈では会議室の予約にGoogleカレンダーを利用しています。

とても便利ですが、「今空いてる会議室はどこだろう?」と複数のカレンダーを見比べるのは少し手間がかかりますよね。

そこで、GAS(Google Apps Script)を利用して、会議室のリアルタイム利用状況を3Dで可視化するWebアプリケーションを作成してみました。

会議室可視化アプリの完成画面(Nameの部分にはGoogleアカウントのアイコンが入ります)

本記事では、このアプリを題材に、GASでGoogleカレンダーの情報を取得し、それをWebで3D表示する方法について、コードを交えながらご紹介します。

TL;DR

  • 目的: Googleカレンダーで管理されている会議室の現在の利用状況を、一目でわかるように3Dで可視化する。
  • アーキテクチャ: Google Apps Script (GAS) のみで完結。
    • バックエンド: Googleカレンダーの情報を取得する関数をCode.gsに記述。
    • フロントエンド: index.htmlファイルにThree.jsを用いた3D描画処理を記述。HtmlServiceでページを配信し、google.script.runでバックエンド関数を呼び出す。
  • メリット: 認証などの考慮をする必要がなく、簡単に社内のみでのデプロイが可能。

GASで作るWebアプリの仕組み

GAS(Google Apps Script)は、Googleのサービスを操作するスクリプトですが、Webページをホスティングする機能も持っています。

今回の構成では、GASプロジェクト内に2つのファイルを作成します。

  1. Code.gs: Googleカレンダーから会議の予定を取得するなど、サーバーサイドで動くロジックを記述します。
  2. index.html: ユーザーのブラウザで表示・実行されるHTML, CSS, JavaScriptを記述します。

この2つは、google.script.runという特別な仕組みを介して連携します。HTML内のJavaScriptから、Code.gs内の関数を呼び出し、その結果を受け取ることができるようになっています。

バックエンドの準備 (Code.gs)

まずは、Googleカレンダーから会議室の情報を取得するサーバーサイドの処理をCode.gsに記述します。

1. GASプロジェクトの作成とファイル準備

Googleドライブの「新規」>「Google Apps Script」からプロジェクトを作成します。

デフォルトでCode.gsファイルがあるので、それに加えて「ファイル」>「HTML」を選択し、indexという名前のHTMLファイルを作成しておきます。

2. 会議室カレンダーのIDの準備

管理方法にもよるかもしれませんが、弊社の環境においてはGoogleカレンダーを開き、「他のカレンダー」>「リソースのブラウジング」から可視化したい会議室にチェックをつけます。

カレンダーIDの取得

そうすると左の「他のカレンダーの設定」欄に会議室が表示されますので、対象の会議室をタップし「カレンダーの統合」に「カレンダーID」を記録しておきます。 通常はメールアドレスのような形式(例: xxxxxxxx@resource.calendar.google.com)になっているかと思います。

3. スクリプトの記述

Code.gsに以下のコードを記述します。

Code.gs

// 会議室の名前とカレンダーIDの対応表
const ROOM_CALENDARS = {
  "Room1": "room1_id@resource.calendar.google.com",
  "Room2": "room2_id@resource.calendar.google.com",
  "Room3": "room3_id@resource.calendar.google.com",
};

/**
 * Webページを表示するための関数
 */
function doGet() {
  return HtmlService.createHtmlOutputFromFile('index')
      .setTitle('会議室リアルタイム3D可視化アプリ');
      // ファビコンの設定なども可能
}

/**
 * フロントエンドから呼び出される関数。
 * 各会議室の現在の利用状況、今後の予定、現在の使用者情報を返す。
 */
function getRoomStatus() {
  const now = new Date();
  const futureLimit = new Date();
  futureLimit.setHours(now.getHours() + 24); // 24時間後までの予定を取得

  const statuses = [];

  for (const roomName in ROOM_CALENDARS) {
    const calendarId = ROOM_CALENDARS[roomName];
    const calendar = CalendarApp.getCalendarById(calendarId);
    if (!calendar) continue;

    const allEvents = calendar.getEvents(now, futureLimit);

    let isBusy = false;
    let currentEventDetails = null;
    const upcomingEvents = [];

    for (const event of allEvents) {
      const startTime = event.getStartTime();
      const endTime = event.getEndTime();

      if (startTime <= now && now <= endTime && !isBusy) {
        isBusy = true;
        
        let userEmail = null;
        let photoUrl = null;
        const creators = event.getCreators();

        if (creators.length > 0) {
          userEmail = creators[0];
        } else {
          const guests = event.getGuestList();
          const actualGuest = guests.find(guest => guest.getEmail() !== calendarId);
          if (actualGuest) {
            userEmail = actualGuest.getEmail();
          }
        }
        
        let currentUserName = '情報なし';
        if (userEmail) {
          currentUserName = userEmail.split('@')[0];
          // 利用者のGoogleアカウントのプロフィール画像も取得可能(getProfilePictureは割愛)
          photoUrl = getProfilePicture(userEmail);
        }

        currentEventDetails = {
          title: '予定あり', // 予定名を取得して表示することも可能
          startTime: startTime.toISOString(),
          endTime: endTime.toISOString(),
          user: currentUserName,
          photoUrl: photoUrl
        };
      } else if (startTime > now) {
         // 今後の予定
        upcomingEvents.push({
          title: '予定あり',
          startTime: startTime.toISOString(),
          endTime: endTime.toISOString()
        });
      }
    }
    
    statuses.push({
      name: roomName,
      busy: isBusy,
      currentEvent: currentEventDetails,
      upcomingEvents: upcomingEvents
    });
  }
  return statuses;
}

/**
 * Google Drive上に配置したオフィス図面の画像をBase64形式の文字列として取得する
 */
function getDrawingImageAsBase64() {
  try {
    // Drive上の図面の画像ファイルIDを入れる
    const fileId = 'XXXXXXX';
    
    const file = DriveApp.getFileById(fileId);
    const contentType = file.getMimeType();
    const base64Data = Utilities.base64Encode(file.getBlob().getBytes());

    return `data:${contentType};base64,${base64Data}`;

  } catch (e) {
    console.error("画像の取得に失敗しました: " + e.toString());
    return null;
  }
}

フロントエンドの実装 (index.html)

次に、先ほど作成したindex.htmlファイルに、3D表示のためのコードを記述します。

index.html

<!DOCTYPE html>
<html>
<head>
    <base target="_top">
    <meta charset="UTF-8">
    <title>会議室リアルタイム3D可視化アプリ</title>
    <style>
        ...
    </style>
</head>

<body>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
    <script src="https://unpkg.com/three@0.128.0/examples/js/controls/OrbitControls.js"></script>
    </script>
        let scene, camera, renderer, controls;
        const roomObjects = {};
        const roomData = {};

        ...

        function init() {
            scene = new THREE.Scene();
            scene.background = new THREE.Color(0xf0f0f0);
            camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 0.1, 1000);

            ...

            // 図面を床に設定
            const floorGeometry = new THREE.PlaneGeometry(16.18, 11.08); // 画像の大きさから計算
            const floorMaterial = new THREE.MeshBasicMaterial({ color: 0x888888, side: THREE.DoubleSide });
            const floor = new THREE.Mesh(floorGeometry, floorMaterial);
            floor.rotation.x = -Math.PI / 2;
            scene.add(floor);
            google.script.run.withSuccessHandler(function (base64Image) {
                if (!base64Image) { console.error("サーバーから画像データを取得できませんでした。"); return; }
                const textureLoader = new THREE.TextureLoader();
                textureLoader.load(base64Image, function (texture) {
                    floor.material.map = texture;
                    floor.material.color.set(0xffffff);
                    floor.material.needsUpdate = true;
                });
            }).getDrawingImageAsBase64();


            // 会議室の箱の位置と大きさの設定
            roomObjects['Room1'] = new THREE.Mesh(new THREE.BoxGeometry(1, 1, 1), new THREE.MeshStandardMaterial({ color: 0x00ff00, transparent: true, opacity: 0.7 }));
            roomObjects['Room1'].position.set(1, 1, 1);
            ...


            for (const roomName in roomObjects) {
                scene.add(roomObjects[roomName]);
            }
        }


        // 予定のある会議室を特定し色をつける関数
        function updateRoomVisuals(statuses) {
            statuses.forEach(status => {
                roomData[status.name] = status;
                const roomObject = roomObjects[roomName];
                const status = roomData[roomName];
                if (roomObject && status && roomObject !== highlightedObject) {
                    const color = status.busy ? 0xff0000 : 0x00ff00;
                    roomObject.material.color.setHex(color);
                }
            });
        }


        // 実行部分
        document.addEventListener('DOMContentLoaded', () => {
            init();

            // 1. ページ読み込み時にデータを取得
            google.script.run.withSuccessHandler(updateRoomVisuals).getRoomStatus();

            // 2. 会議室全体のステータスは10分おきに更新
            setInterval(() => {
                google.script.run.withSuccessHandler(updateRoomVisuals).getRoomStatus();
            }, 600000); // 10分 = 600,000ミリ秒
        });
    </script>
</body>
</html>

簡単に現在予定がある場合は赤、空いている場合は緑にするアプリケーションのコードです。 これに、会議室の名前のラベルをつけたり、利用者のアイコンをつけたりなどのカスタマイズが可能です。

appsscript.jsonの編集

カレンダーにアクセスしたり、ドライブから画像を取得したりするために権限の設定をする必要があります。 「プロジェクトの設定」>「「appsscript.jsonマニフェスト ファイルをエディタで表示する」をチェックします。

appsscript.jsonの表示

さらにエディタに戻り、appsscript.jsonを下記のように編集します。

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {
    "enabledAdvancedServices": [
      {
        "userSymbol": "AdminDirectory",
        "version": "directory_v1",
        "serviceId": "admin"
      }
    ]
  },
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8",
  "oauthScopes": [
    "https://www.googleapis.com/auth/script.container.ui",
    "https://www.googleapis.com/auth/calendar.readonly",
    "https://www.googleapis.com/auth/drive.readonly"
  ],
  "webapp": {
    "executeAs": "USER_DEPLOYING",
    "access": "MYSELF"
  }
}

デプロイ

コードが完成したら、Webアプリとしてアクセスできるようにデプロイします。

新しいデプロイ画面

  1. スクリプトエディタ右上の「デプロイ」 > 「新しいデプロイ」を選択
  2. 「種類の選択」で歯車アイコンをクリックし、「ウェブアプリ」を選択
  3. 「アクセスできるユーザー」を「〇〇内の全員」に設定し、「デプロイ」をクリック
  4. 表示されるURLが、作成したアプリケーションのURLになります。

完成

完成したアプリの動画

簡単にデプロイし、アプリを開くことができました!!

今回は

  • ツールチップで今後の予定を表示
  • 使用者アイコンの表示(ここではNameというテキストができるようにした)

などを追加してみました!

まとめと今後の展望

今回は、GASとThree.jsを組み合わせて、身近な課題である「会議室の空き状況確認」を解決するアプリケーションを作成してみました。

  • GASの強力な連携機能: Google Workspaceのサービスを数行のコードで操作できるだけでなく、WebページのホスティングまでGASで完結できる。
  • google.script.run: この仕組みを使うことで、フロントエンドとバックエンドを簡単かつ安全に連携させることができる。

ぜひ皆さんも、身の回りの課題をGASで楽しく解決してみてはいかがでしょうか。

今後の展望

今回のアプリケーションは、単に「今使える会議室がわかる」だけでなく、さらに多くの可能性を秘めています。

例えば、GASで取得した利用状況データをスプレッドシートなどに蓄積していくことで、以下のようなオフィス改善に繋げることができます。

  • 利用率の分析とオフィス設計への活用: どの会議室がどの時間帯に最も利用されているかを分析し、データに基づいて会議室の増設時期や最適な設置場所を検討できます。
  • IoTデバイスとの連携: 各会議室に人感センサーを設置すれば、「予約されているのに誰もいない(空予約)」状態を検知して自動で予約をキャンセルする、といった高度なリソース管理が実現可能です。

このように、データ活用や外部サービス連携によって、よりスマートなオフィス環境を構築していくことができます。

We're Hiring!

燈では、こうした面白いアイデアを形にすることに興味のあるエンジニアを募集しています! 少しでも興味を持っていただけたら、カジュアル面談でお話しさせていただけると嬉しいです! akariinc.co.jp