イラストマップ・パン巡回

1枚の大きなイラストマップの中を、カメラが滑らかにパンして名所を順番に巡ります。到着するとスポット名がポンと現れる。観光・地域サイトのナビゲーションを、viewBox操作だけで実現する手法です。

#svg#viewbox#map#camera-pan

ライブデモ

使用例(お題: カフェ MOON BREW)

この技法を「カフェ MOON BREW」というテーマのダミーサイトで実際に使った例です。

HTML
<!-- MOON BREW: 店舗マップ。カメラが街を巡り、各店をポンと案内 -->
<div class="stage">
  <svg class="map" data-map viewBox="80 520 640 400" preserveAspectRatio="xMidYMid slice"
       xmlns="http://www.w3.org/2000/svg" aria-label="MOON BREWの店舗を巡るマップ">
    <rect x="0" y="0" width="1600" height="1000" fill="#EAF3E6"/>

    <path d="M180 380 L360 90 L540 380 Z" fill="#9FBF7E"/>
    <path d="M430 380 L610 130 L790 380 Z" fill="#B6CE96"/>
    <path d="M1180 300 L1340 70 L1500 300 Z" fill="#9FBF7E"/>

    <path d="M1120 0 C1020 220 1220 380 1080 600 S900 880 1010 1000" stroke="#9ED4FF" stroke-width="46" fill="none" stroke-linecap="round"/>

    <path d="M-20 780 Q400 710 820 760 T1640 720" stroke="#C9C9C9" stroke-width="32" fill="none"/>
    <path d="M-20 780 Q400 710 820 760 T1640 720" stroke="#F7F1E6" stroke-width="22" fill="none"/>
    <path d="M520 1020 Q560 740 720 560 T940 220" stroke="#C9C9C9" stroke-width="28" fill="none"/>
    <path d="M520 1020 Q560 740 720 560 T940 220" stroke="#F7F1E6" stroke-width="20" fill="none"/>

    <g>
      <rect x="300" y="680" width="260" height="150" rx="14" fill="#FBE9C4"/>
      <rect x="312" y="694" width="236" height="10" rx="5" fill="#E6CE97"/>
      <rect x="312" y="724" width="236" height="10" rx="5" fill="#E6CE97"/>
      <rect x="312" y="754" width="236" height="10" rx="5" fill="#E6CE97"/>
      <rect x="312" y="784" width="236" height="10" rx="5" fill="#E6CE97"/>
    </g>

    <g>
      <rect x="640" y="840" width="70" height="54" fill="#E7C8A0"/><path d="M632 840 L675 808 L718 840 Z" fill="#8B5A2B"/>
      <rect x="760" y="430" width="64" height="50" fill="#D9F2FF"/><path d="M752 430 L792 400 L832 430 Z" fill="#6F4E37"/>
      <rect x="980" y="520" width="64" height="50" fill="#FBE9C4"/><path d="M972 520 L1012 490 L1052 520 Z" fill="#C97B4A"/>
      <rect x="200" y="560" width="60" height="48" fill="#E7C8A0"/><path d="M192 560 L230 532 L268 560 Z" fill="#6F4E37"/>
      <rect x="1300" y="560" width="66" height="52" fill="#FBE9C4"/><path d="M1292 560 L1333 528 L1374 560 Z" fill="#8B5A2B"/>
    </g>

    <g fill="#4F7A3A">
      <g><rect x="146" y="690" width="8" height="20" fill="#6F4E37"/><circle cx="150" cy="680" r="18"/></g>
      <g><rect x="246" y="700" width="8" height="20" fill="#6F4E37"/><circle cx="250" cy="690" r="16"/></g>
      <g><rect x="566" y="640" width="8" height="20" fill="#6F4E37"/><circle cx="570" cy="630" r="18"/></g>
      <g><rect x="876" y="640" width="8" height="20" fill="#6F4E37"/><circle cx="880" cy="630" r="16"/></g>
      <g><rect x="1066" y="470" width="8" height="20" fill="#6F4E37"/><circle cx="1070" cy="460" r="18"/></g>
      <g><rect x="1156" y="540" width="8" height="20" fill="#6F4E37"/><circle cx="1160" cy="530" r="16"/></g>
      <g><rect x="1426" y="360" width="8" height="20" fill="#6F4E37"/><circle cx="1430" cy="350" r="18"/></g>
      <g><rect x="420" y="900" width="8" height="20" fill="#6F4E37"/><circle cx="424" cy="890" r="16"/></g>
      <g><rect x="900" y="900" width="8" height="20" fill="#6F4E37"/><circle cx="904" cy="890" r="18"/></g>
      <g><rect x="1250" y="760" width="8" height="20" fill="#6F4E37"/><circle cx="1254" cy="750" r="16"/></g>
    </g>

    <!-- 店舗マーカー(コーヒーカップ) -->
    <g transform="translate(430,720)"><path d="M-13 -8 H13 L10 12 H-10 Z" fill="#C97B4A"/><rect x="-15" y="-13" width="30" height="6" rx="3" fill="#6F4E37"/><path d="M13 -4 q14 5 0 16" stroke="#6F4E37" stroke-width="5" fill="none"/></g>
    <g transform="translate(940,500)"><path d="M-13 -8 H13 L10 12 H-10 Z" fill="#C97B4A"/><rect x="-15" y="-13" width="30" height="6" rx="3" fill="#6F4E37"/><path d="M13 -4 q14 5 0 16" stroke="#6F4E37" stroke-width="5" fill="none"/></g>
    <g transform="translate(1320,300)"><path d="M-13 -8 H13 L10 12 H-10 Z" fill="#C97B4A"/><rect x="-15" y="-13" width="30" height="6" rx="3" fill="#6F4E37"/><path d="M13 -4 q14 5 0 16" stroke="#6F4E37" stroke-width="5" fill="none"/></g>

    <g transform="translate(430,694)"><g class="label" data-spot="0">
      <rect x="-92" y="-36" width="184" height="34" rx="8" fill="#fff"/>
      <path d="M-9 -2 L9 -2 L0 9 Z" fill="#fff"/>
      <text x="0" y="-13" text-anchor="middle" font-size="18" fill="#3a2417">中央ロースタリー</text>
    </g></g>
    <g transform="translate(940,432)"><g class="label" data-spot="1">
      <rect x="-86" y="-36" width="172" height="34" rx="8" fill="#fff"/>
      <path d="M-9 -2 L9 -2 L0 9 Z" fill="#fff"/>
      <text x="0" y="-13" text-anchor="middle" font-size="18" fill="#3a2417">みなと焙煎所</text>
    </g></g>
    <g transform="translate(1320,196)"><g class="label" data-spot="2">
      <rect x="-80" y="-36" width="160" height="34" rx="8" fill="#fff"/>
      <path d="M-9 -2 L9 -2 L0 9 Z" fill="#fff"/>
      <text x="0" y="-13" text-anchor="middle" font-size="18" fill="#3a2417">丘の上カフェ</text>
    </g></g>
  </svg>
