地図で指定した場所の週間天気予報をGitHubPagesで表示してみた


## 概要
- 地図をタップした場所の週間天気予報を表示する、スマートフォン向けのシンプルなアプリです
  - 地図データ提供：OpenStreetMap
  - Reverse Geocoding：Nominatim
  - 天気データ提供：Open-Meteo
  - ホスティング：GitHubPages

## デモ

https://kubo-4ka.github.io/map-tap-weekly-forecast/

## ソース

https://github.com/kubo-4ka/map-tap-weekly-forecast

## 主な機能
- 特徴
  - スマホの縦持ちに特化したデザイン
  - 地図上の任意の場所を指定 → 住所情報取得
  - Open-Meteo API による週間天気（天気・最低気温・最高気温）
  - UTF-8絵文字で天気をわかりやすく表示
  - ダークテーマ + 紫系配色で目に優しいデザイン
  - HTML / CSS / JavaScript構成（サーバ不要）
  - 各ボタンは3秒間のインターバルを設けており、APIへの負担を軽減
　
- 使い方
  - 地図の任意の場所をタップします。
  - 「場所設定」ボタンを押し、タップした場所の住所を確認します。
  - 「予報取得」ボタンを押し、選択した場所の最新の天気予報を取得します。

- 技術仕様
  - HTML / CSS / JavaScriptを用いたフロントエンドのみの構成
  - GitHub Pagesでホスト
  - 地図表示はLeafletを利用。Webページに地図を表示するための軽量で使いやすいJavaScriptライブラリ

https://leafletjs.com/

## ソースについて

<details><summary>htmlについて</summary>

🔹0. index.html 全体構成の概要
HTMLは次のような構造になっています

```html
<!DOCTYPE html>
<html lang="ja">
  <head> ... </head>
  <body>
    <h1>...</h1>
    <div id="map">...</div>
    <div id="message">...</div>
    <div id="info">...</div>
    <div id="forecast">...</div>
    <footer>...</footer>
    <script src="..."></script>
  </body>
</html>
```

🔹1. !DOCTYPE html と html lang="ja"
```html
<!DOCTYPE html>
<html lang="ja">
```
- HTML5 の文書宣言
- lang="ja" によって、文書の言語を「日本語」と指定（アクセシビリティや検索にも影響）

🔹2. head セクション
```html
<head>
  <meta charset="UTF-8" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>週間天気予報</title>
  <link rel="stylesheet" href="style.css" />
  <link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
</head>
```
- meta charset: 日本語を含む文字を正しく表示するための設定（UTF-8）
- viewport: スマホでの表示最適化（レスポンシブ対応）
- title: タブや検索結果に表示されるページタイトル
- style.css: 自作のスタイルシート
- Leaflet のCSS: 地図描画ライブラリLeafletの外部スタイル

🔹3. body セクション（画面の見える部分）(1) タイトル表示
```html
<h1>地図ベース週間天気予報</h1>
```
- ページの見出し。フォントサイズや色はCSSで制御

🔹4. body セクション (2) 地図表示エリア
```html
<div id="map"></div>
```
- 地図（Leaflet.jsで描画される）を表示するためのコンテナ
- サイズはCSSで設定（高さ300px、幅100％）

🔹5. body セクション (3) 操作案内メッセージ
```html
<div id="message">操作：地図タップ → 場所設定 → 予報取得</div>
```
- 初期表示における操作ガイド
- 操作が進むとJSで内容が書き換えられたり非表示になったりします

🔹6. body セクション (4) 情報パネル（緯度経度、住所、ボタン）
```html
<div id="info">
    <button id="setLocation">場所設定</button>
    <p id="status"></p>
    <p id="coords">緯度・経度: -</p>
    <p id="address">住所: -</p>
    <button id="fetchWeather" disabled>予報取得</button>
    <p id="fetchedTime"></p>
</div>
```
- setLocationボタン	地図で指定した地点から住所を取得するトリガー
- status	現在の状態（例：住所取得中、予報取得完了など）
- coords	タップされた緯度・経度を表示
- address	取得した住所を表示
- fetchWeatherボタン	天気予報取得ボタン。初期は無効（disabled）
- fetchedTime	予報を取得した日時を表示

🔹7. body セクション (5) 予報表示エリア

```html
<div id="forecast"></div>
```
- JavaScriptから天気情報（絵文字と気温など）が挿入される場所
- 初期は非表示にしておき、予報取得後に表示する

