地図ユニットを mapbox にするためにカスタムユニットの実装について

a-blog cms には Google Maps や OpenStreetMap の地図ユニットがありますが、今回は編集画面の操作感をできるだけ保ちながら、表示ライブラリを Mapbox GL JS へ置き換えたカスタムユニットを作りました。

この記事は実装手順の記録であると同時に、将来同じものを作りたいときの「AI への仕様書」でもあります。記事 URL を AI コーディングエージェントへ渡し、次のように依頼できる状態を目指しています。

この記事 https://kazumich.com/mapbox-custom-unit.html と同じ Mapbox カスタムユニットを、この a-blog cms テーマへ実装してください。既存テーマの継承構造とカスタムユニット設定を調べ、パスはプロジェクトに合わせてください。

完成したもの

編集画面では、左側に地図、右側に設定項目を配置します。

  • 住所・スポット検索を地図左上にオーバーレイ
  • マーカーをドラッグして位置を変更
  • 地図のドラッグ、クリック、検索でも位置を変更
  • 緯度、経度、ズームを自動更新
  • 地図の高さを 320 px、420 px、560 px から選択
  • Standard と Satellite を切り替え
  • 改行可能な吹き出し
  • 新規ユニットではブラウザの現在地を初期位置に利用
  • 現在地を取得できない場合は東京駅へフォールバック

公開画面では次の機能を提供します。

  • Mapbox Standard の日本語ラベル
  • ズームコントロール
  • メートル法の縮尺バー
  • ⌘ または Ctrl キーを押している間だけスクロールズーム
  • タッチ端末では2本指で地図を操作
  • 協調ジェスチャーの案内は日本語
  • 同一ページに複数の Mapbox ユニットを配置可能

実装環境

この記事の実装では次を使用しました。

  • a-blog cms 3.x
  • Mapbox GL JS 3.25.0
  • Mapbox Search Box API
  • テーマ名 site
  • カスタムユニット識別子 custom_mapbox

テーマ名やテンプレートの継承構造はサイトごとに異なります。以下の themes/site/ は、対象プロジェクトのアクティブテーマへ読み替えてください。

ファイル構成

実装で追加・変更する主なファイルは次のとおりです。

web/
├── config.server.php
├── extension/acms/Hook.php
└── themes/site/
    ├── admin/entry/unit/extend.html
    ├── include/unit/extend.html
    ├── include/head/js.html
    ├── include/edit/custom.js
    ├── js/mapbox-unit-editor.js
    ├── js/mapbox-unit.js
    ├── css/mapbox-unit-editor.css
    └── css/mapbox-unit.css

既存テーマが @extends("/_layouts/unit.html")@include("/include/unit/extend.html") を利用しているか、最初に確認してください。

1. Mapbox アクセストークンを設定する

Mapbox アカウントで pk. から始まる公開アクセストークンを作成し、config.server.php に設定します。

define('MAPBOX_ACCESS_TOKEN', 'pk.xxxxx');

公開アクセストークンはブラウザへ配信される前提の値です。ただし、Mapbox の管理画面で本番サイトと開発サイトの URL 制限を設定してください。

独自 PHP 定数はそのままではテンプレート変数にならない

ここは今回もっとも引っかかった点です。

config.server.php に定数を書いただけでは、テンプレートの %{MAPBOX_ACCESS_TOKEN} として利用できません。web/extension/acms/Hook.phpextendsGlobalVars() へ登録します。

public function extendsGlobalVars(&$globalVars)
{
    $globalVars->set(
        'MAPBOX_ACCESS_TOKEN',
        defined('MAPBOX_ACCESS_TOKEN') ? MAPBOX_ACCESS_TOKEN : ''
    );
}

テンプレートキャッシュのインクルードパスでも利用できるよう、次も追加しました。

public function addGlobalVarsInIncludePath(&$globalVarNames)
{
    $globalVarNames[] = 'MAPBOX_ACCESS_TOKEN';
}

2. カスタムユニットを登録する

a-blog cms のユニット設定で、次のカスタムユニットを追加します。

項目
タイプcustom_mapbox
ラベルMapbox
カテゴリーカスタム
配置未指定
サイズ未指定
編集画面未指定

管理画面から登録するのが基本です。自動構築する場合は、acms_config の次の設定群をサイトのブログ ID に対して追加します。

column_add_type = custom_mapbox
column_add_type_label = Mapbox
column_add_type_category_slug = custom
column_def_add_custom_mapbox_type = custom_mapbox
column_def_add_custom_mapbox_align
column_def_add_custom_mapbox_group
column_def_add_custom_mapbox_size
column_def_add_custom_mapbox_edit
column_def_add_custom_mapbox_field_1 ... field_5

