地図上の地点の週間天気予報を OpenWeatherMap と ZENRINMapsAPI で表示してみる


# 要約

本記事では、地図上の特定地点に対する 1 週間の天気予報を、OpenWeatherMap と ZENRINMapsAPI の 2 種類の天気 API を使って表示する方法についてご紹介いたします。OpenWeatherMap は豊富な天気データ（現在の天気、1 分ごとの予報、時間ごとの予報、1 週間の予報、アラート情報など）を返すほか、天気マップレイヤーも提供しておりますので、通常は Google Maps や Leaflet、OpenStreetMap などの他の地図 API と組み合わせて使います。一方、ZENRINMapsAPI (search_weather_week_info) は、同社の JavaScript Map API ([こちら](https://developers.zmaps-api.com/v20/reference/javascriptAPI/)) と連携することで、天気情報と地図データを同じ環境でスムーズに統合できるのが魅力です。

# 1. はじめに

天気予報は、日々の予定を立てる上で欠かせない情報ですよね。今回、1 週間の天気予報を地図上に表示するプロジェクトにチャレンジしてみました。ここでは、以下の 2 種類の API を比較して使ってみた結果をご紹介いたします。

- **OpenWeatherMap One Call API 3.0**  
  基本的には豊富な天気データと天気マップレイヤーを提供する API ですが、One Call API 3.0 は有料プランとなるため、通常の API キーでは使えません。そこで、ウィジェットコンストラクター（[こちら](https://openweathermap.org/widgets-constructor)）を活用して、簡単に天気ウィジェットのコードを作成いたしました。

- **ZENRINMapsAPI (search_weather_week_info)**  
  こちらは、2 ヶ月間のトライアルキーを使って無料でいろいろな API を試せます。同社の JavaScript Map API と連携すれば、天気情報と地図データを同じプラットフォーム上でスムーズに扱えるので、とても便利です。

# 2. API キーの取得方法

## OpenWeatherMap

API キーを取得するには、まずアカウントを作成していただく必要がございます。[Sign Up ページ](https://home.openweathermap.org/users/sign_up) で必要な情報を入力して「Create Account」をクリックしてください。
![OWMform.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3981491/196171ad-1416-4b51-964c-5856a5269b51.png)

その後、画面に表示される API キーをご確認ください。

## Zenrin Maps API

ZENRIN Maps API を利用するためには、検証用 ID とパスワードを取得する必要がございます。  
必要事項を入力して送信すると、下記のフォームから簡単にお試し ID を発行できます。
（2 か月無料でお試しいただけます）
🆓[ZENRIN Maps API 無料お試し ID お申込みフォーム](https://www.zenrin-datacom.net/solution/zenrin-maps-api/trial?fm_cp=6757baf3203e3a00bb118509&fm_mu=67639d6a0dbf8e060dd6271a&utm_campaign=6757baf3203e3a00bb118509&utm_medium=else&utm_source=Qiita31)
![trialForm.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3981491/95e46565-c04a-4be1-967c-dc17b63ad9f8.png)

### 検証用 ID とパスワードの確認

フォーム送信後、3 営業日以内にメールで検証用 ID とパスワードが届きます。  
[こちら](https://qiita.com/goldie9/items/1983bf8c4ad796290cbf) をご参照いただき、コンソール内の設定や API キー、認証方式の設定を行ってください。

# 3. 各 API の特徴

## OpenWeatherMap One Call API 3.0

- **提供内容**: 現在の天気、1 分ごとの予報、時間ごとの予報、1 週間の予報、アラート情報など、豊富な天気データおよび天気マップレイヤーを返します。
- **利用方法**: 通常の API キーでは使えず、ウィジェットコンストラクターを使うことで、既製のウィジェットコードを取得して利用いただけます。
- **注意点**: 天気マップレイヤー自体は地図表示機能がないため、Google Maps や Leaflet、OpenStreetMap など、他の地図 API と組み合わせて表示する必要がございます。

## ZENRINMapsAPI (search_weather_week_info)

- **提供内容**: 1 指定した緯度経度、住所コード、または予報区域コードから、7 日間の天気情報を取得できます。 取得できる情報には、各日の天気、降水確率、最高・最低気温、信頼度などが含まれます。
- **利用方法**: 3 ヶ月間のトライアルキーを利用して、自由に API を試すことが可能です。
- **強み**: の API は、同社の JavaScript Map API と組み合わせることで、天気情報と地図データを同一プラットフォーム上でシームレスに統合でき、視覚的にわかりやすい表示が可能です。

# 4. 実装例と考察

### OpenWeatherMap のアプローチ

OpenWeatherMap では、豊富な天気データと天気マップレイヤーを提供するため、基本的には地図表示をしないでウィジェットのみ表示しました。今回、OpenWeatherMap が提供しているウィジェットコンストラクターを使って簡単にウィジェットコードを生成し、HTML で表示する形で実装しました。手軽に天気情報を追加できる点が魅力です。

#### Widgets constructor

![OWMwidget.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3981491/67018da3-1735-4148-82f8-08762e24ea51.png)

**Your Api key**と**Your city name**に入力して**Search city**を押下すると**Select type widgets:**でウィジェットが生成され、コードをコピーするだけです。

#### ソースコード

<details>

<summary>生成されたコード</summary>

```HTML
<!DOCTYPE html>
<html lang="en">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Document</title>
    <script src='//openweathermap.org/themes/openweathermap/assets/vendor/owm/js/d3.min.js'></script>
    <script>
        window.myWidgetParam ? window.myWidgetParam : window.myWidgetParam = [];
        window.myWidgetParam.push({
            id: 11,
            cityid: '1850147',
            appid: 'You-API-key',
            units: 'metric',
            containerid: 'own',
        });
        (function () {
            var script = document.createElement('script');
            script.async = true;
            script.charset = "utf-8";
            script.src = "//openweathermap.org/themes/openweathermap/assets/vendor/owm/js/weather-widget-generator.js";
            var s = document.getElementsByTagName('script')[0];
            s.parentNode.insertBefore(script, s);
        })();
    </script>
</head>

<body>
    <div id="own"></div>
</body>

</html>

```

</details>

#### 週間天気予報ウィジェット

![weatherOWM.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3981491/35f9ed9f-1bc7-46fd-acc2-a1601cb10a5c.png)

### ZENRINMapsAPI のアプローチ

ZENRINMapsAPI では、`search_weather_week_info` エンドポイントを利用して、指定した地点の 1 週間の天気予報を直接取得いたします。さらに、同社の JavaScript Map API を使えば、天気情報と地図データを同じ環境で統合できるので、ユーザーは直感的に天気予報と地図情報を確認することが可能です。

#### ソースコード

<details>
<summary>HTML</summary>

```HTML
<!DOCTYPE html>
<html lang="ja">

<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>ZenrinMapsAPI</title>
    <link rel="stylesheet" href="style.css">
    <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css">
    <script src="	https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js
    "></script>
    <script src="https://{DOMAIN}/zma_loader.js?key=[APIキー]&auth=[認証方式]"></script>
</head>

<body>
    <h4>気象コンテンツ検索[週間天気予報]</h4>
    <div class="container-fluid">
        <div id="info">
            <div id="ZMap">
                <div class="loading d-flex justify-content-center align-items-center visually-hidden">
                    <span class="visually-show">読み込み中
                        <div class="spinner-grow spinner-grow-sm text-dark" role="status">
                        </div>
                        <div class="spinner-grow spinner-grow-sm text-secondary" role="status">
                        </div>
                        <div class="spinner-grow spinner-grow-sm text-light" role="status">
                        </div>
                    </span>
                </div>
                <button type="button" class="btn btn-warning" id="liveToastBtn">ヒントを表示</button>
                <div class="toast-container">
                    <div id="liveToast" class="toast" role="alert" aria-live="assertive" aria-atomic="true">
                        <div class="toast-header">
                            <strong class="me-auto">Tips</strong>
                            💡
                            <button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
                        </div>
                        <div class="toast-body">
                            <p>
                                💡 Ctrl キーを押しながらマウスを上下にドラッグすると、地図を傾けることができます。<br><br>
                                💡 Ctrlキーを押しながらマウスを左右にドラッグすると地図を回転できます。
                            </p>
                        </div>
                    </div>
                </div>
            </div>
            <p class="icon_refere">※天気アイコンは <a href="https://www.jma.go.jp/jma/index.html" target="_blank"
                    rel="noopener noreferrer">気象庁ホームページより</a></p>

            <div class="card-group" id="week-forecast">
            </div>
        </div>
    </div>

    <script src="script.js"></script>
</body>

</html>
```

</details>

<details>
<summary>Javascript</summary>

```javascript
// ポップアップデータ用のグローバルオブジェクトとキャッシュ
let map, weatherToday_widget;
const todayPopupCache = {};

// トースト（通知）処理（変更なし）
const toastTrigger = document.getElementById("liveToastBtn");
const toastLive = document.getElementById("liveToast");
if (toastTrigger) {
  const toastBootstrap = bootstrap.Toast.getOrCreateInstance(toastLive);
  toastTrigger.addEventListener("click", () => toastBootstrap.show());
}

// ローディング要素の取得と表示クラスの設定
const loadingElement = document.querySelector(".loading");
loadingElement.classList.remove("visually-hidden");
loadingElement.classList.add("visually-show");

// ヘルパー：本日の日付と7日後の日付を計算する関数
function from_to_date() {
  const today = new Date();
  const formattedToday = today.toISOString().slice(0, 10).replace(/-/g, "");
  today.setDate(today.getDate() + 7);
  const sevenDaysLater = today.toISOString().slice(0, 10).replace(/-/g, "");
  return [formattedToday, sevenDaysLater];
}

// ヘルパー：指定されたタイプに基づいて日付文字列を変換する関数
function convertDate(forecastDate, type) {
  if (!forecastDate) return "";
  if (type === "week") {
    const date = new Date(forecastDate);
    if (isNaN(date)) throw new Error("無効な日付形式です。");
    const day = date.getDate();
    const month = date.getMonth() + 1;
    const dayOfWeek = new Intl.DateTimeFormat("ja-JP", {
      weekday: "short",
    }).format(date);
    return `${month}/${day}日（${dayOfWeek}）`;
  } else if (type === "today") {
    const year = parseInt(forecastDate.slice(0, 4), 10);
    const month = parseInt(forecastDate.slice(4, 6), 10) - 1;
    const day = parseInt(forecastDate.slice(6, 8), 10);
    const date = new Date(year, month, day);
    const dayOfWeek = date.toLocaleDateString("ja-JP", { weekday: "short" });
    return `${day}日（${dayOfWeek}）`;
  }
  return forecastDate;
}

// ヘルパー：天気ポップアップのHTMLを作成する関数
function createTodayPopupHTML(data, area_name) {
  const weatherCodeMap = {
    0: "資料無し",
    1: "100",
    2: "200",
    3: "300",
    4: "303",
    5: "400",
  };
  const forecastTypeMap = {
    "01": "予報",
    "02": "現況",
    "03": "過去",
  };

  const weather_code_str = weatherCodeMap[data.weather.code] || "不明";
  const forecast_type_str = forecastTypeMap[data.type] || "不明";
  const formattedForcastDate = convertDate(data.datetime, "today");

  return `
        <table>
            <tr><td><strong>${area_name}${forecast_type_str}</strong></td></tr>
            <tr><td><img src="https://www.jma.go.jp/bosai/forecast/img/${weather_code_str}.svg" class="today_img" alt="${data.weather.text}"></td></tr>
            <tr><td><strong>${data.weather.text}</strong></td></tr>
            <tr><td>${formattedForcastDate}</td></tr>
            <tr><td><b>気温</b> ${data.temperature.value}℃</td></tr>
        </table>
    `;
}

// API呼び出しをPromiseでラップしてasync/awaitで使用する関数
function fetchTodayWeather(location) {
  // ローディング要素を表示する
  loadingElement.classList.remove("visually-hidden");
  loadingElement.classList.add("visually-show");
  const api = "/search/weather/search_weather_info";
  const params = { position: `${location.lng},${location.lat}` };

  return new Promise((resolve, reject) => {
    try {
      map.requestAPI(api, params, (response) => {
        if (response.ret && response.ret.status === "OK") {
          resolve(response.ret.message.result.item[0]);
          // API呼び出し完了後、ローディング要素を非表示にする
          loadingElement.classList.remove("visually-show");
          loadingElement.classList.add("visually-hidden");
        } else {
          reject("Today_weather検索失敗");
        }
      });
    } catch (error) {
      reject(error);
    }
  });
}

// 最適化済み：キャッシュ付きの非同期today_popup関数
async function today_popup(location, area_name) {
  const cacheKey = `${location.lng},${location.lat}`;
  if (todayPopupCache[cacheKey]) {
    showTodayPopup(todayPopupCache[cacheKey], location, area_name);
    return;
  }
  try {
    // ローディング要素を表示（必要に応じて）
    loadingElement.classList.remove("visually-show");
    const data = await fetchTodayWeather(location);
    todayPopupCache[cacheKey] = data; // 結果をキャッシュする
    showTodayPopup(data, location, area_name);
  } catch (error) {
    console.error("Today_weatherエラーが発生しました:", error);
  }
}

// ヘルパー：ポップアップウィジェットを作成して追加する関数
function showTodayPopup(data, location, area_name) {
  if (weatherToday_widget) {
    removePopup();
  }
  const htmlContent = createTodayPopupHTML(data, area_name);
  weatherToday_widget = new ZDC.Popup(location, { htmlSource: htmlContent });
  map.addWidget(weatherToday_widget);
}

// ポップアップウィジェットを削除する関数
function removePopup() {
  map.removeWidget(weatherToday_widget);
}

// マーカー作成関数（各マーカーに対してローカル変数を使用）
function showMarker(weather_poi) {
  weather_poi.forEach((location) => {
    const marker = new ZDC.Marker(location, {
      styleId: ZDC.MARKER_COLOR_ID_RED_L,
      contentStyleId: ZDC.MARKER_NUMBER_ID_STAR_L,
    });
    marker.addEventListener("click", function () {
      week_forecast(marker.getLatLng());
    });
    map.addWidget(marker);
  });
}

// ヘルパー関数：ファイルの存在をチェックした後にアイコンのHTMLを取得する
async function getWeatherIconHTML(weather_code, weather_status) {
  const imgUrl = `weather_icon/${weather_code}.svg`;
  try {
    // HEADリクエストを実行してファイルの存在を確認する
    const response = await fetch(imgUrl, { method: "HEAD" });
    if (response.ok) {
      // ファイルが存在する場合はimgタグを返す
      return `<img src="${imgUrl}" class="card-img-top" alt="${weather_status}">`;
    } else {
      // ファイルが存在しない場合はテキストを返す
      return `<p class="m-0">${weather_status}</p>`;
    }
  } catch (error) {
    // エラーが発生した場合は天気状態のテキストを返す
    return `<p class="m-0">${weather_status}</p>`;
  }
}

// 週間天気予報関数（非同期処理でアイコン存在チェックを行うよう更新）
function week_forecast(location) {
  const [fromDate, toDate] = from_to_date();
  const api = "/search/weather/search_weather_week_info";
  const params = {
    position: `${location.lng},${location.lat}`,
    datefrom: fromDate,
    dateto: toDate,
    datum: "JGD",
  };

  try {
    map.requestAPI(api, params, function (response) {
      if (response.ret && response.ret.status === "OK") {
        (async function () {
          const weather_response = response.ret.message.result;
          let weatherCards = "";
          let area_name = "";

          // 各日の予報データを非同期で処理する
          for (let i = 0; i < 7; i++) {
            const dailyWeather = weather_response.weather[i];
            const weather_code = dailyWeather.weather_data.weather_cd;
            const weather_status = dailyWeather.weather_data.weather_text;
            const forecastDate = dailyWeather.weather_data.forecast_date;
            const precipChance = extractNumber(
              dailyWeather.weather_data.precipChance
            );
            const max_temp = dailyWeather.weather_data.max_temp_degree;
            const min_temp = dailyWeather.weather_data.min_temp_degree;
            const pref_name = dailyWeather.pref_nm;
            let reliability = dailyWeather.weather_data.reliability;
            area_name = dailyWeather.area_nm;

            // ヘッダー用カード：ラベルを1回のみ表示する
            if (i === 0) {
              weatherCards += `                    
                            <div class="card text-center">
                                <hr class="border border-dark border-2 opacity-50">
                                <div class="card-body">
                                    <h5 class="areaName">${area_name}</h5>
                                    <p class="card-text">日付</p>
                                    <hr class="border border-dark border-2 opacity-50">
                                    <p class="card-text">降水確率(%)</p>
                                    <hr class="border border-dark border-2 opacity-50">
                                    <h5 class="card-title">${pref_name}</h5>
                                    <p class="card-text"><span style="color:red;">最高</span> / <span style="color:blue;">最低</span> (℃)</p>
                                    <hr class="border border-dark border-2 opacity-50">
                                    <p class="card-text"><small class="text-body-secondary">信頼度</small></p>
                                </div>
                            </div>`;
            }

            if (reliability === null) reliability = "-";
            const formattedForcastDate = convertDate(forecastDate, "week");

            // HEADリクエストをawaitしてアイコンのHTMLを取得する
            const iconHTML = await getWeatherIconHTML(
              weather_code,
              weather_status
            );

            weatherCards += `
                            <div class="card text-center">
                                <hr class="border border-dark border-2 opacity-50">
                                ${iconHTML}
                                <div class="card-body">
                                    <h5 class="card-title">${weather_status}</h5>
                                    <p class="card-text">${formattedForcastDate}</p>
                                    <hr class="border border-dark border-2 opacity-50">
                                    <p class="card-text">${precipChance}</p>
                                    <hr class="border border-dark border-2 opacity-50">
                                    <h5 class="temp"></h5>
                                    <p class="card-text"><span style="color:red;">${max_temp}</span> / <span style="color:blue;">${min_temp}</span></p>
                                    <hr class="border border-dark border-2 opacity-50">
                                    <p class="card-text"><small class="text-body-secondary">${reliability}</small></p>
                                </div>
                            </div>`;
          }
          document.getElementById("week-forecast").innerHTML = weatherCards;
          today_popup(location, area_name);
        })();
      } else {
        console.error("Week_Weather検索失敗");
      }
    });
  } catch (error) {
    console.error("Week_Weather検索中にエラーが発生しました:", error);
  }
}

// 'パーセント'を含む文字列から数値を抽出する関数
function extractNumber(input) {
  if (input.includes("パーセント")) {
    let numberString = input.replace("パーセント", "");
    numberString = numberString.replace(/[０-９]/g, (char) =>
      String.fromCharCode(char.charCodeAt(0) - 0xfee0)
    );
    return parseInt(numberString, 10);
  }
  return input;
}

// ZMALoaderを初期化して地図を生成する
ZMALoader.setOnLoad(function (mapOptions, error) {
  if (error) {
    console.error(error);
    return;
  }
  Object.assign(mapOptions, {
    mouseWheelReverseZoom: true,
    centerZoom: false,
    center: new ZDC.LatLng(35.6883444933389, 139.75312809703533),
    rotatable: true,
    tiltable: true,
    zoom: 10,
    minZoom: 3,
  });

  map = new ZDC.Map(
    document.getElementById("ZMap"),
    mapOptions,
    function () {
      const weather_poi = [
        new ZDC.LatLng(35.6883444933389, 139.75312809703533), // 東京
        new ZDC.LatLng(43.060015261847646, 141.35439106869504), // 札幌
        new ZDC.LatLng(34.670229387890956, 135.49805041142122), // 大阪
        new ZDC.LatLng(33.589826045571265, 130.40334745425807), // 福岡
        new ZDC.LatLng(26.219315985997966, 127.67049106354028), // 那覇
      ];
      week_forecast(weather_poi[0]);
      showMarker(weather_poi);
    },
    function () {
      console.error("APIエラー");
    }
  );
});
```

</details>

#### 週間天気予報 [参照サイト](https://orion8-dev.github.io/week-weather-forecast/zmap_index.html)

![weatherZDC.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3981491/afe3b7cb-2db0-40e4-905b-78939fe12faa.png)
※天気アイコンは[気象庁ホームページより](https://www.jma.go.jp/jma/index.html)

# 5. 結論

今回のプロジェクトでは、OpenWeatherMap と ZENRINMapsAPI の両方を使って週間の天気予報を地図上に表示する方法を試してみました。

- **OpenWeatherMap** は、ウィジェットコンストラクターを使うことで簡単に天気ウィジェットを生成できますが、天気マップレイヤー単体のため、他の地図 API と組み合わせる必要がございます。
- **ZENRINMapsAPI** は、同社の JavaScript Map API と連携することで、天気情報と地図データを同一プラットフォーム上で簡単に統合でき、シームレスな表示が可能となっております。