🔹8. body セクション (6) フッター（利用APIのクレジット）
```html
<footer>
    <div>地図データ提供：<a href="https://www.openstreetmap.org/copyright" target="_blank">OpenStreetMap</a></div>
    <div>Reverse Geocoding：<a href="https://nominatim.openstreetmap.org/ui/about.html" target="_blank">Nominatim</a></div>
    <div>天気データ提供：<a href="https://open-meteo.com/" target="_blank">Open-Meteo</a></div><br>
    このページの説明は以下の記事に記載します。<br>
    <a href="https://qiita.com/kubo4ka/items/9b7fbeca3e28599d8bc0"
        target="_blank">https://qiita.com/kubo4ka/items/9b7fbeca3e28599d8bc0</a>
</footer>
```
- 利用しているデータ/API元の明示（クレジット義務あり）
- a タグで外部リンク。target="_blank" で新しいタブに開く

🔹9. body セクション (7) スクリプト読み込み
```html
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="script.js"></script>
```
- Leafletライブラリ（JS本体）
- 自作の JavaScript（script.js）でアプリのロジックを制御

</details>

---

<details><summary>jsについて</summary>

🔹0. 全体構造の見通し
```js
// 初期変数・要素取得
// 関数：経度補正・日付整形・気温整形・天気絵文字変換
// 地図の初期化とタップ処理
// 「場所設定」ボタン処理
// 「予報取得」ボタン処理
```


🔹1. 初期変数の定義・DOM要素取得
```js
let selectedLat = null;
let selectedLng = null;
let marker = null;

const coordsEl = document.getElementById("coords");
const addressEl = document.getElementById("address");
const statusEl = document.getElementById("status");
const forecastEl = document.getElementById("forecast");
const fetchWeatherBtn = document.getElementById("fetchWeather");
const setLocationBtn = document.getElementById("setLocation");
const messageEl = document.getElementById("message");
const fetchedTimeEl = document.getElementById("fetchedTime");
// 仮マーカー（グレーっぽい）
const tempMarkerIcon = L.icon({
    iconUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-icon.png",
    iconSize: [25, 41],
    iconAnchor: [12, 41],
    popupAnchor: [1, -34],
    shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png",
    shadowSize: [41, 41],
    className: "temp-marker" // カスタムスタイル用
});

// 正式マーカー（通常アイコン）
const officialMarkerIcon = L.icon({
    iconUrl: "https://raw.githubusercontent.com/pointhi/leaflet-color-markers/master/img/marker-icon-violet.png",
    iconSize: [25, 41],
    iconAnchor: [12, 41],
    popupAnchor: [1, -34],
    shadowUrl: "https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/images/marker-shadow.png",
    shadowSize: [41, 41],
});

let lastSetTime = 0;
let lastFetchTime = 0;

```

- ユーザーが地図をタップしたときの 緯度・経度 を保持
- HTMLから取得するDOM要素を変数に保持しておき、後で動的に値を書き換える
- 仮設定アイコン、正式設定アイコンの画像を読み込み
- API呼び出しの連打防止（3秒間隔）のため、lastSetTime と lastFetchTime を使う

🔹2. 補助関数群
(a) 経度を -180〜180 に補正
```js
function normalizeLongitude(lon) {
    return ((lon + 180) % 360 + 360) % 360 - 180;
}
```

- 例えば 249° → -111° に変換
- Leafletは180を超えた経度を返せるが、Open-Meteo や Nominatim API では -180～180 の範囲外はエラーとなるため

(b) 日付文字列を "MM/DD (曜)" に整形
```js
function getFormattedDate(dateStr) {
    const date = new Date(dateStr);
    const month = String(date.getMonth() + 1).padStart(2, "0");
    const day = String(date.getDate()).padStart(2, "0");
    const weekday = date.toLocaleDateString("ja-JP", { weekday: "short" });
    return `${month}/${day} (${weekday})`;
}
```
- スマホのスペース考慮して年は省略
- 例："2025-03-30" → "03/30 (日)"
- padStart() を使ってゼロ詰め

