📍

スプレッドシートとGASで作る!聖地巡礼マップ ~緯度経度マッピングとPlaceID活用と自動化への道~

に公開

はじめに

好きなアーティスト(水樹奈々さん)がライブを行った会場や聖地をGoogleマップですぐ確認したかった見たかった!という理由で聖地巡礼マップを作成して、自動化をしてみました。
本サービス:https://nm7-map.web.app/
緯度経度取得用API:https://nm7-map.web.app/areas.json

テーマ

GoogleMapsを使ったマッピングの仕方、簡単な追加、自動化
緯度経度やPlace IDの利便性に気づき、両方のいいとこ取りをして精度を上げました。
合わせて、GASが便利だなとも思いました。👍

構成

バックエンド

フロント


フロントサイトは、Firebase Hostingにあげました。

Github

https://github.com/nm7-karaage/nm7-map
フロントページ(JavaScript,HTML)、GASのスクリプトが置いてあります。

【第1章】 緯度経度ベースでのマッピングと挫折 😢

緯度/経度があれば、GoogleMapsにマッピングしていけるだろという考えのもと作業開始
Googleスプレッドシートにライブ会場名を入れて、緯度経度を一つずつ入力しようかと思ったが、今までライブが開催された会場は数知れず、これを手打ちで収集していくのは中々骨が折れる...
(2019年に開催された NANA MIZUKI LIVE EXSPRESS 2019で200公演を迎えました!おめでとうございます!)

GeminiやChatGPTのDeepSearchを使いリストを算出し、緯度経度を合わせて入力する ということもやらせてみたが、1時間程度かかって出てくるものでも緯度経度の精度が悪いものが多い
実際には、マッピングしてみると、さいたまスーパーアリーナはそこじゃないんだよ!ということが発生!

以下のような感じで普通にマッピング

  const res = await fetch('https://nm7-map.web.app/areas.json');
  allLocations = await res.json();

  map = new google.maps.Map(document.getElementById('map'), {
    center: { lat: 35.681236, lng: 139.767125 },
    zoom: 6,
    zoomControl: true
  });

  allLocations.forEach((loc) => {
    const marker = new google.maps.Marker({
      position: { lat: loc.lat, lng: loc.lng },
      map: map,
      title: loc.title,
    });

    const info = new google.maps.InfoWindow({
      content: `<strong>${loc.title}</strong><br>${loc.description}`,
    });

    marker.addListener('click', () => {
      info.open(map, marker);
    });
  });

【第2章】 救世主? PlaceIDベースへの移行 ✨

GoogleMapsには緯度経度指定とPlaceID指定でマッピングが出来るということが分かり、こちらを調査
これはGoogleMap独自のIDであり、このID指定であれば、正確にマッピングすることができるとのこと!

PlaceIDとは

https://developers.google.com/maps/documentation/places/web-service/place-id?hl=ja

引用:

プレイス ID は、Google プレイスのデータベースおよび Google マップで、特定の場所を一意に識別する ID です。

https://developers.google.com/maps/documentation/javascript/examples/places-placeid-finder

ここで手動検索ができる!
いくつか採取してマッピングOKだったので、手動でPlaceIDを取得していく
以下のように指定

async function initMap() {
   const placesService = new google.maps.places.PlacesService(map);
   allLocations.forEach(loc => {
     // placeIdがあればそれを使う
     if (loc.placeId) {
       placesService.getDetails({
         placeId: loc.placeId,
         fields: ['name', 'geometry', 'photos'] // 写真なども取得
       }, (place, status) => {
         if (status === google.maps.places.PlacesServiceStatus.OK && place && place.geometry) {
           const marker = new google.maps.Marker({
             position: place.geometry.location,
             map: map,
             title: place.name || loc.title
           });
         }
       });
     }
   });
}

【第3章】 Place IDと緯度経度を自動取得

まずはライブ会場だけでも100弱くらいあるなと思い、日本語検索とかできないかな調査しました。
APIが公開されていて、GoogleMapsAPIでPlaceIDが地名から検索ができるようです。

https://developers.google.com/maps/documentation/places/web-service/legacy/search-find-place?hl=ja

https://maps.googleapis.com/maps/api/place/findplacefromtext/json?input=${encodeURIComponent(query)}&inputtype=textquery&fields=place_id,name,geometry/location&language=ja&key=${apiKey}

