Google Maps API を YouTube カタログに導入し、ロケ地点と連動させる

YouTube 動画のカタログサイトに Google Maps API(Maps JavaScript API / Geocoding API)を後から入れ、ロケ地点を町名ラベルと地図で連動させた実装の記録だ。対象サイトは GeoRunning 公式Webサイトの https://georunning.work/ 。HTTP API やフロントの非同期処理には慣れているが、Maps はこれから、という人向けに書く。

先にカタログ画面の骨格を見る。続けて、どの API を何に使ったか、地点の保存、短い住所の作り方、公開側の地図、の順で進める。

GeoRunning 公式Webサイト = YouTube 動画カタログ

GeoRunning 公式Webサイトの https://georunning.work/ は、YouTube チャンネルの「GeoRunning」をはじめ、サブチャンネルの「Geo SUB-CH」、「GeoAUTO」、「GeoBreathing」の公開動画カタログだ。メイン・サブチャンネルの計4チャンネルの公会動画を同じ画面で探せる。CMS は Drupal。動画のメタデータは自前のテーブルに持ち、トップでタイルとして並べている。

GeoRunning トップの PANEL 表示。フィルタ、PANEL/MAP タブ、住所付きの動画タイルが並ぶ。
トップを開いた直後(PANEL)。上が絞り込み、下が動画タイル。各タイルの下に町名までの住所がある。

画面の骨格は、次のとおりだ。

トップページのワイヤーフレーム。ヘッダ、フィルタ、PANEL/MAP、タイル、住所ラベル、フッタ。
①ヘッダ ②絞り込み・ソート・検索 ③PANEL / MAP ④タイル一覧 ⑤町名ラベル ⑥フッタ
  • ②でチャンネル・並び順・シリーズ・キーワードを変えると、④のタイルが入れ替わる
  • ③の PANEL がタイル一覧、MAP が同じ条件の地点を地図にしたもの
  • ⑤の住所を押すと、その動画だけの地点地図が開く(後述)

タイル配置に Muuri を使う

タイルの位置決めには Muuri を使っている。フィルタやソートのたびに、残ったタイルを隙間なく並べ直すためだ。

このサイト側で決めたことは、次の程度だ。

  • ドラッグ並べ替えはしない(閲覧用カタログだから)
  • レイアウトのアニメーション時間は 0(件数が多いので動きを抑える)
  • 仮想スクロールで見えていないタイルは、レイアウト対象から外す
  • チャンネル・シリーズ・キーワード・字幕検索・各種ソートは Muuri の外で判定し、結果だけをグリッドへ渡す
  • Muuri が初期化できなかったときは CSS Grid に落とす。リスト表示に切り替えたときは Muuri を使わず DOM を直接並べる

Muuri は「残ったカードをどう並べるか」だけを担当する。「どれを残すか」「どの順か」はサイト側のロジックが決める。

Google Maps API で足したこと

カタログとしては、タイトルとサムネイルで動画を探せた。足りなかったのは「その動画がどこで撮られたか」だ。ロケは1本に複数ある。一覧では町名までを見せ、番地までは出さない。住所を押したらその動画の地点だけを開き、MAP ではいま一覧に出ている動画をまとめて見られるようにした。

タイルの住所から開いた地図。1本の動画の地点が番号付きで、衛星写真と下の札に並ぶ。
タイル下の住所を押した画面。この動画の地点だけが 1〜4 で並ぶ。下の札は町名までの保存済み文字列。

使う API は二つ

Google Maps Platform のうち、この機能に使ったのは次の二つだ。初めて Maps を触るときは、ここを取り違えやすい。

  • Maps JavaScript API … ブラウザ上に地図を描く。ピン、ズーム、ROADMAP / HYBRID、ストリートビュー(ペグマン)はこちら
  • Geocoding API … 緯度経度と住所文字列の変換。このサイトではサーバから latlng を投げ、日本語の短いラベルを得る(逆ジオコード)

API キーはブラウザ用とサーバ用で分ける。リファラ制限(JS)と IP / API 制限(サーバ)をそれぞれ付ける。