</div>
CSS
:root{ --ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1); }
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #EAF3E6; }

.stage { width: 100%; height: 100vh; min-height: 240px; max-height: 100%; overflow: hidden; background: #EAF3E6; }
.map { width: 100%; height: 100%; display: block; }

.label {
  opacity: 0; transform: scale(0);
  transform-box: fill-box; transform-origin: center bottom;
  filter: drop-shadow(0 3px 6px rgba(0,0,0,.25));
  transition: opacity .2s ease, transform .2s ease;
}
.label.on { opacity: 1; transform: scale(1); transition: transform .35s var(--ease-spring), opacity .25s ease; }
.label text { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-weight: 700; }
JavaScript
// viewBox を rAF で lerp 補間してカメラパン(デモと同じロジック)。
(() => {
  const svg = document.querySelector('[data-map]');
  if (!svg) return;
  const labels = [...svg.querySelectorAll('.label')];

  const spots = [{ x: 80, y: 520 }, { x: 620, y: 300 }, { x: 1000, y: 90 }];
  const W = 640, H = 400;
  const cur = { x: spots[0].x, y: spots[0].y };
  let i = 0, state = 'dwell', mark = 0;

  const setVB = () => svg.setAttribute('viewBox', `${cur.x.toFixed(1)} ${cur.y.toFixed(1)} ${W} ${H}`);
  const showLabel = (idx) => { labels.forEach(l => l.classList.remove('on')); const l = labels[idx]; if (l) l.classList.add('on'); };

  setVB();
  showLabel(0);

  const loop = (now) => {
    if (!mark) mark = now;
    if (state === 'dwell') {
      if (now - mark > 2000) { state = 'move'; i = (i + 1) % spots.length; labels.forEach(l => l.classList.remove('on')); }
    } else {
      const t = spots[i];
      cur.x += (t.x - cur.x) * 0.04;
      cur.y += (t.y - cur.y) * 0.04;
      if (Math.hypot(t.x - cur.x, t.y - cur.y) < 2) { cur.x = t.x; cur.y = t.y; state = 'dwell'; mark = now; showLabel(i); }
      setVB();
    }
    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

実装ガイド

使いどころ

観光・地域・店舗紹介のナビで、1枚のイラストマップ内をカメラが巡って名所を案内したいときに。

実装時の注意点

CSS transformでのパンではなく、SVGのviewBox 4値をrAFで目標へlerp(cur += (target-cur)*0.04)して動かします。ズームも同じ式で書けます。到着判定は目標との距離<2。ラベルは入れ子gで「外gが座標・内gがCSS scale」と分離し、SVGのtransform属性とCSS transformの衝突を避けます。

対応ブラウザ

SVG・setAttribute・requestAnimationFrameは全モダンブラウザで安定動作します。文字はSVG <text> でfont-family:system-uiにし外部フォント依存を避けます。対応は実機で確認してください。

よくある失敗

viewBoxのlerpをrAF外のタイマーでやるとガタつきます。ラベルの座標をtransform属性、scaleをCSSで同時指定するとCSS側が属性を上書きして位置が飛ぶため、入れ子で分離します。精密に描き込まずグリッドに置くと地図らしくなります。

応用例

スポットを店舗や施設に、到着でカードや写真を表示、クリックで該当ページへ、ズームインを加えるなどに発展できます。

コード

HTML
<!-- イラストマップ・パン巡回:viewBoxをlerpで動かしてカメラが名所を巡る -->
<div class="stage">
  <svg class="map" data-map viewBox="80 520 640 400" preserveAspectRatio="xMidYMid slice"
       xmlns="http://www.w3.org/2000/svg" aria-label="イラストマップを巡回するカメラ">
    <rect x="0" y="0" width="1600" height="1000" fill="#E2FFE9"/>

    <!-- 山(三角2枚重ね) -->
    <path d="M180 380 L360 90 L540 380 Z" fill="#7BC86C"/>
    <path d="M430 380 L610 130 L790 380 Z" fill="#4ED1A1"/>
    <path d="M1180 300 L1340 70 L1500 300 Z" fill="#7BC86C"/>

    <!-- 川(青帯) -->
    <path d="M1120 0 C1020 220 1220 380 1080 600 S900 880 1010 1000" stroke="#9ED4FF" stroke-width="46" fill="none" stroke-linecap="round"/>

    <!-- 道(縁取り+路面) -->
    <path d="M-20 780 Q400 710 820 760 T1640 720" stroke="#C9C9C9" stroke-width="32" fill="none"/>
    <path d="M-20 780 Q400 710 820 760 T1640 720" stroke="#F7F5F0" stroke-width="22" fill="none"/>
    <path d="M520 1020 Q560 740 720 560 T940 220" stroke="#C9C9C9" stroke-width="28" fill="none"/>
    <path d="M520 1020 Q560 740 720 560 T940 220" stroke="#F7F5F0" stroke-width="20" fill="none"/>

    <!-- 田(角丸+ストライプ) -->
    <g>
      <rect x="300" y="680" width="260" height="150" rx="14" fill="#FFF3C9"/>
      <rect x="312" y="694" width="236" height="10" rx="5" fill="#EAD89A"/>
      <rect x="312" y="724" width="236" height="10" rx="5" fill="#EAD89A"/>
      <rect x="312" y="754" width="236" height="10" rx="5" fill="#EAD89A"/>
      <rect x="312" y="784" width="236" height="10" rx="5" fill="#EAD89A"/>
    </g>
    <rect x="1180" y="640" width="200" height="120" rx="12" fill="#E2FFE9" stroke="#Bfe8cd" stroke-width="4"/>

    <!-- 家5軒(矩形+三角屋根) -->
    <g>
      <rect x="640" y="840" width="70" height="54" fill="#FFD9E0"/><path d="M632 840 L675 808 L718 840 Z" fill="#C0453F"/>
      <rect x="760" y="430" width="64" height="50" fill="#D9F2FF"/><path d="M752 430 L792 400 L832 430 Z" fill="#4D7CFE"/>
      <rect x="980" y="520" width="64" height="50" fill="#FFF3C9"/><path d="M972 520 L1012 490 L1052 520 Z" fill="#E8A33D"/>
      <rect x="200" y="560" width="60" height="48" fill="#E2FFE9"/><path d="M192 560 L230 532 L268 560 Z" fill="#2E9E78"/>
      <rect x="1300" y="560" width="66" height="52" fill="#FFD9E0"/><path d="M1292 560 L1333 528 L1374 560 Z" fill="#9B6BFF"/>
    </g>

    <!-- 木10本(円+幹) -->
    <g fill="#3FA86A">
      <g><rect x="146" y="690" width="8" height="20" fill="#8B5A2B"/><circle cx="150" cy="680" r="18"/></g>
      <g><rect x="246" y="700" width="8" height="20" fill="#8B5A2B"/><circle cx="250" cy="690" r="16"/></g>
      <g><rect x="566" y="640" width="8" height="20" fill="#8B5A2B"/><circle cx="570" cy="630" r="18"/></g>
      <g><rect x="876" y="640" width="8" height="20" fill="#8B5A2B"/><circle cx="880" cy="630" r="16"/></g>
      <g><rect x="1066" y="470" width="8" height="20" fill="#8B5A2B"/><circle cx="1070" cy="460" r="18"/></g>
      <g><rect x="1156" y="540" width="8" height="20" fill="#8B5A2B"/><circle cx="1160" cy="530" r="16"/></g>
      <g><rect x="1426" y="360" width="8" height="20" fill="#8B5A2B"/><circle cx="1430" cy="350" r="18"/></g>
      <g><rect x="420" y="900" width="8" height="20" fill="#8B5A2B"/><circle cx="424" cy="890" r="16"/></g>
      <g><rect x="900" y="900" width="8" height="20" fill="#8B5A2B"/><circle cx="904" cy="890" r="18"/></g>
      <g><rect x="1250" y="760" width="8" height="20" fill="#8B5A2B"/><circle cx="1254" cy="750" r="16"/></g>
    </g>

    <!-- 名所A:港農園 -->
    <circle cx="430" cy="720" r="10" fill="#FF5C5C"/>
    <!-- 名所B:中央プラザ -->
    <circle cx="940" cy="500" r="60" fill="#FFD9E0"/><circle cx="940" cy="500" r="28" fill="#FF9EB3"/>
    <!-- 名所C:展望台(丘の上の塔) -->
    <rect x="1300" y="240" width="40" height="70" fill="#F7F5F0" stroke="#9B6BFF" stroke-width="4"/>
    <path d="M1296 240 L1320 214 L1344 240 Z" fill="#9B6BFF"/>
    <path d="M1320 214 L1320 196 L1346 204 L1320 210" fill="#FFC83D"/>

    <!-- スポットラベル(入れ子g:外gで位置、内gでCSS scale) -->
    <g transform="translate(430,694)"><g class="label" data-spot="0">
      <rect x="-78" y="-36" width="156" height="34" rx="8" fill="#fff"/>
      <path d="M-9 -2 L9 -2 L0 9 Z" fill="#fff"/>
      <text x="0" y="-13" text-anchor="middle" font-size="18" fill="#1A1A1A">みなと農園</text>
    </g></g>
    <g transform="translate(940,432)"><g class="label" data-spot="1">
      <rect x="-82" y="-36" width="164" height="34" rx="8" fill="#fff"/>
      <path d="M-9 -2 L9 -2 L0 9 Z" fill="#fff"/>
      <text x="0" y="-13" text-anchor="middle" font-size="18" fill="#1A1A1A">中央プラザ</text>
    </g></g>
    <g transform="translate(1320,196)"><g class="label" data-spot="2">
      <rect x="-94" y="-36" width="188" height="34" rx="8" fill="#fff"/>
      <path d="M-9 -2 L9 -2 L0 9 Z" fill="#fff"/>
      <text x="0" y="-13" text-anchor="middle" font-size="18" fill="#1A1A1A">ほしぞら展望台</text>
    </g></g>
  </svg>
</div>
CSS
:root{
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-spring:   cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-inout:    cubic-bezier(0.65, 0, 0.35, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #E2FFE9; }

.stage {
  width: 100%;
  height: 100vh;
  min-height: 240px;
  max-height: 100%;
  overflow: hidden;
  background: #E2FFE9;
}
.map { width: 100%; height: 100%; display: block; }

/* ラベル:到着で scale 0→1(下中央基準でポップ)、出発で素早く消す */
.label {
  opacity: 0;
  transform: scale(0);
  transform-box: fill-box;
  transform-origin: center bottom;
  filter: drop-shadow(0 3px 6px rgba(0,0,0,.25));
  transition: opacity .2s ease, transform .2s ease;
}
.label.on {
  opacity: 1;
  transform: scale(1);
  transition: transform .35s var(--ease-spring), opacity .25s ease;
}
.label text { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-weight: 700; }
JavaScript
// viewBox の x,y を rAF で目標へ lerp 補間(cur += (target-cur)*0.04)。CSS transform は使わない。
(() => {
  const svg = document.querySelector('[data-map]');
  if (!svg) return; // null安全
  const labels = [...svg.querySelectorAll('.label')];

  const spots = [{ x: 80, y: 520 }, { x: 620, y: 300 }, { x: 1000, y: 90 }]; // viewBox左上
  const W = 640, H = 400;
  const cur = { x: spots[0].x, y: spots[0].y };
  let i = 0, state = 'dwell', mark = 0;

  const setVB = () => svg.setAttribute('viewBox', `${cur.x.toFixed(1)} ${cur.y.toFixed(1)} ${W} ${H}`);
  const showLabel = (idx) => { labels.forEach(l => l.classList.remove('on')); const l = labels[idx]; if (l) l.classList.add('on'); };

  setVB();
  showLabel(0);

  const loop = (now) => {
    if (!mark) mark = now;
    if (state === 'dwell') {
      if (now - mark > 2000) { state = 'move'; i = (i + 1) % spots.length; labels.forEach(l => l.classList.remove('on')); }
    } else { // move
      const t = spots[i];
      cur.x += (t.x - cur.x) * 0.04;
      cur.y += (t.y - cur.y) * 0.04;
      if (Math.hypot(t.x - cur.x, t.y - cur.y) < 2) {
        cur.x = t.x; cur.y = t.y; state = 'dwell'; mark = now; showLabel(i);
      }
      setVB();
    }
    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

🤖 AIエージェント用プロンプト

このままコピーしてAIに貼り付け「追加する場所」だけ書き換えればOK
あなたは熟練のフロントエンドエンジニアです。私のWebサイトに「イラストマップ・パン巡回」の効果を追加してください。

# 追加してほしい効果
イラストマップ・パン巡回(SVG エフェクト)
1枚の大きなイラストマップの中を、カメラが滑らかにパンして名所を順番に巡ります。到着するとスポット名がポンと現れる。観光・地域サイトのナビゲーションを、viewBox操作だけで実現する手法です。

# 追加する場所
👉【ここに対象箇所を記入:例「トップのヒーローセクション」「お問い合わせボタン」「記事カードの一覧」など】

# 参考実装(この見た目・挙動を再現してください)
【HTML】
<!-- イラストマップ・パン巡回:viewBoxをlerpで動かしてカメラが名所を巡る -->
<div class="stage">
  <svg class="map" data-map viewBox="80 520 640 400" preserveAspectRatio="xMidYMid slice"
       xmlns="http://www.w3.org/2000/svg" aria-label="イラストマップを巡回するカメラ">
    <rect x="0" y="0" width="1600" height="1000" fill="#E2FFE9"/>

    <!-- 山(三角2枚重ね) -->
    <path d="M180 380 L360 90 L540 380 Z" fill="#7BC86C"/>
    <path d="M430 380 L610 130 L790 380 Z" fill="#4ED1A1"/>
    <path d="M1180 300 L1340 70 L1500 300 Z" fill="#7BC86C"/>

    <!-- 川(青帯) -->
    <path d="M1120 0 C1020 220 1220 380 1080 600 S900 880 1010 1000" stroke="#9ED4FF" stroke-width="46" fill="none" stroke-linecap="round"/>

    <!-- 道(縁取り+路面) -->
    <path d="M-20 780 Q400 710 820 760 T1640 720" stroke="#C9C9C9" stroke-width="32" fill="none"/>
    <path d="M-20 780 Q400 710 820 760 T1640 720" stroke="#F7F5F0" stroke-width="22" fill="none"/>
    <path d="M520 1020 Q560 740 720 560 T940 220" stroke="#C9C9C9" stroke-width="28" fill="none"/>
    <path d="M520 1020 Q560 740 720 560 T940 220" stroke="#F7F5F0" stroke-width="20" fill="none"/>

    <!-- 田(角丸+ストライプ) -->
    <g>
      <rect x="300" y="680" width="260" height="150" rx="14" fill="#FFF3C9"/>
      <rect x="312" y="694" width="236" height="10" rx="5" fill="#EAD89A"/>
      <rect x="312" y="724" width="236" height="10" rx="5" fill="#EAD89A"/>
      <rect x="312" y="754" width="236" height="10" rx="5" fill="#EAD89A"/>
      <rect x="312" y="784" width="236" height="10" rx="5" fill="#EAD89A"/>
    </g>
    <rect x="1180" y="640" width="200" height="120" rx="12" fill="#E2FFE9" stroke="#Bfe8cd" stroke-width="4"/>

    <!-- 家5軒(矩形+三角屋根) -->
    <g>
      <rect x="640" y="840" width="70" height="54" fill="#FFD9E0"/><path d="M632 840 L675 808 L718 840 Z" fill="#C0453F"/>
      <rect x="760" y="430" width="64" height="50" fill="#D9F2FF"/><path d="M752 430 L792 400 L832 430 Z" fill="#4D7CFE"/>
      <rect x="980" y="520" width="64" height="50" fill="#FFF3C9"/><path d="M972 520 L1012 490 L1052 520 Z" fill="#E8A33D"/>
      <rect x="200" y="560" width="60" height="48" fill="#E2FFE9"/><path d="M192 560 L230 532 L268 560 Z" fill="#2E9E78"/>
      <rect x="1300" y="560" width="66" height="52" fill="#FFD9E0"/><path d="M1292 560 L1333 528 L1374 560 Z" fill="#9B6BFF"/>
    </g>

    <!-- 木10本(円+幹) -->
    <g fill="#3FA86A">
      <g><rect x="146" y="690" width="8" height="20" fill="#8B5A2B"/><circle cx="150" cy="680" r="18"/></g>
      <g><rect x="246" y="700" width="8" height="20" fill="#8B5A2B"/><circle cx="250" cy="690" r="16"/></g>
      <g><rect x="566" y="640" width="8" height="20" fill="#8B5A2B"/><circle cx="570" cy="630" r="18"/></g>
      <g><rect x="876" y="640" width="8" height="20" fill="#8B5A2B"/><circle cx="880" cy="630" r="16"/></g>
      <g><rect x="1066" y="470" width="8" height="20" fill="#8B5A2B"/><circle cx="1070" cy="460" r="18"/></g>
      <g><rect x="1156" y="540" width="8" height="20" fill="#8B5A2B"/><circle cx="1160" cy="530" r="16"/></g>
      <g><rect x="1426" y="360" width="8" height="20" fill="#8B5A2B"/><circle cx="1430" cy="350" r="18"/></g>
      <g><rect x="420" y="900" width="8" height="20" fill="#8B5A2B"/><circle cx="424" cy="890" r="16"/></g>
      <g><rect x="900" y="900" width="8" height="20" fill="#8B5A2B"/><circle cx="904" cy="890" r="18"/></g>
      <g><rect x="1250" y="760" width="8" height="20" fill="#8B5A2B"/><circle cx="1254" cy="750" r="16"/></g>
    </g>

    <!-- 名所A:港農園 -->
    <circle cx="430" cy="720" r="10" fill="#FF5C5C"/>
    <!-- 名所B:中央プラザ -->
    <circle cx="940" cy="500" r="60" fill="#FFD9E0"/><circle cx="940" cy="500" r="28" fill="#FF9EB3"/>
    <!-- 名所C:展望台(丘の上の塔) -->
    <rect x="1300" y="240" width="40" height="70" fill="#F7F5F0" stroke="#9B6BFF" stroke-width="4"/>
    <path d="M1296 240 L1320 214 L1344 240 Z" fill="#9B6BFF"/>
    <path d="M1320 214 L1320 196 L1346 204 L1320 210" fill="#FFC83D"/>

    <!-- スポットラベル(入れ子g:外gで位置、内gでCSS scale) -->
    <g transform="translate(430,694)"><g class="label" data-spot="0">
      <rect x="-78" y="-36" width="156" height="34" rx="8" fill="#fff"/>
      <path d="M-9 -2 L9 -2 L0 9 Z" fill="#fff"/>
      <text x="0" y="-13" text-anchor="middle" font-size="18" fill="#1A1A1A">みなと農園</text>
    </g></g>
    <g transform="translate(940,432)"><g class="label" data-spot="1">
      <rect x="-82" y="-36" width="164" height="34" rx="8" fill="#fff"/>
      <path d="M-9 -2 L9 -2 L0 9 Z" fill="#fff"/>
      <text x="0" y="-13" text-anchor="middle" font-size="18" fill="#1A1A1A">中央プラザ</text>
    </g></g>
    <g transform="translate(1320,196)"><g class="label" data-spot="2">
      <rect x="-94" y="-36" width="188" height="34" rx="8" fill="#fff"/>
      <path d="M-9 -2 L9 -2 L0 9 Z" fill="#fff"/>
      <text x="0" y="-13" text-anchor="middle" font-size="18" fill="#1A1A1A">ほしぞら展望台</text>
    </g></g>
  </svg>
</div>

【CSS】
:root{
  --ease-out-expo: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-spring:   cubic-bezier(0.34, 1.56, 0.64, 1);
  --ease-inout:    cubic-bezier(0.65, 0, 0.35, 1);
}
* { box-sizing: border-box; }
html, body { margin: 0; width: 100%; min-height: 100%; background: #E2FFE9; }

.stage {
  width: 100%;
  height: 100vh;
  min-height: 240px;
  max-height: 100%;
  overflow: hidden;
  background: #E2FFE9;
}
.map { width: 100%; height: 100%; display: block; }

/* ラベル:到着で scale 0→1(下中央基準でポップ)、出発で素早く消す */
.label {
  opacity: 0;
  transform: scale(0);
  transform-box: fill-box;
  transform-origin: center bottom;
  filter: drop-shadow(0 3px 6px rgba(0,0,0,.25));
  transition: opacity .2s ease, transform .2s ease;
}
.label.on {
  opacity: 1;
  transform: scale(1);
  transition: transform .35s var(--ease-spring), opacity .25s ease;
}
.label text { font-family: system-ui, -apple-system, "Segoe UI", sans-serif; font-weight: 700; }

【JavaScript】
// viewBox の x,y を rAF で目標へ lerp 補間(cur += (target-cur)*0.04)。CSS transform は使わない。
(() => {
  const svg = document.querySelector('[data-map]');
  if (!svg) return; // null安全
  const labels = [...svg.querySelectorAll('.label')];

  const spots = [{ x: 80, y: 520 }, { x: 620, y: 300 }, { x: 1000, y: 90 }]; // viewBox左上
  const W = 640, H = 400;
  const cur = { x: spots[0].x, y: spots[0].y };
  let i = 0, state = 'dwell', mark = 0;

  const setVB = () => svg.setAttribute('viewBox', `${cur.x.toFixed(1)} ${cur.y.toFixed(1)} ${W} ${H}`);
  const showLabel = (idx) => { labels.forEach(l => l.classList.remove('on')); const l = labels[idx]; if (l) l.classList.add('on'); };

  setVB();
  showLabel(0);

  const loop = (now) => {
    if (!mark) mark = now;
    if (state === 'dwell') {
      if (now - mark > 2000) { state = 'move'; i = (i + 1) % spots.length; labels.forEach(l => l.classList.remove('on')); }
    } else { // move
      const t = spots[i];
      cur.x += (t.x - cur.x) * 0.04;
      cur.y += (t.y - cur.y) * 0.04;
      if (Math.hypot(t.x - cur.x, t.y - cur.y) < 2) {
        cur.x = t.x; cur.y = t.y; state = 'dwell'; mark = now; showLabel(i);
      }
      setVB();
    }
    requestAnimationFrame(loop);
  };
  requestAnimationFrame(loop);
})();

# 外部ライブラリ
なし(追加ライブラリ不要)

# 守ってほしいこと
- 既存のHTML構造・レイアウト・デザインを壊さないこと。必要に応じてクラス名・色・サイズを私のサイトに合わせて調整してよい。
- クラス名やidが既存と衝突しないよう、必要なら接頭辞で名前空間を分けること。
- レスポンシブ対応と prefers-reduced-motion への配慮を入れること。
- 私のサイトのフレームワーク(React / Vue / 素のHTML など)に合わせて実装すること。不明な場合は素のHTML/CSS/JSで提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。