先頭のデータを取得してPlaceIDとしました。
合わせて、現在位置と緯度経度で近くの聖地がないかを検索できるボタンが欲しかったのでそちらも取得するとしました。

最終的に「会場名」もスプレッドシートで管理して、会場名からGASでこのAPIを叩いて自動で入れていくスタイルとしました。
会場名と入力してスクリプトを走らせると、自動調整を行います。
最終的にフロントで使うJSONを吐き出してもらうようなGASを調整

// --- 設定値 ---
// APIキーをスクリプトプロパティに保存するためのキー名
const API_KEY_PROPERTY_URL = 'Maps_API_KEY'; // 関数名に合わせて変更

// スプレッドシートの列番号 (1から始まる)
const ID_COLUMN_NUMBER_URL = 1;          // A列 (ユニークID)
const QUERY_COLUMN_NUMBER_URL = 2;       // B列 (地名/検索クエリ)
const PLACE_ID_COLUMN_NUMBER_URL = 3;    // C列 (Place ID)
const NAME_COLUMN_NUMBER_URL = 4;        // D列 (検索結果名)
const PLACE_ID_URL_COLUMN_URL = 5;     // ★ E列 (Place IDベースURL) - 新規
const LAT_COLUMN_NUMBER_URL = 6;         // ★ F列 (緯度)
const LNG_COLUMN_NUMBER_URL = 7;         // ★ G列 (経度)
const LATLNG_URL_COLUMN_URL = 8;       // ★ H列 (緯度経度ベースURL) - 新規

const HEADER_ROWS_URL = 1;               // ヘッダー行の数
const JSON_FILE_BASENAME_URL = 'areas';  // Driveに保存するJSONファイルの基本名
const TARGET_FOLDER_PATH_URL = 'nm7/map'; // Driveの保存先のフォルダパス
// --- 設定値ここまで ---

/**
 * スプレッドシートを開いたときにカスタムメニューを追加します。
 */
function onOpen() {
  SpreadsheetApp.getUi()
      .createMenu('データ処理(URL出力対応)') // メニュー名を変更
      .addItem('1. APIキー設定', 'showApiKeyPrompt_url')
      .addSeparator()
      .addItem('PlaceID・緯度経度・URL調整とJSONエクスポートを一括実行', 'processSheetAndExportJson_url')
      .addSeparator()
      .addItem('JSONエクスポートのみ実行 (Driveへ保存)', 'callExportSheetDataToDriveOnly_url')
      .addToUi();
}

/**
 * APIキー設定用のプロンプトを表示し、スクリプトプロパティに保存します。
 */
function showApiKeyPrompt_url() {
  const ui = SpreadsheetApp.getUi();
  const currentApiKey = PropertiesService.getScriptProperties().getProperty(API_KEY_PROPERTY_URL);
  const promptMessage = currentApiKey
    ? `現在のAPIキー: ${currentApiKey.substring(0, 4)}...${currentApiKey.substring(currentApiKey.length - 4)}\n新しいAPIキーを入力 (変更しない場合はキャンセル):`
    : 'Google Maps Platform APIキーを入力してください:';
  const result = ui.prompt('APIキー設定', promptMessage, ui.ButtonSet.OK_CANCEL);

  if (result.getSelectedButton() == ui.Button.OK) {
    const apiKey = result.getResponseText();
    if (apiKey && apiKey.trim() !== "") {
      PropertiesService.getScriptProperties().setProperty(API_KEY_PROPERTY_URL, apiKey.trim());
      ui.alert('APIキーが保存されました。');
    } else if (apiKey.trim() === "" && currentApiKey) {
       ui.alert('APIキーは変更されませんでした。');
    } else {
      ui.alert('APIキーが入力されていません。');
    }
  }
}

/**
 * 指定された検索クエリ文字列からPlace ID、名前、緯度経度を取得します。
 */
