イラストマップ・パン巡回
1枚の大きなイラストマップの中を、カメラが滑らかにパンして名所を順番に巡ります。到着するとスポット名がポンと現れる。観光・地域サイトのナビゲーションを、viewBox操作だけで実現する手法です。
ライブデモ
使用例(お題: カフェ 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で提示し、組み込み手順も説明すること。
- 変更後の確認手順も簡潔に教えてください。