データの流れ(全体)

  1. 管理画面で、動画ごとに緯度・経度・地点名・順序を保存する
  2. 保存のタイミングで Geocoding API を呼び、「都道府県〜町名」の短文を作って一緒に保存する
  3. 公開ページのタイルは、その短文を読むだけ(開くたびに Geocoding しない)
  4. タイルの住所クリック → Maps JavaScript API で「その動画の地点」だけを表示
  5. MAP タブ → 同じ API で「いま一覧に出ている動画の地点」をまとめて表示

地点は管理画面で保存する

動画 ID に対して、地点を複数行持たせる。持つのは緯度、経度、地点名、メモ、表示順(sort_order)だ。この表示順が、あとで地図上の 1、2、3 になる。緯度経度が両方 0、または地点名が空の保存は拒否する。

保存のたびに逆ジオコードを走らせ、できた短文を地点に紐づけて置く。公開ページがタイルや地図の札に出すのは、この保存済み短文だ。

管理画面。地図をクリックして地点を選び、登録済み地点が番号・座標付きで並ぶ。
管理画面の例。地図クリックで地点を追加し、登録済み地点が番号・座標・順序で並ぶ。公開側の 1〜4 はこの一覧の順になる。

上の例では 4 地点が登録されている。リスト非表示のチェックは、カタログのタイルには出さず、地点データだけ残したいときに使う。

Geocoding API で町名までのラベルを作る

Geocoding API は、座標に対して「兵庫県神戸市中央区布引町四丁目1番1号」のような全文も返す。タイルの下に出すには長い。そこで address_components から部品を積み、丁目より下を落として保存する。

例(切り方の説明用。この駅そのものの登録値ではない):緯度 34.694839、経度 135.194942。language=ja、region=jp で返る全文は「兵庫県神戸市中央区布引町四丁目1番1号」。残すラベルは「兵庫県神戸市中央区布引町」。

以下は、実装を追えるように関数名を解説用へ置き換えた版だ。コメントの【Google】は API の一般的な手続き、【方針】はこのサイトの表示・キャッシュの決めごとだ。

<?php
// 解説用に関数名・定数名を置き換えた版。
// 【Google】= Geocoding API の一般的な使い方
// 【方針】  = このサイトで決めた表示ルール / キャッシュ方針

const TOWN_LABEL_CACHE_SECONDS = 86400 * 30; // 【方針】約30日

/**
 * 緯度経度 → 「都道府県〜町名」までの短いラベル。
 * 丁目・番地・号は出さない。
 */
function buildTownLevelLabel(float $lat, float $lon, bool $forceRefresh = false): string
{
    // 【方針】1m弱の差は同じ地点として扱う
    $latKey = round($lat, 5);
    $lonKey = round($lon, 5);
    $cacheId = 'town_label_v3:' . hash('sha256', $latKey . ',' . $lonKey);

    if (!$forceRefresh) {
        $cached = cacheGet($cacheId); // Drupal cache など
        if (is_string($cached) && $cached !== '') {
            return $cached === '__EMPTY__' ? '' : $cached;
        }
    }

    $apiKey = getMapsApiKey(); // サーバ側で保持
    if ($apiKey === '') {
        return '';
    }

    // 【Google】逆ジオコード(latlng)
    $url = 'https://maps.googleapis.com/maps/api/geocode/json?' . http_build_query([
        'latlng'   => sprintf('%F,%F', $lat, $lon),
        'language' => 'ja',   // 【方針】日本語ラベルが欲しい
        'region'   => 'jp',   // 【方針】日本の解釈を優先
        'key'      => $apiKey,
    ], '', '&', PHP_QUERY_RFC3986);

    $payload = httpGet($url);
    $decoded = json_decode($payload, true);
    if (!is_array($decoded)) {
        return '';
    }

    $status = $decoded['status'] ?? '';
    if ($status !== 'OK') {
        // 【方針】失敗もキャッシュし、空振りの連続リクエストを防ぐ
        cacheSet($cacheId, '__EMPTY__', TOWN_LABEL_CACHE_SECONDS);
        return '';
    }

    $components = $decoded['results'][0]['address_components'] ?? [];
    $label = composeTownLabelFromComponents($components);

    cacheSet(
        $cacheId,
        $label !== '' ? $label : '__EMPTY__',
        TOWN_LABEL_CACHE_SECONDS
    );

    return $label;
}