function getPlaceDetailsFromQuery_url(query) {
  const apiKey = PropertiesService.getScriptProperties().getProperty(API_KEY_PROPERTY_URL);
  if (!apiKey) throw new Error('APIキーが設定されていません。「データ処理(URL出力対応)」>「1. APIキー設定」から設定してください。');
  if (!query || query.toString().trim() === "") return { placeId: '', name: '' };
  const url = `https://maps.googleapis.com/maps/api/place/findplacefromtext/json?input=${encodeURIComponent(query)}&inputtype=textquery&fields=place_id,name,geometry/location&language=ja&key=${apiKey}`;
  try {
    const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
    const data = JSON.parse(response.getContentText());
    if (response.getResponseCode() === 200 && data.status === "OK" && data.candidates && data.candidates.length > 0) {
      const candidate = data.candidates[0];
      const result = {
        placeId: candidate.place_id,
        name: candidate.name
      };
      if (candidate.geometry && candidate.geometry.location) {
        result.lat = candidate.geometry.location.lat;
        result.lng = candidate.geometry.location.lng;
      }
      return result;
    } else if (data.status === "ZERO_RESULTS") {
      Logger.log(`No results for query '${query}'`);
      return { placeId: '該当なし', name: '該当なし' };
    } else {
      Logger.log(`API Error for query '${query}': Status: ${data.status} ${data.error_message ? `- ${data.error_message}` : ''}`);
      return { placeId: `エラー: ${data.status}`, name: `エラー: ${data.status}` };
    }
  } catch (e) {
    Logger.log(`Exception fetching Place Details for query '${query}': ${e.toString()}`);
    return { placeId: '例外エラー', name: '例外エラー' };
  }
}

/**
 * 指定されたPlace IDから緯度経度を取得します (補助的な関数)。
 */
function getLatLngFromPlaceId_url_supplemental(placeId) {
  const apiKey = PropertiesService.getScriptProperties().getProperty(API_KEY_PROPERTY_URL);
  if (!apiKey) throw new Error('APIキーが設定されていません。');
  if (!placeId || placeId === '該当なし' || placeId.startsWith('エラー:')) return null;
  const url = `https://maps.googleapis.com/maps/api/place/details/json?place_id=${encodeURIComponent(placeId)}&fields=geometry/location&language=ja&key=${apiKey}`;
  try {
    const response = UrlFetchApp.fetch(url, { muteHttpExceptions: true });
    const data = JSON.parse(response.getContentText());
    if (response.getResponseCode() === 200 && data.status === "OK" && data.result && data.result.geometry && data.result.geometry.location) {
      return { lat: data.result.geometry.location.lat, lng: data.result.geometry.location.lng };
    } else {
      Logger.log(`Places API Details Error for placeId '${placeId}': Status: ${data.status} ${data.error_message ? `- ${data.error_message}` : ''}`);
      return null;
    }
  } catch (e) {
    Logger.log(`Exception fetching Place Details for placeId '${placeId}': ${e.toString()}`);
    return null;
  }
}

/**
 * 指定された行のPlace ID、名前、URL、緯度経度を調整(取得・書き込み)します。
 */