(c) 気温を固定長表示 " - 2.3°" の形に整形
```js
function formatTemp(temp) {
    const sign = temp < 0 ? "-" : " ";
    const abs = Math.abs(temp);
    const intPart = Math.floor(abs);
    const decPart = Math.round((abs - intPart) * 10);
    const tens = intPart >= 10 ? Math.floor(intPart / 10) : " ";
    const ones = intPart % 10;
    return `${sign} ${tens}${ones}.${decPart}°`;
}
```
- スペース＋桁揃えの整形
- 例：-2.3 → " - 2.3°"、10.7 → " 10.7°"

(d) 天気コードを絵文字に変換
```js
function weatherEmoji(code) {
    if (code === 0) return '☀️';           // 快晴
    if (code === 1) return '🌤️';          // ほぼ晴れ
    if (code === 2) return '⛅';           // 薄曇り
    if (code === 3) return '☁️';           // 曇り

    if (code === 45 || code === 48) return '🌫️'; // 霧

    if (code === 51 || code === 53 || code === 55) return '🌦️'; // 霧雨
    if (code === 56 || code === 57) return '🌧️❄️';              // 凍結霧雨

    if (code === 61 || code === 63) return '🌧️';      // 弱〜中程度の雨
    if (code === 65) return '🌧️🌧️';                  // 強い雨
    if (code === 66 || code === 67) return '🌧️❄️';    // 凍結雨

    if (code === 71 || code === 73) return '🌨️';      // 弱〜中程度の雪
    if (code === 75) return '❄️❄️';                  // 強い雪
    if (code === 77) return '🌨️⛄';                   // 雪あられ

    if (code === 80) return '🚿';           // 弱いにわか雨
    if (code === 81) return '🌧️🚿';         // 中程度のにわか雨
    if (code === 82) return '🌧️🌧️🚿';       // 激しいにわか雨

    if (code === 85) return '🌨️🚿';         // 弱いにわか雪
    if (code === 86) return '🌨️❄️❄️';       // 強いにわか雪

    if (code === 95) return '⛈️';           // 雷雨
    if (code === 96) return '⛈️🧊';         // 雷雨（弱い雹）
    if (code === 99) return '⛈️🧊🧊';       // 雷雨（強い雹）

    return '❔'; // 未定義コードのフォールバック
}
```
- Open-Meteo の weathercode を WMO の定義に準拠して分類、それっぽい絵文字を割り当て
- 定義のないコードは "❔" で代替

https://www.nodc.noaa.gov/archive/arc0021/0002199/1.1/data/0-data/HTML/WMO-CODE/WMO4677.HTM

🔹3. 地図の初期化とタップイベント

```js
const map = L.map("map").setView([35.6895, 139.6917], 5);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
    attribution: "© OpenStreetMap contributors",
}).addTo(map);
```

- 地図を #map 要素に描画（Leaflet）
- 東京を中心にズームレベル5で初期表示

```js
map.on("click", function (e) {
    selectedLat = e.latlng.lat;
    selectedLng = normalizeLongitude(e.latlng.lng);
    coordsEl.textContent = `緯度・経度: ${selectedLat.toFixed(4)}, ${selectedLng.toFixed(4)}`;
    addressEl.textContent = "住所: -";
    statusEl.innerHTML = "地図がタップされました。<br>「場所設定」ボタンを押してください。";
    fetchWeatherBtn.disabled = true;
    forecastEl.innerHTML = "";
    fetchedTimeEl.textContent = "";
    forecastEl.classList.add("hidden"); // 予報非表示に戻す
    forecastEl.classList.add("hidden");

    if (marker) map.removeLayer(marker);
    marker = L.marker([selectedLat, selectedLng], { icon: tempMarkerIcon }).addTo(map);
});
```
- 地図をタップしたら座標を取得・画面に表示
- 状態やボタンの有効化を管理
- 予報エリアをリセットして非表示化

🔹4. 場所設定ボタン処理（Nominatimによる住所取得）

```js
setLocationBtn.addEventListener("click", async () => {
    const now = Date.now();
    if (now - lastSetTime < 3000) return;
    lastSetTime = now;

    if (!selectedLat || !selectedLng) {
        statusEl.innerHTML = "まず地図をタップしてください。";
        return;
    }

    statusEl.innerHTML = "住所取得中...";

    if (marker) map.removeLayer(marker);
    marker = L.marker([selectedLat, selectedLng], { icon: officialMarkerIcon }).addTo(map);
```

- 連打防止（3秒）
- マーカー更新（既にある場合は削除して再描画）