/**
 * address_components から表示用短文を組む。
 * Google は「全文」も返すが、一覧には長すぎるので使わない。【方針】
 */
function composeTownLabelFromComponents(array $components): string
{
    $prefecture = firstLongName($components, 'administrative_area_level_1');
    $locality   = firstLongName($components, 'locality');           // 市など
    $ward       = firstLongName($components, 'sublocality_level_1'); // 区など
    $county     = firstLongName($components, 'administrative_area_level_2');

    // 市区の骨格
    $base = '';
    if ($locality !== '' && $ward !== '') {
        $base = (mb_strpos($locality, $ward) !== false)
            ? $locality
            : ($locality . $ward);
    } elseif ($locality !== '') {
        $base = $locality;
    } elseif ($county !== '') {
        // 郡・支庁など locality が薄いケースのフォールバック
        $base = $county;
    }

    if ($base === '') {
        $base = $prefecture;
    }

    // 町名・字相当を足す(番地は足さない)
    $withTown = appendNeighborhood($base, $components);

    // 都道府県が頭に無ければ付ける
    $withPref = ensurePrefecturePrefix($withTown, $prefecture);

    // 「数字+丁目」以降を落とす
    return stripFromChomeOnward($withPref);
}

function appendNeighborhood(string $base, array $components): string
{
    $types = [
        'neighborhood',
        'sublocality_level_2',
        'sublocality_level_3',
        'sublocality_level_4',
        'sublocality_level_5',
    ];
    $parts = [];
    foreach ($types as $type) {
        $name = firstLongName($components, $type);
        if ($name === '' || mb_strpos($base, $name) !== false) {
            continue;
        }
        $parts[] = $name;
    }
    return $base . implode('', $parts);
}

function ensurePrefecturePrefix(string $base, string $prefecture): string
{
    $base = trim($base);
    $prefecture = trim($prefecture);
    if ($base === '') {
        return $prefecture;
    }
    if ($prefecture === '' || mb_strpos($base, $prefecture) === 0) {
        return $base;
    }
    return $prefecture . $base;
}

/** 【方針】最初の「◯丁目」とその後ろを捨てる。丁目表記が無い数字はそのまま残る */
function stripFromChomeOnward(string $s): string
{
    $s = trim($s);
    if ($s === '') {
        return '';
    }
    return trim(preg_replace('/[0-9]+丁目.*$/us', '', $s) ?? $s);
}

function firstLongName(array $components, string $wantType): string
{
    foreach ($components as $c) {
        $types = $c['types'] ?? [];
        if (!is_array($types) || !in_array($wantType, $types, true)) {
            continue;
        }
        $long = trim((string) ($c['long_name'] ?? ''));
        if ($long !== '') {
            return $long;
        }
    }
    return '';
}

ポイントだけ抜き出すと、次の三つだ。

  1. 【Google】geocode/json に latlng を渡し、results[0].address_components を読む
  2. 【方針】都道府県・市区・町名までを組み立て、最初の「数字+丁目」以降を正規表現で落とす
  3. 【方針】緯度経度を小数第5位で丸めたキーで約30日キャッシュし、空結果も覚える(失敗の連打を防ぐ)

地点名が動画タイトルとまったく同じ文字列だけ、というケースでは、それを住所ラベルとしては出さない。タイトルの繰り返しが、場所に見えてしまうためだ。必要ならキャッシュを読まずに取り直すフラグ(上の $forceRefresh)を使う。

公開ページは保存済みラベルを読む

タイルを描くとき、表示のたびに Geocoding API は呼ばない。先頭の地点について、次の順でラベルを決める。

// 擬似コード
if (保存済みの短文がある) {
  タイル下段 = 保存済みの短文;
} else if (地点名があり、かつ動画タイトルと違う) {
  タイル下段 = 地点名から「数字+丁目」以降を落としたもの;
} else {
  タイル下段 = 出さない / GPS アイコンのみ;
}

同時に、その動画の全地点(緯度経度と表示用住所)を JSON としてタイル側に埋め込んでおく。地図を開くスクリプトは、この JSON だけを読めばよい。

タイルの住所から、その動画の地点地図を開く