function adjustPlaceInfoAndUrlsForRow_url(sheet, rowNum) { // ★関数名を変更
  const queryCell = sheet.getRange(rowNum, QUERY_COLUMN_NUMBER_URL);
  const placeIdCell = sheet.getRange(rowNum, PLACE_ID_COLUMN_NUMBER_URL);
  const nameCell = sheet.getRange(rowNum, NAME_COLUMN_NUMBER_URL);
  const placeIdUrlCell = sheet.getRange(rowNum, PLACE_ID_URL_COLUMN_URL); // ★E列
  const latCell = sheet.getRange(rowNum, LAT_COLUMN_NUMBER_URL);       // ★F列
  const lngCell = sheet.getRange(rowNum, LNG_COLUMN_NUMBER_URL);       // ★G列
  const latLngUrlCell = sheet.getRange(rowNum, LATLNG_URL_COLUMN_URL);   // ★H列

  let updated = false;
  const currentPlaceId = placeIdCell.getValue().toString().trim();
  const currentLat = latCell.getValue().toString().trim();
  const currentLng = lngCell.getValue().toString().trim();

  // Place IDが空で、検索クエリがある場合
  if (currentPlaceId === "" && queryCell.getValue().toString().trim() !== "") {
    const query = queryCell.getValue().toString().trim();
    const result = getPlaceDetailsFromQuery_url(query);
    if (result) {
      placeIdCell.setValue(result.placeId);
      nameCell.setValue(result.name);
      if (result.placeId && result.placeId !== '該当なし' && !result.placeId.startsWith('エラー:')) {
        placeIdUrlCell.setValue(`https://www.google.com/maps/search/?api=1&query=Google&query_place_id=${result.placeId}`);
      }
      if (result.lat !== undefined && result.lng !== undefined) {
        latCell.setValue(result.lat);
        lngCell.setValue(result.lng);
        latLngUrlCell.setValue(`https://www.google.com/maps/search/?api=1&query=${result.lat},${result.lng}`);
      }
      Utilities.sleep(200); 
      updated = true;
    }
  } else if (currentPlaceId !== "" && currentPlaceId !== '該当なし' && !currentPlaceId.startsWith('エラー:')) {
    // Place IDは既にある場合、URLや緯度経度が空なら追記
    let needsUpdate = false;
    if (placeIdUrlCell.getValue().toString().trim() === "") {
        placeIdUrlCell.setValue(`https://www.google.com/maps/search/?api=1&query=Google&query_place_id=${currentPlaceId}`);
        needsUpdate = true;
    }
    if (currentLat === "" || currentLng === "") {
      const latLngResult = getLatLngFromPlaceId_url_supplemental(currentPlaceId);
      if (latLngResult) {
        latCell.setValue(latLngResult.lat);
        lngCell.setValue(latLngResult.lng);
        latLngUrlCell.setValue(`https://www.google.com/maps/search/?api=1&query=${latLngResult.lat},${latLngResult.lng}`);
        needsUpdate = true;
      }
    } else if (latLngUrlCell.getValue().toString().trim() === "" && currentLat !== "" && currentLng !== "") {
      // 緯度経度はあるがURLがない場合
      latLngUrlCell.setValue(`https://www.google.com/maps/search/?api=1&query=${currentLat},${currentLng}`);
      needsUpdate = true;
    }
    if (needsUpdate) {
        Utilities.sleep(100); // 追記の場合は少し短めのスリープ
        updated = true;
    }
  }
  return updated;
}

/**
 * 指定されたシートの全行(ヘッダー除く)に対して情報を調整します。
 */
function adjustAllPlaceInfoAndUrls_url(sheet) { // ★関数名を変更
  if (!sheet) throw new Error('情報調整処理でシートオブジェクトが無効です。');
  const lastRow = sheet.getLastRow();
  let updatedCount = 0;
  if (lastRow <= HEADER_ROWS_URL) {
    Logger.log('No data rows found for info adjustment.');
    return updatedCount;
  }
  Logger.log('Starting Place ID, Lat/Lng, and URL adjustment...');
  for (let i = HEADER_ROWS_URL + 1; i <= lastRow; i++) {
    if(adjustPlaceInfoAndUrlsForRow_url(sheet, i)) updatedCount++; // ★呼び出し先変更
  }
  Logger.log(`Place ID, Lat/Lng, and URL adjustment completed. ${updatedCount} rows updated.`);
  return updatedCount;
}

/**
 * 指定されたパスのフォルダを取得または作成します。 (Google Drive用)
 */
function getOrCreateDriveFolder_url_(path) {
  let currentFolder = DriveApp.getRootFolder();
  const folderNames = path.split('/').filter(name => name.trim() !== '');
  for (const folderName of folderNames) {
    const folders = currentFolder.getFoldersByName(folderName);
    if (folders.hasNext()) {
      currentFolder = folders.next();
    } else {
      currentFolder = currentFolder.createFolder(folderName);
      Logger.log(`Drive Folder created: ${currentFolder.getName()}`);
    }
  }
  return currentFolder;
}

/**
 * 指定されたシートのデータを読み込み、JSONオブジェクトの配列に変換します。
 * (id, title, placeId, name, lat, lng を含む)
 */