```js
    try {
        const res = await fetch(
            `https://nominatim.openstreetmap.org/reverse?lat=${selectedLat}&lon=${selectedLng}&format=json`
        );
        const data = await res.json();
        addressEl.textContent = `住所: ${data.display_name || "不明"}`;
        statusEl.innerHTML = "場所が設定されました。<br>「予報取得」ボタンを押してください。";
        fetchWeatherBtn.disabled = false;
        messageEl.textContent = ""; // 案内文消去
    } catch (err) {
        statusEl.innerHTML = "住所の取得に失敗しました。";
    }
});
```

- Reverse Geocoding API 呼び出し
- 住所の表示、状態メッセージ、ボタン有効化を行う

🔹5. 予報取得ボタン処理（Open-Meteo API 呼び出し）

```js
fetchWeatherBtn.addEventListener("click", async () => {
    const now = Date.now();
    if (now - lastFetchTime < 3000) return;
    lastFetchTime = now;

    if (!selectedLat || !selectedLng) {
        statusEl.innerHTML = "まず場所を設定してください。";
        return;
    }

    statusEl.innerHTML = "予報取得中...";

```
- 緯度経度が設定されていなければ処理中断
- 予報API呼び出し準備

```js
    try {
        const url = `https://api.open-meteo.com/v1/forecast?latitude=${selectedLat}&longitude=${selectedLng}&daily=weathercode,temperature_2m_max,temperature_2m_min&timezone=Asia%2FTokyo`;
        const res = await fetch(url);
        const data = await res.json();
        const daily = data.daily;

        const output = daily.time.map((date, i) => {
            const emoji = weatherEmoji(daily.weathercode[i]);
            const dateStr = getFormattedDate(date);
            const min = formatTemp(daily.temperature_2m_min[i]);
            const max = formatTemp(daily.temperature_2m_max[i]);

            return `
        <div class="forecast-day">
          <span>${dateStr}</span>
          <span>${emoji}</span>
          <span>${min}</span>
          <span>/</span>
          <span>${max}</span>
        </div>
      `;
        }).join("");

        forecastEl.innerHTML = output;
        forecastEl.classList.remove("hidden"); // 表示
        statusEl.innerHTML = "予報取得完了";
        fetchedTimeEl.textContent = `予報取得日時: ${new Date().toLocaleString("ja-JP")}`;
    } catch (err) {
        statusEl.innerHTML = "予報の取得に失敗しました。";
    }
});
```
- Open-Meteo APIを呼び出してデータを取得
- 日別のHTMLブロックを .forecast-day として生成・表示
- 予報エリア表示、更新日時の記録、状態更新

</details>

---

<details><summary>cssについて</summary>

🔹0. 全体構成の概要
　CSSは主に以下の構成になっています
- 基本スタイル（body・h1など）
- レイアウト要素（地図・予報エリア・フッターなど）
- ボタンデザイン（通常・ホバー・無効）
- 予報表示（gridレイアウト）
- 案内表示（メッセージ・ステータス）
- ユーティリティ（.hidden など）

🔹1. 基本スタイル
```css
body {
    margin: 0;
    padding: 0;
    font-family: sans-serif;
    background-color: #121212;
    color: #ffffff;
    text-align: center;
}

h1 {
    margin: 1rem;
    font-size: 1.5rem;
    color: #888;
}
```
- body 全体の背景色はダークテーマ（#121212）
- フォントはシンプルな sans-serif（スマホでも読みやすい）
- テキストは中央寄せ
- h1 でタイトルの余白・サイズを指定

🔹2. 地図表示と案内メッセージ
```css
#map {
    height: 300px;
    width: 100%;
}

#message {
    padding: 0.8rem;
    margin: 1rem auto;
    width: fit-content;
    color: #9050ea;
    font-weight: bold;
    font-size: 0.95rem;
}

#status {
    color: #9050ea;
    padding: 0.5rem;
    font-weight: bold;
    width: fit-content;
    margin: 0.5rem auto;
    font-size: 0.9rem;
}
```
- #map：Leafletが描画する地図領域（縦300px）
- #message・#status：
  - 紫系テキストで操作ナビゲーションを目立たせる
  - fit-content を使って中央寄せ＋自然な横幅調整

🔹3. ボタンデザイン（紫ベース＋ホバー・無効対応）

```css
button {
    padding: 0.6rem 1.2rem;
    font-size: 1rem;
    background-color: #6200ea;
    color: white;
    border: none;
    border-radius: 8px;
    cursor: pointer;
    margin: 0.5rem;
    box-shadow: 0 4px 6px rgba(0, 0, 0, 0.3);
    transition: 0.3s;
}