PANEL でタイル下の住所を押すと、モーダルが開き、Maps JavaScript API の地図がその動画の地点だけをプロットする。一覧全体には広げない。地点が2件以上なら、保存順に 1 から番号を振る。ピンと下の札が同じ番号を共有する。1件だけのときは番号を付けず、開いた直後のズームを 15 にする。複数件なら全地点に fitBounds する。

// タイルから開く地図(その動画の地点だけ)
const map = new google.maps.Map(canvas, {
  zoom: 13,
  center: { lat: 34.678541, lng: 135.010225 }, // 初期値。直後に fitBounds する
  mapTypeId: google.maps.MapTypeId.HYBRID,     // 衛星 + 道路・地名
  streetViewControl: true,
  mapTypeControl: false,                      // 標準UIは出さず、自前ボタンで切替
});

// 道路地図 ↔ 衛星(ハイブリッド)
map.setMapTypeId(
  isRoadmapNow
    ? google.maps.MapTypeId.HYBRID
    : google.maps.MapTypeId.ROADMAP
);

// 番号札 / ピン押下 → その地点へ
map.panTo(marker.getPosition());
const z = map.getZoom();
map.setZoom(typeof z === 'number' ? Math.max(z, 17) : 17);

開いた直後の地図種別は HYBRID(衛星写真に道路と地名)。自前ボタンで ROADMAP と往復し、右下のペグマンを道へ置くとストリートビューになる。番号かピンを押すと panTo し、ズームが 17 未満なら 17 まで上げる。

札の文言は、保存済みの表示用住所を先に使う。空の地点だけ、モーダルを開いたタイミングでブラウザ側 Geocoder にフォールバックする。タイル下段そのものは、開くたびに取り直さない。

MAP タブで一覧の地点をまとめて見る

MAP は別の地図インスタンスだ。プロット対象は、「いま PANEL 側で見えているタイル」に付いた地点だけ。検索やチャンネル絞り込みでタイルが隠れれば、そのピンも地図から外れる。ピンを押すと、その動画のカードを地図の上に出す。

地点は近畿の外や海外にもある。全ピンに fitBounds すると、見たい地域が端の小さな塊になる。最初の範囲は近畿のおおよその矩形に入る点だけを使う。矩形内に点が無いときだけ全点に広げる。

// 一覧全体の MAP(いま見えているタイルの地点だけ)
const KANSAI = { south: 33.4, west: 134.5, north: 35.8, east: 136.9 };

function pickInitialPoints(allPoints) {
  const inKansai = allPoints.filter((p) =>
    p.lat >= KANSAI.south && p.lat <= KANSAI.north &&
    p.lng >= KANSAI.west && p.lng <= KANSAI.east
  );
  // 近畿に1点も無ければ全点(海外だけ、など)
  return inKansai.length > 0 ? inKansai : allPoints;
}

map.fitBounds(boundsFrom(pickInitialPoints(allPoints)), 40);
google.maps.event.addListenerOnce(map, 'idle', () => {
  if (map.getZoom() > 16) map.setZoom(16); // 街区屋根まで寄らない
});

MAP を開いた直後の地図種別は ROADMAP だ。タイルから開くモーダルが HYBRID なのは、「1本のルートを地形込みで見る」用途と、「カタログ全体の分布を見る」用途を分けたためだ。どちらも道路地図 / 衛星写真 / ストリートビューを切り替えられる。

別のカタログへ持ち出すとき

  1. 動画ごとに緯度経度を複数、順序付きで保存する
  2. 保存時に Geocoding API を language=ja、region=jp で一度だけ呼ぶ
  3. 都道府県・市区町村・町名を積み、最初の「数字+丁目」以降を落とす
  4. 短文を保存し、公開のタイルと地図はそれを読む
  5. 丸めた緯度経度をキーに結果を覚え、空の結果も覚える
  6. タイルの住所ではその動画の地点だけを番号付きで出す。地図種別とストリートビューを切り替える
  7. 一覧全体の地図は、見せたい地域の内側の点だけで初期範囲を決める。ズーム上限も決める

この短文は、一覧と地図で場所のあたりを示すためのものだ。配送先や、現地で番地まで案内する画面には、別の文字列を使う。