function getSheetDataAsJsonArray_url(sheet) { // ★関数名を変更
  if (!sheet) throw new Error('JSON生成処理でシートオブジェクトが無効です。');
  const lastRow = sheet.getLastRow();
  if (lastRow <= HEADER_ROWS_URL) return [];
  
  // ★ 少なくともH列 (8列目) まで読むように調整
  const lastColumnToRead = Math.max(sheet.getLastColumn(), LATLNG_URL_COLUMN_URL); 
  const dataRange = sheet.getRange(HEADER_ROWS_URL + 1, 1, lastRow - HEADER_ROWS_URL, lastColumnToRead);
  const values = dataRange.getValues();
  const areas = [];

  values.forEach(row => {
    const id = row[ID_COLUMN_NUMBER_URL - 1];
    const title = row[QUERY_COLUMN_NUMBER_URL - 1];
    const placeId = row[PLACE_ID_COLUMN_NUMBER_URL - 1];
    const nameFromSheet = row[NAME_COLUMN_NUMBER_URL - 1];    // D列
    // E列 (PlaceID URL) はJSONに含めない
    const lat = row[LAT_COLUMN_NUMBER_URL - 1];          // F列
    const lng = row[LNG_COLUMN_NUMBER_URL - 1];          // G列
    // H列 (LatLng URL) はJSONに含めない

    if (id && id.toString().trim() !== "" && title && title.toString().trim() !== "") {
      const areaObject = {
        id: id,
        title: title,
        placeId: placeId,
        name: nameFromSheet, // D列の内容を name として使用
        lat: lat,
        lng: lng
        // description は現状含めていません。もしD列をdescriptionにしたい、または別の列を使いたい場合はここを調整
      };
      areas.push(areaObject);
    }
  });
  return areas;
}

/**
 * 指定されたシートのデータをJSON形式でGoogle Driveの特定フォルダにタイムスタンプ付きファイル名で保存します。
 */
function exportSheetDataToDrive_TimestampOnly_url(sheet) { // ★関数名を変更
  if (!sheet) {
    const errorMsg = 'JSONエクスポート処理でシートオブジェクトが無効です。';
    return { success: false, error: errorMsg };
  }
  const areasArray = getSheetDataAsJsonArray_url(sheet); // ★呼び出し先変更
  if (areasArray.length === 0) {
    return { success: false, error: 'JSONエクスポート対象のデータがありませんでした。'};
  }
  const jsonString = JSON.stringify(areasArray, null, 2);
  let timestampFilename = '';
  try {
    const targetDriveFolder = getOrCreateDriveFolder_url_(TARGET_FOLDER_PATH_URL);
    if (!targetDriveFolder) {
        return { success: false, error: `ターゲットフォルダ '${TARGET_FOLDER_PATH_URL}' の取得または作成に失敗しました。` };
    }
    const timestamp = Utilities.formatDate(new Date(), Session.getScriptTimeZone(), "yyyyMMddHHmmss");
    timestampFilename = `${JSON_FILE_BASENAME_URL}_${timestamp}.json`;
    const tsFile = targetDriveFolder.createFile(timestampFilename, jsonString, "application/json");
    Logger.log(`Timestamped File '${timestampFilename}' created in Drive folder '${targetDriveFolder.getName()}'.`);
    return { success: true, timestampFilename: timestampFilename };
  } catch (e) {
    let errorMessage = e.message || "不明なエラー";
    let errorStack = e.stack || "スタックトレースなし";
    Logger.log(`Error exporting to Drive: ${e.toString()}, Stack: ${errorStack}`);
    return { success: false, error: `Google Driveへの保存中にエラー: ${errorMessage}`, errorDetails: errorStack, timestampFilename: timestampFilename };
  }
}![ezgif-3039b006b370de.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/4091463/142f0f13-80e4-43ac-a723-ff5b713c7989.gif)


/**
 * メイン関数:Place ID・緯度経度・URL調整とJSONエクスポートを一括実行します。
 */