button:hover {
    background-color: #9575cd;
}

button:disabled {
    background-color: #555555;
    cursor: not-allowed;
}
```
- 通常：柔らかい紫色（#6200ea）と丸みのある角
- ホバー：少し明るい紫（#9575cd）に変化
- 無効状態（disabled）：灰色で明確に見た目を変える＋カーソル変更

🔹4. 予報表示スタイル（.forecast-day）
```css
#forecast {
    background-color: #1e1e1e;
    border: 1px solid #444;
    border-radius: 8px;
    margin: 1rem auto;
    padding: 1rem;
    max-width: 90%;
}
```
- 背景をやや明るめのダークグレー（視認性を保ちつつダークテーマ）
- 角丸、余白、最大幅などを調整してコンパクトに中央寄せ

```css
.forecast-day {
    display: grid;
    grid-template-columns: 20% 20% 25% 10% 10%;
    gap: 1ch;
    align-items: center;
    padding: 0.4rem 0;
    border-bottom: 1px solid #333;
    font-size: 0.9rem;
    font-family: 'Courier New', monospace;
    font-feature-settings: "tnum";
}
```
- display: grid で5列構成（日付・絵文字・最低・/・最高）
- 各列の幅を％で調整
- フォントは等幅（数字の桁をきれいに揃える）
- font-feature-settings: "tnum" で数字の揃えをさらに補強

```css
.forecast-day span {
    white-space: nowrap; /* 改行防止 */
}

.forecast-day span:first-child { justify-self: start; }
.forecast-day span:nth-child(2) { justify-self: center; }
.forecast-day span:nth-child(3) { justify-self: end; }
.forecast-day span:nth-child(4) { justify-self: center; }
.forecast-day span:last-child { justify-self: start; }

```
- spanごとの位置揃え（左寄せ／中央／右寄せ）を細かく制御


🔹5. フッターとリンク

```css
footer {
    font-size: 0.7rem;
    color: #888;
    margin: 2rem 0 1rem 0;
    line-height: 1.6;
}

footer a {
    color: #bb86fc;
    text-decoration: none;
}
```
- データ提供元を明記
- リンクカラーもダークテーマに合わせた落ち着いた色
- line-heightで行間を調整し読みやすさを確保

</details>

---

## フロー図

```mermaid
sequenceDiagram
    participant User as 利用者（スマホ）
    participant GitHub as GitHub Pages（ホスティング）
    participant OpenStreetMap as OpenStreetMap
    participant Nominatim as Nominatim
    participant OpenMeteo as OpenMeteo

    User->>GitHub: スマホでアクセス
    GitHub-->>User: HTML, CSS, JavaScript を取得
    User->>User: JavaScriptを実行（スマホ上）

    User->>OpenStreetMap: 地図をリクエスト
    OpenStreetMap-->>User: レスポンス（地図データ）

    User->>User: 場所設定ボタン（スマホ上）
    User->>Nominatim: Reverse GeocodingのAPIにリクエスト
    Nominatim-->>User: レスポンス（住所データ）

    User->>User: 予報取得ボタン（スマホ上）
    User->>OpenMeteo: 緯度経度を含めてAPIにリクエスト
    OpenMeteo-->>User: レスポンス（天気データ）

    User->>User: 天気情報を表示

```
---

## アプリについて
天気予報シリーズ第三弾。
別にシリーズにしようと思った訳ではなく、
先ず一つ作ってみたら、普通に気温がほしくなり、
二つ目で気温は付けたけど、ハードコードした経度緯度が気になり、
三つ目になりました。
今回は一応思い残す点はなく完成とできた感じです。
予報の列を揃えられた点も、前回の諦めを対応できた感じです。
ナビゲーションの場所とか、
ステータスとか、もっと画面の構成考えてからやれば良かったとは思いますが、
今回はこれで完成としました。

:::note info
似たようなことを試していますので、良ければご確認ください。

気象庁のjsonを利用した天気予報表示

https://qiita.com/kubo4ka/items/9b7fbeca3e28599d8bc0

プルダウンの場合の週間天気予報表示

https://qiita.com/kubo4ka/items/1dcc35bf350a9e600e3c

:::

以上