column_add_typecolumn_add_type_labelcolumn_add_type_iconcolumn_add_type_classcolumn_add_type_category_slug は同じ件数・同じ順番で対応するリストです。SQL で直接追加する場合は、この並びを壊さないことが重要です。

3. 編集フォームを作る

admin/entry/unit/extend.htmlcustom_mapbox ブロックを追加します。

<!-- BEGIN custom_mapbox -->
<div class="js-mapbox-unit-editor" data-mapbox-token="%{MAPBOX_ACCESS_TOKEN}">
  <div class="mapbox-unit-editor-layout">
    <div class="mapbox-unit-editor-canvas">
      <div class="mapbox-unit-editor-search">
        <label class="acms-admin-hide-visually" for="mapbox-search-{id}">住所・スポットを検索する</label>
        <div class="mapbox-unit-editor-search-row">
          <input id="mapbox-search-{id}" type="search" class="js-mapbox-search" placeholder="住所、またはスポット名">
          <button type="button" class="js-mapbox-search-button acms-admin-btn-admin">検索</button>
        </div>
        <span class="js-mapbox-search-status" role="status"></span>
      </div>
      <div class="js-mapbox-editor-map mapbox-unit-editor-map"></div>
    </div>

    <div class="mapbox-unit-editor-fields">
      <label>地図の高さ
        <select name="mapbox_height{id}">
          <option value="320" {mapbox_height:selected#320}>320px</option>
          <option value="420" {mapbox_height:selected#420}>420px</option>
          <option value="560" {mapbox_height:selected#560}>560px</option>
        </select>
      </label>
      <label>ズーム
        <input type="number" name="mapbox_zoom{id}" value="{mapbox_zoom}" class="js-mapbox-zoom">
      </label>
      <label>緯度
        <input type="number" step="0.000001" name="mapbox_lat{id}" value="{mapbox_lat}" class="js-mapbox-lat">
      </label>
      <label>経度
        <input type="number" step="0.000001" name="mapbox_lng{id}" value="{mapbox_lng}" class="js-mapbox-lng">
      </label>
      <label>地図スタイル
        <select name="mapbox_style{id}" class="js-mapbox-style">
          <option value="standard" {mapbox_style:selected#standard}>Standard</option>
          <option value="standard-satellite" {mapbox_style:selected#standard-satellite}>Satellite</option>
        </select>
      </label>
      <label>吹き出し
        <textarea name="mapbox_caption{id}" rows="5">{mapbox_caption}</textarea>
      </label>
    </div>
  </div>

  <input type="hidden" name="unit{id}[]" value="mapbox_lat{id}">
  <input type="hidden" name="unit{id}[]" value="mapbox_lng{id}">
  <input type="hidden" name="unit{id}[]" value="mapbox_zoom{id}">
  <input type="hidden" name="unit{id}[]" value="mapbox_style{id}">
  <input type="hidden" name="unit{id}[]" value="mapbox_height{id}">
  <input type="hidden" name="unit{id}[]" value="mapbox_caption{id}">
</div>
<!-- END custom_mapbox -->

実際には各入力へ一意な idfor を設定し、{id} を必ず含めてください。name="mapbox_lat" のように固定名にすると、同じページに複数ユニットを置いたときに値が混線します。

編集画面のレイアウト

CSS Grid で左 3、右 1 程度の比率にします。検索 UI は地図の左上へ絶対配置します。

.mapbox-unit-editor-layout {
  display: grid;
  grid-template-columns: minmax(0, 3fr) minmax(200px, 1fr);
  gap: 12px;
}
.mapbox-unit-editor-canvas { position: relative; min-width: 0; }
.mapbox-unit-editor-map { width: 100%; min-height: 390px; }
.mapbox-unit-editor-search {
  position: absolute;
  z-index: 2;
  top: 12px;
  left: 12px;
  width: min(360px, calc(100% - 24px));
}
.mapbox-unit-editor-search-row { display: flex; gap: 6px; }
.mapbox-unit-editor-search-row input {
  flex: 1 1 0;
  width: 0 !important;
  max-width: none !important;
}
@media (max-width: 900px) {
  .mapbox-unit-editor-layout { grid-template-columns: 1fr; }
}

管理画面の共通 CSS に入力幅の上限があるため、検索入力には max-width: none が必要でした。指定しないと検索 UI の右側に不自然な空白が残ります。

4. 編集画面の Mapbox を初期化する

mapbox-unit-editor.js の責務は次のとおりです。

  1. Mapbox GL JS を一度だけロード
  2. .js-mapbox-unit-editor をすべて列挙
  3. 各ユニット内だけを root.querySelector() で検索
  4. 地図、マーカー、検索、入力欄をユニットごとに結び付ける
  5. 動的に追加されたユニットを MutationObserver で検出

重要部分を抜粋します。

function updatePosition(root, lngLat) {
  root.querySelector('.js-mapbox-lat').value = Number(lngLat[1]).toFixed(6);
  root.querySelector('.js-mapbox-lng').value = Number(lngLat[0]).toFixed(6);
}

async function initialize(root) {
  if (root.dataset.initialized === 'true') return;
  root.dataset.initialized = 'true';

  const latInput = root.querySelector('.js-mapbox-lat');
  const lngInput = root.querySelector('.js-mapbox-lng');
  const zoomInput = root.querySelector('.js-mapbox-zoom');
  const mapContainer = root.querySelector('.js-mapbox-editor-map');

  const map = new mapboxgl.Map({
    container: mapContainer,
    style: 'mapbox://styles/mapbox/standard',
    center: [Number(lngInput.value), Number(latInput.value)],
    zoom: Number(zoomInput.value) || 14,
    language: 'ja',
    locale: cooperativeLocale(),
    cooperativeGestures: true
  });

  const marker = new mapboxgl.Marker({ draggable: true })
    .setLngLat(map.getCenter())
    .addTo(map);

  map.on('moveend', function () {
    const center = map.getCenter();
    marker.setLngLat(center);
    updatePosition(root, [center.lng, center.lat]);
    zoomInput.value = Math.round(map.getZoom());
  });

  marker.on('dragend', function () {
    const position = marker.getLngLat();
    updatePosition(root, [position.lng, position.lat]);
    map.easeTo({ center: position });
  });

  new ResizeObserver(function () { map.resize(); }).observe(mapContainer);
}

function scan() {
  document.querySelectorAll('.js-mapbox-unit-editor').forEach(initialize);
}

scan();
new MutationObserver(scan).observe(document.body, {
  childList: true,
  subtree: true
});

複数ユニット対応の要点

複数ユニット対応で大切なのは、単に querySelectorAll() で複数の地図を作ることではありません。

地図を動かしたとき、次のようなコードでページ全体から入力欄を探すと、先頭ユニットの値だけが上書きされます。

// 悪い例
document.querySelector('.js-mapbox-lat').value = lat;

必ず、イベントが発生した地図のルート要素から検索します。

// 良い例
root.querySelector('.js-mapbox-lat').value = lat;

また、a-blog cms の編集画面では追加直後や折りたたみ中に地図コンテナのサイズが 0 になる場合があります。各地図に個別の ResizeObserver を設定し、表示サイズが変わったら map.resize() を呼びます。

5. 住所・スポット検索

Mapbox Search Box API を使います。検索ごとにセッショントークンを生成し、suggest の先頭結果を retrieve します。

const sessionToken = crypto.randomUUID();
const suggestParams = new URLSearchParams({
  q: query,
  access_token: token,
  session_token: sessionToken,
  language: 'ja',
  country: 'JP',
  limit: '5',
  proximity: `${map.getCenter().lng},${map.getCenter().lat}`
});

const suggestions = await fetch(
  `https://api.mapbox.com/search/searchbox/v1/suggest?${suggestParams}`
).then(response => response.json());

const retrieveParams = new URLSearchParams({
  access_token: token,
  session_token: sessionToken
});

const result = await fetch(
  `https://api.mapbox.com/search/searchbox/v1/retrieve/${suggestions.suggestions[0].mapbox_id}?${retrieveParams}`
).then(response => response.json());

const coordinates = result.features[0].geometry.coordinates;
map.flyTo({ center: coordinates, zoom: 14 });

通信失敗、結果0件、位置情報なしをそれぞれ処理し、検索ボタンの二重送信も防いでください。

6. 新規ユニットの初期位置に現在地を使う

緯度と経度がどちらも空の場合だけ、Geolocation API を呼びます。既存ユニットの保存値を現在地で上書きしてはいけません。

const hasSavedPosition = latInput.value !== '' && lngInput.value !== '';

if (!hasSavedPosition && navigator.geolocation) {
  navigator.geolocation.getCurrentPosition(
    function (position) {
      latInput.value = position.coords.latitude.toFixed(6);
      lngInput.value = position.coords.longitude.toFixed(6);
    },
    function () {
      latInput.value = '35.681236';
      lngInput.value = '139.767125';
    },
    { enableHighAccuracy: false, timeout: 7000, maximumAge: 300000 }
  );
}

同時に複数の新規ユニットを追加しても許可要求を繰り返さないよう、位置取得の Promise はファイル内で共有します。

7. 公開側テンプレート

include/unit/extend.html に出力ブロックを追加します。

<!-- BEGIN unit#custom_mapbox -->
<div
  class="mapbox-unit not-editor-style js-mapbox-unit"
  data-token="%{MAPBOX_ACCESS_TOKEN}"
  data-lat="{mapbox_lat}[escape]"
  data-lng="{mapbox_lng}[escape]"
  data-zoom="{mapbox_zoom}[escape]"
  data-style="{mapbox_style}[escape]"
  data-height="{mapbox_height}[escape]"
  data-caption="{mapbox_caption}[escape]"
>
  <div class="js-mapbox-map mapbox-unit-map"></div>
  <p class="js-mapbox-error mapbox-unit-error" hidden></p>
</div>

<!-- BEGIN_SetRendered id="mapbox-loader" -->
<link href="https://api.mapbox.com/mapbox-gl-js/v3.25.0/mapbox-gl.css" rel="stylesheet">
<link href="/css/mapbox-unit.css" rel="stylesheet">
<script src="https://api.mapbox.com/mapbox-gl-js/v3.25.0/mapbox-gl.js"></script>
<script src="/js/mapbox-unit.js"></script>
<!-- END_SetRendered -->
<!-- END unit#custom_mapbox -->

SetRendered を使うことで、Mapbox ユニットがあるページだけライブラリを読み込みます。include/head/js.html など、head 内で評価される場所へ次を置きます。

<!-- GET_Rendered id="mapbox-loader" -->

8. 公開側 JavaScript

公開側も全ユニットを列挙し、各コンテナ要素を直接 container に渡します。

document.querySelectorAll('.js-mapbox-unit').forEach(function (root) {
  if (root.dataset.initialized === 'true') return;
  root.dataset.initialized = 'true';

  const container = root.querySelector('.js-mapbox-map');
  const map = new mapboxgl.Map({
    container,
    style: `mapbox://styles/mapbox/${root.dataset.style || 'standard'}`,
    center: [Number(root.dataset.lng), Number(root.dataset.lat)],
    zoom: Number(root.dataset.zoom) || 14,
    language: 'ja',
    locale: cooperativeLocale(),
    cooperativeGestures: true
  });

  map.addControl(new mapboxgl.NavigationControl(), 'bottom-left');
  map.addControl(
    new mapboxgl.ScaleControl({ maxWidth: 120, unit: 'metric' }),
    'bottom-right'
  );

  new mapboxgl.Marker({ color: '#e8503f' })
    .setLngLat(map.getCenter())
    .addTo(map);
});

9. 2本指スクロールと日本語案内

通常のトラックパッド操作でページスクロールを止めないため、cooperativeGestures: true を使います。

  • Mac: ⌘ + スクロールで地図をズーム
  • Windows: Ctrl + スクロールで地図をズーム
  • タッチ端末: 2本指で地図を移動

Mapbox GL JS 3.25.0で使われている日本語ロケール設定は次のとおりです。

function cooperativeLocale() {
  return {
    'ScrollZoomBlocker.CtrlMessage':
      'Ctrlキーを押しながらスクロールして地図をズーム',
    'ScrollZoomBlocker.CmdMessage':
      '⌘キーを押しながらスクロールして地図をズーム',
    'TouchPanBlocker.Message':
      '2本の指で地図を移動'
  };
}

古い記事や MapLibre の型定義では、CooperativeGesturesHandler.MacHelpText などのキーが見つかる場合があります。しかし Mapbox GL JS 3.25.0 の実体では、ScrollZoomBlocker.*TouchPanBlocker.Message が使われています。バージョンを変更するときは、配信される Mapbox GL JS 内の既定文言とキーを再確認してください。

10. 吹き出しの改行と安全な出力

吹き出しへ HTML 文字列を直接渡さず、DOM 要素の textContent を使います。

const content = document.createElement('div');
content.className = 'mapbox-unit-popup';
content.textContent = root.dataset.caption;

const popup = new mapboxgl.Popup({ offset: 24 })
  .setDOMContent(content);

CSS で改行を保持します。

.mapbox-unit-popup {
  white-space: pre-wrap;
}

これでユーザー入力を HTML として解釈せず、改行だけを反映できます。

11. 実装後の確認項目

最低限、次を確認します。

  • Mapbox アクセストークンが pk. で始まる
  • Mapbox Styles API が 200 を返す
  • カスタムユニットが管理画面の一覧に表示される
  • 住所検索とスポット検索が動く
  • 地図移動後、同じユニットの緯度・経度・ズームだけが更新される
  • 同じエントリーに地図を2件置き、別々の座標と吹き出しを保存できる
  • 公開画面に2件とも表示される
  • 新規ユニットだけ位置情報の許可が求められる
  • 位置情報を拒否しても東京駅が表示される
  • 通常の2本指スクロールでページが動く
  • ⌘ または Ctrl 併用時だけ地図がズームする
  • 操作案内が日本語で表示される
  • 吹き出しの改行が反映される
  • 320 px、420 px、560 px の高さが反映される
  • 縮尺バーが表示される
  • JavaScript 構文エラーとブラウザコンソールエラーがない

a-blog cms ではテンプレートキャッシュにより、修正前の HTML や JS 参照が残ることがあります。テンプレート変更後はキャッシュを削除し、JS/CSS の URL へバージョン文字列を付けてブラウザキャッシュも更新してください。

AI コーディングエージェントへ依頼するときのプロンプト

この記事を AI へ渡す場合は、次のように依頼できます。

この記事の仕様と実装方針に従って、現在の a-blog cms プロジェクトへ
Mapbox カスタムユニットを実装してください。

最初に以下を調査してください。
- AGENTS.md などのプロジェクト固有ルール
- docroot とアクティブテーマ
- unit.html の継承構造
- admin/entry/unit/extend.html と include/unit/extend.html の既存実装
- カスタムユニットの登録状況
- config.server.php と extension/acms/Hook.php

要件:
- custom_mapbox として登録
- 編集画面は左地図・右設定の 2 カラム
- 検索 UI は地図左上、ズームは左下
- Search Box API で住所・スポット検索
- 新規ユニットだけ現在地を初期値にする
- 取得失敗時は東京駅
- 複数ユニットで入力値を混線させない
- moveend は同じ root 内の緯度・経度・ズームだけ更新
- MutationObserver と ResizeObserver に対応
- 公開側は日本語ラベル、縮尺、吹き出し改行に対応
- cooperativeGestures を有効化
- ScrollZoomBlocker.* の案内を常時日本語化
- Mapbox を使うページだけ SetRendered で CDN を読み込む
- トークンをコードやログへ表示しない
- 構文、HTTP 配信、2 ユニット保存を検証

このプロンプトには「既存のユーザー変更を壊さない」「テーマやブログ ID を決め打ちしない」も付け加えておくと安全です。

まとめ

今回の実装は「Google Maps ユニットを Mapbox へ置き換える」だけに見えますが、実際には次の3点が重要でした。

  1. a-blog cms のカスタムユニットとしてフィールドを正しく分離保存する
  2. 複数ユニットでイベントと入力欄を同じルート要素へ閉じ込める
  3. ページスクロールを妨げず、案内文や吹き出しまで日本語サイト向けに整える

特に、複数ユニット対応は「地図を複数初期化できた」だけでは不十分です。地図を動かしたときに別ユニットの緯度・経度を上書きしないことまで確認して、初めて実用的なカスタムユニットになります。

この記事を、人が読める解説としてだけでなく、次に AI へ実装を依頼するときの再利用可能なコンテキストとして役立ててもらえればと思います。

著者写真
この記事を書いた人
山本 一道 / 有限会社アップルップル 代表

名古屋のWeb制作会社 (有)アップルップル代表。HTMLファーストな国産CMS「a-blog cms」開発・販売・サポート / 名古屋のWeb制作者コミュニティ「WCAN」主催 / コワーキングスペース「ベースキャンプ名古屋」運営。Web制作の現場をより良くするための活動をしています。

@kazumich

関連記事

この記事のハッシュタグ #ablogcms#カスタムユニット#カスタマイズ から関連する記事を表示しています。

a-blog cms のグループユニットでメイソンリー(Masonry)レイアウトを実現する方法
a-blog cms のグループユニットでメイソンリー(Masonry)レイアウトを実現する方法
a-blog cms のグループユニットで Swiper を利用できる実装方法について
a-blog cms のグループユニットで Swiper を利用できる実装方法について
PhotoCollage.js を a-blog cms のブログテーマに実装してみた
PhotoCollage.js を a-blog cms のブログテーマに実装してみた
a-blog cms と View Transitions API で作るページ遷移のアニメーションの実装について
a-blog cms と View Transitions API で作るページ遷移のアニメーションの実装について
a-blog cms のベンチマークモードの活用法
a-blog cms のベンチマークモードの活用法
定期開催のイベントサイトを作る際のブログ設定
定期開催のイベントサイトを作る際のブログ設定