function processSheetAndExportJson_url() { // ★関数名を変更
  const ui = SpreadsheetApp.getUi();
  try {
    const apiKey = PropertiesService.getScriptProperties().getProperty(API_KEY_PROPERTY_URL);
    if (!apiKey) {
      ui.alert('APIキーが設定されていません。「データ処理(URL出力対応)」>「1. APIキー設定」から設定してください。処理を中止します。');
      return;
    }
    const sheet = SpreadsheetApp.getActiveSpreadsheet().getActiveSheet();
    if (!sheet) {
      ui.alert('アクティブなシートが見つかりません。処理を中止します。');
      return;
    }
    Logger.log(`Processing sheet: ${sheet.getName()}`);
    ui.alert(`処理を開始します。\n1. Place ID, 名前, URL, 緯度経度の調整 (未入力または不足分のみ)\n2. JSONエクスポート (Google Driveの ${TARGET_FOLDER_PATH_URL} へタイムスタンプ付きファイルとして保存)\n行数によっては時間がかかる場合があります。`);

    const updatedRowsCount = adjustAllPlaceInfoAndUrls_url(sheet); // ★呼び出し先変更
    ui.alert(`情報調整が完了しました。\n${updatedRowsCount} 件の行で情報が更新/取得されました。`);

    const exportResult = exportSheetDataToDrive_TimestampOnly_url(sheet); // ★呼び出し先変更
    if (exportResult.success) {
      ui.alert(`'${exportResult.timestampFilename}' がGoogle Driveのフォルダ '${TARGET_FOLDER_PATH_URL}' に保存されました。\n全ての処理が完了しました。`);
    } else {
      let alertMessage = `JSONエクスポートに失敗しました: ${exportResult.error}`;
      if (exportResult.errorDetails) {
          alertMessage += `\n\nエラー詳細(一部):\n${exportResult.errorDetails.substring(0, 400)}${exportResult.errorDetails.length > 400 ? '...' : ''}`;
      }
      alertMessage += "\n\nより詳しい情報はApps Scriptエディタの実行ログを確認してください。\n情報調整は完了している可能性があります。";
      ui.alert(alertMessage);
    }
  } catch (e) {
    const errorMessage = e.message || "不明なエラー";
    Logger.log(`Error in processSheetAndExportJson_url: ${e.toString()}`);
    ui.alert(`処理中に予期せぬエラーが発生しました: ${errorMessage}\n\n詳細はApps Scriptエディタの実行ログを確認してください。`);
  }
}

【第4章】 作成したJSONでフロントエンドの実装

先ほど生成したJSONファイルをFirebase hostingにアップ
https://nm7-map.web.app/areas.json

[
{
"id": 1,
"title": "東京ドーム (東京都)",
"placeId": "ChIJ89TugkeMGGARDmSeJIiyWFA",
"name": "東京ドーム",
"lat": 35.7056396,
"lng": 139.7518913
},
~~~~~~省略~~~~~~~~

これをデータを元にGoogleMap上へマッピングをしていきます。
スマフォのズーム対応、近くの聖地があればリストアップする対応をフロント側で行いました。
getDistanceFromLatLonInKmという関数はAI選手が解説を交えて教えてくれました!ありがとうGemini😭

<!DOCTYPE html>
<html>
  <head>
    <meta charset="UTF-8" />
    <title>聖地巡礼マップ</title>
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <style>
      ~~~~~省略~~~~~
    </style>
  </head>
  <body>
    <div id="map"></div>
    <button id="checkBtn" onclick="checkProximity()">🎯 近くの聖地を調べる</button>
    <div id="alert"></div>
    <script src="script.js"></script>
    <script src="env.js"></script>
    <script>
    const script = document.createElement('script');
    script.src = `https://maps.googleapis.com/maps/api/js?key=${window.env.GOOGLE_MAPS_API_KEY}&libraries=places&callback=initMap`;
    script.async = true;
    script.defer = true;
    document.head.appendChild(script);
    </script>
  </body>
</html>
let allLocations = [];
let map = null;
let userMarker = null;
const DETECTION_RADIUS_KM = 5; // 検索半径をkmで定義 (例: 5km)

async function initMap() {
  try {
    // areas.json には、id, title, placeId, description, lat, lng が含まれている想定
    const res = await fetch('https://nm7-map.web.app/areas.json');
    if (!res.ok) {
      throw new Error(`Failed to fetch areas.json: ${res.status} ${res.statusText}`);
    }
    allLocations = await res.json();
  } catch (error) {
    console.error("Error loading or parsing areas.json:", error);
    showBanner("聖地データの読み込みに失敗しました。ページを再読み込みしてください。");
    allLocations = []; // エラー時は空の配列として処理を継続
  }

  map = new google.maps.Map(document.getElementById('map'), {
    center: { lat: 35.681236, lng: 139.767125 }, // 東京駅あたりを中心
    zoom: 6, // 初期ズームレベル
    mapTypeControl: false,
    streetViewControl: false
  });

  const placesService = new google.maps.places.PlacesService(map);
  let activeInfoWindow = null; // 現在開いている情報ウィンドウを管理

  allLocations.forEach((loc) => {
    // lat, lng が数値であり、有効な範囲にあるか基本的なチェック
    if (typeof loc.lat !== 'number' || typeof loc.lng !== 'number' ||
        loc.lat < -90 || loc.lat > 90 || loc.lng < -180 || loc.lng > 180) {
      console.warn("Invalid or missing lat/lng for location:", loc.title, loc);
      // lat/lngが無効な場合、マーカーを立てないか、エラー処理を行う
      // 今回はスキップして次の場所へ
      return;
    }

    const marker = new google.maps.Marker({
      position: { lat: loc.lat, lng: loc.lng },
      map: map,
      title: loc.title
    });

    marker.addListener("click", () => {
      if (activeInfoWindow) {
        activeInfoWindow.close(); // 既に開いているウィンドウがあれば閉じる
      }

      // 情報ウィンドウの基本コンテンツ (写真なしの状態)
      let contentString = `
        <div style="min-width:200px; max-width: 300px; padding: 5px; font-family: sans-serif;">
          <strong>${loc.title}</strong>
          <p style="margin-top: 5px; margin-bottom: 0;">${loc.description || ''}</p>
          <div id="iw-photo-${loc.placeId}" style="margin-top: 8px;">
            <small>写真情報を読み込み中...</small>
          </div>
        </div>
      `;

      const infoWindow = new google.maps.InfoWindow({
        content: contentString
      });
      infoWindow.open(map, marker);
      activeInfoWindow = infoWindow;

      // Place IDがあれば写真を取得して情報ウィンドウを更新
      if (loc.placeId && loc.placeId !== '該当なし' && !loc.placeId.startsWith('エラー:')) {
        placesService.getDetails({
          placeId: loc.placeId,
          fields: ['name', 'photos'] // 写真と名前(タイトル確認用)をリクエスト
        }, (place, status) => {
          if (status === google.maps.places.PlacesServiceStatus.OK && place) {
            let photoHtml = '<small>写真はありません。</small>'; // デフォルトメッセージ
            if (place.photos && place.photos.length > 0) {
              const photoUrl = place.photos[0].getUrl({ maxWidth: 280, maxHeight: 180 }); // サイズ調整
              photoHtml = `<img src="${photoUrl}" alt="${place.name || loc.title}" style="width:100%; max-height: 180px; object-fit: cover; border-radius:8px;">`;
            }

            // 現在開いている情報ウィンドウのコンテンツを更新(写真部分のみ)
            // 注意: infoWindow.getContent() で既存コンテンツを取得して一部置換するのは複雑なので、
            //       写真表示用のプレースホルダーDOM要素を特定して内容を書き換える
            const photoDiv = document.getElementById(`iw-photo-${loc.placeId}`);
            if (photoDiv && infoWindow.getMap()) { // photoDiv が存在し、かつウィンドウが開いている場合のみ更新
                 photoDiv.innerHTML = photoHtml;
            } else if (infoWindow.getMap()) { // ウィンドウは開いているが、何らかの理由でphotoDivが特定できない場合 (稀)
                // コンテンツ全体を再構築して設定することもできるが、ちらつきの原因になる可能性
                const updatedContentString = `
                    <div style="min-width:200px; max-width: 300px; padding: 5px; font-family: sans-serif;">
                      ${photoHtml}
                      <strong>${place.name || loc.title}</strong>
                      <p style="margin-top: 5px; margin-bottom: 0;">${loc.description || ''}</p>
                    </div>
                  `;
                infoWindow.setContent(updatedContentString);
            }
          } else {
            Logger.warn(`Place Details (photos) request failed for ${loc.title} (Place ID: ${loc.placeId}). Status: ${status}`);
            const photoDiv = document.getElementById(`iw-photo-${loc.placeId}`);
            if (photoDiv && infoWindow.getMap()) {
                 photoDiv.innerHTML = '<small>写真情報の取得に失敗しました。</small>';
            }
          }
        });
      } else {
        // Place IDがない場合は写真なし
        const photoDiv = document.getElementById(`iw-photo-${loc.placeId}`); // placeIdがない場合、このIDは不適切になる可能性
        if (photoDiv && infoWindow.getMap()) { // このIDのdivは存在しないはずなので、より一般的なセレクタが必要かも
             photoDiv.innerHTML = '<small>写真情報はありません (Place IDなし)。</small>';
        } else if (document.querySelector(`#iw-photo-${loc.id}`) && infoWindow.getMap()){ // 代わりに loc.id を使う
             const photoDivById = document.querySelector(`#iw-photo-${loc.id}`);
             if(photoDivById) photoDivById.innerHTML = '<small>写真情報はありません (Place IDなし)。</small>';
        }
        Logger.warn(`No Place ID for location: ${loc.title}. Cannot fetch photos.`);
      }
    });
  });

  locateAndCenterMap();
}

function locateAndCenterMap() {
  if (!navigator.geolocation) {
    showBanner("お使いのブラウザでは位置情報を取得できません。");
    return;
  }
  navigator.geolocation.getCurrentPosition(
    (pos) => {
      const { latitude: lat, longitude: lng } = pos.coords;
      map.setCenter({ lat, lng });
      map.setZoom(14);
      updateUserMarker(lat, lng);
    },
    (err) => {
      console.warn("位置情報が取得できません: " + err.message);
      showBanner("位置情報が取得できませんでした。設定を確認してください。");
    },
    { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
  );
}

/**
 * Haversine公式を使って2点間の距離をkmで計算します。
 */
function getDistanceFromLatLonInKm(lat1, lon1, lat2, lon2) {
  const R = 6371; // 地球の半径 (km)
  const dLat = deg2rad(lat2 - lat1);
  const dLon = deg2rad(lon2 - lon1);
  const a =
    Math.sin(dLat / 2) * Math.sin(dLat / 2) +
    Math.cos(deg2rad(lat1)) * Math.cos(deg2rad(lat2)) *
    Math.sin(dLon / 2) * Math.sin(dLon / 2);
  const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
  const d = R * c; // 距離 (km)
  return d;
}

function deg2rad(deg) {
  return deg * (Math.PI / 180);
}

function checkProximity() {
  if (!navigator.geolocation) {
    showBanner("お使いのブラウザでは位置情報を取得できません。");
    return;
  }
  if (allLocations.length === 0) {
    showBanner("聖地データが読み込まれていません。");
    return;
  }

  showBanner("現在地を取得し、近くの聖地を検索中...");

  navigator.geolocation.getCurrentPosition(
    (pos) => {
      const { latitude: userLat, longitude: userLng } = pos.coords;
      updateUserMarker(userLat, userLng);
      map.setCenter({ lat: userLat, lng: userLng });
      map.setZoom(13);

      const nearbyLocations = [];
      allLocations.forEach(loc => {
        if (typeof loc.lat !== 'number' || typeof loc.lng !== 'number') {
          return;
        }
        const distance = getDistanceFromLatLonInKm(userLat, userLng, loc.lat, loc.lng);
        if (distance <= DETECTION_RADIUS_KM) {
          nearbyLocations.push({ ...loc, distance: distance });
        }
      });

      if (nearbyLocations.length > 0) {
        nearbyLocations.sort((a, b) => a.distance - b.distance);

        let message = `近くの聖地 (${DETECTION_RADIUS_KM}km以内):\n`;
        nearbyLocations.slice(0, 5).forEach(loc => {
          message += `- ${loc.title} (約${loc.distance.toFixed(1)}km)\n`;
        });
        if (nearbyLocations.length > 5) {
          message += `${nearbyLocations.length - 5}件...`;
        }
        showBanner(message, true);
      } else {
        showBanner(`現在地の${DETECTION_RADIUS_KM}km以内に聖地は見つかりませんでした。`);
      }
    },
    (err) => {
      console.warn("位置情報が取得できませんでした: " + err.message);
      showBanner("位置情報が取得できませんでした: " + err.message);
    },
    { enableHighAccuracy: true, timeout: 10000, maximumAge: 0 }
  );
}

function updateUserMarker(lat, lng) {
  const pos = { lat, lng };
  if (userMarker) {
    userMarker.setPosition(pos);
  } else {
    userMarker = new google.maps.Marker({
      position: pos,
      map,
      title: "あなたの現在地",
      icon: {
        path: google.maps.SymbolPath.CIRCLE,
        scale: 8,
        fillColor: '#4285F4',
        fillOpacity: 1,
        strokeWeight: 2,
        strokeColor: '#ffffff'
      },
      zIndex: 999
    });
  }
}
~~~~~省略~~~~~

終わりに

この仕組みをすべて自動化することにより、好きなアニメやアーティストの名前を入れたら自動でその作品やアーティストにまつわる場所をDeepSearchなどで特定→緯度経度を自動で調査、即席でマッピングするというサービスもできるのではないかと思いました。

Discussion