デモ
デモを読み込めませんでした。
仕様
- 用途
- セクション
- 動作
- DOM+WebGL
- 種類
- シーン
- 動作確認
- three 0.186.0・gsap 3.15.0
- 公開日
しくみと調整
点で世界地図を描いた地球儀に、6都市の拠点のピンが立っています。ドラッグすると、つかんだ所ごと地球儀が回り、放しても勢いを残してしばらく回ってから止まります。ピンにカーソルを合わせると、その上に拠点名のラベルが表示されます。
地図は、球の上に均等に並べた1万6000個の点を、陸と海に分けて描いたものです。陸か海かの判定には、緯度と経度の2°ごとのマス目を白黒で塗った陸地のビットマップを使っています。これを文字列にしてコードに埋め込み、点ごとの緯度と経度で引きます。陸の点は大きく濃く、海の点は小さく薄くしたので、海の部分にも球の丸みが残ります。
回転は、「正面に来ている経度」と「正面に来ている緯度」の2つの値だけで表しています。横にドラッグしたときは、動かした量を地球儀の半径(px)で割った角度だけ経度を進めます。これで、つかんだ所がポインタに付いてくるように見えます。放したあとの勢いは指数で減らしていて、減り方の係数3を大きくすればすぐに止まり、小さくすれば長く回り続けます。
ピンに当たったかどうかは Raycaster で調べます。当てる相手は、ピンの頭より少し大きい見えない球と、地球儀の球そのものの2つです。ピンだけに当てれば簡単ですが、それでは裏側に回ったピンにも当たり、見えていない拠点のラベルが表示されてしまいます。先に地球儀の球に当たったときは裏側とみなす、という決まりにしました。縁の向こうへ回りかけたピンは頭が球からはみ出して見えるので、薄くしてから消しています。
拠点の一覧は button で書いてあります。押すかキーボードでフォーカスすると、その拠点が近い向きへ1.1秒かけて回って正面に来て、ラベルが表示されます。地球儀の canvas はスクリーンリーダーから外し、拠点名は一覧で伝えています。ピンの位置は一覧のボタンに書いた緯度と経度だけで決まるので、拠点を足したいときは一覧に項目を1つ増やせば済みます。
コード
<section class="globe" aria-labelledby="globe-title">
<div class="globe__text">
<p class="globe__eyebrow">OFFICES</p>
<h2 class="globe__title" id="globe-title">世界6都市の拠点</h2>
<p class="globe__lead">ここに説明文が入ります。</p>
<!-- 拠点の位置は緯度・経度で書く。押すかフォーカスすると、その拠点が地球儀の正面へ回る -->
<ul class="globe__list">
<li><button class="globe__site" type="button" data-lat="35.68" data-lon="139.69" data-en="TOKYO">東京</button></li>
<li><button class="globe__site" type="button" data-lat="34.69" data-lon="135.5" data-en="OSAKA">大阪</button></li>
<li><button class="globe__site" type="button" data-lat="33.59" data-lon="130.4" data-en="FUKUOKA">福岡</button></li>
<li><button class="globe__site" type="button" data-lat="25.03" data-lon="121.57" data-en="TAIPEI">台北</button></li>
<li><button class="globe__site" type="button" data-lat="1.35" data-lon="103.82" data-en="SINGAPORE">シンガポール</button></li>
<li><button class="globe__site" type="button" data-lat="51.51" data-lon="-0.13" data-en="LONDON">ロンドン</button></li>
</ul>
</div>
<div class="globe__stage">
<!-- 地球儀は一覧と同じ内容の飾りなので、読み上げからは外す -->
<canvas class="globe__canvas" aria-hidden="true"></canvas>
<div class="globe__label" aria-hidden="true"><span class="globe__name"></span><span class="globe__en"></span></div>
</div>
</section>
.globe {
/* 地球儀の色はここで決める。script.js がこの 5 つを読んでシーンへ渡す */
--face: #171717; /* 球の面 */
--dot: #eeece6; /* 点と経緯線 */
--pin: #e2948e; /* ピンの頭(ローズカラー) */
--pin-hover: #f2bdb7; /* 重ねたときの頭 */
--stick: #eeece6; /* ピンの棒 */
--bg: #111111;
--surface: #262626;
--ink: #eeece6;
--mute: #a8a6a0;
--line: rgba(238, 236, 230, 0.14);
display: grid;
grid-template-columns: minmax(0, 400px) minmax(0, 1fr);
align-items: center;
gap: 48px;
padding: 64px clamp(24px, 7vw, 96px);
background: var(--bg);
color: var(--ink);
}
.globe__eyebrow { margin: 0; font-size: 13px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute); }
.globe__title { margin: 20px 0 0; font-size: clamp(32px, 5vw, 44px); font-weight: 800; line-height: 1.1; letter-spacing: -0.02em; word-break: keep-all; }
.globe__lead { margin: 28px 0 0; font-size: 18px; line-height: 1.8; letter-spacing: 0.04em; }
.globe__list { display: grid; grid-template-columns: repeat(2, minmax(0, 180px)); gap: 4px 0; margin: 40px 0 0; padding: 0; list-style: none; }
.globe__site {
display: flex;
align-items: center;
gap: 12px;
padding: 8px 0;
border: 0;
background: none;
color: inherit;
font: inherit;
font-size: 15px;
font-weight: 600;
line-height: 1;
letter-spacing: 0.04em;
white-space: nowrap;
cursor: pointer;
}
.globe__site::before { content: ""; flex: none; width: 8px; height: 8px; border-radius: 50%; background: var(--pin); }
.globe__site:focus-visible { outline: 2px solid var(--ink); outline-offset: 4px; border-radius: 2px; }
/* 地球儀は正方形の中に描く。ラベルは canvas の上に重ねる */
.globe__stage { position: relative; width: min(100%, 600px); aspect-ratio: 1; justify-self: center; }
.globe__canvas { display: block; width: 100%; height: 100%; touch-action: pan-y; cursor: grab; }
.globe__canvas.is-grabbing { cursor: grabbing; }
.globe__canvas.is-pin { cursor: pointer; }
.globe__label {
position: absolute;
left: 0;
top: 0;
display: grid;
gap: 6px;
padding: 10px 14px;
border-radius: 4px;
background: var(--surface);
box-shadow: inset 0 0 0 1px var(--line);
text-align: center;
white-space: nowrap;
visibility: hidden;
pointer-events: none;
}
.globe__name { font-size: 15px; font-weight: 700; line-height: 1; letter-spacing: 0.04em; }
.globe__en { font-size: 11px; font-weight: 700; line-height: 1; letter-spacing: 0.14em; color: var(--mute); }
@media (max-width: 860px) {
.globe { grid-template-columns: minmax(0, 1fr); }
}
import { gsap } from "gsap";
import * as THREE from "three";
const RADIUS = 0.45; // 地球儀の半径(canvas の幅に対する比)
const TILT = 20, START = 10; // 正面に来る緯度と、最初に正面に来ている経度(°)。少し上から見て、北半球の拠点を見やすくする
const DOTS = 16000, GRID = 30, HIT = 0.05; // 点の数・経緯線の間隔(°)・ピンの当たりの球の半径(地球儀の半径 = 1)
const LAND = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAB/+Af/gAAAAAAAAAAAAAAAAAAAAAAA///////AAHgBAAADgAAAAAAAAAAAAd//////4AB/gAAAAB+AAAAAAAAAAB/G/4////4AAbAAAcAAfwAAAAAAAAADfm/wA///wAAAAADwAf/4AH4AAAAAAH/3/8AP//wAAAAAODD///+DwAAgH8AH/73/wP//gAAA4AMH////////+fv/09f/3/////////8//n///////w+P4AAABAC/D8A////gBNAAgAAAAAAAA3n///////////////////////////+Af/////708D4AIAH//////////////Af/////gHwB4AAAP7////////////4AH4P///gH+AAAAEP4//////////+cAADwB///8H+AAAAGD7/////////8A8AAMAA////v/gAAAPDv/////////4B4AAAABf///v/wAAAbv///////////AwAAAAAP/////wAAAH////////////AgAAAAAH////+8AAAD///////////9AAAAAAAD////+8AAAD///////////9AAAAAAAD/////AAAAB//7/f//////5AAAAAAAH////gAAAAf9vwPP//////zgAAAAAAD////gAAAAfi///v/////+CAAAAAAAD///+AAAAAfCLf/n/////MCAAAAAAAB///8AAAAAf/zf///////mOAAAAAAAB///8AAAAAH/Ai///////G8AAAAAAAAf//4AAAAAf/zB///////hwAAAAAAAAf//wAAAAAf//////////hAAAAAAAAAP/owAAAAA/////f/////gAAAAAAAAAH/AwAAAAB///+/v/////gAAAAAAAAAD/AQAAAAD/////3/////gAAAAAAAAABeA4AAAAD////f/D///+gAAAAAAAAAAfGOAAAAH//////B/z/wAAAAAAAgAAAf+FoAAAD////v+A/h+wgAAAAAAAAAAH8AAAAAH////38A/B/AgAAAAAAAAAAAfgAAAAH/////wAcAfggAAAAAAAAAAAHgAAAAH/////AAcAfgQAAAAAAAAAAABD7AAAD/////wAMAXgYAAAAAAAAAAAA//AAAB/////wAOASAYAAAAAAAAAAAAX/gAAA/////gACAYAcAAAAAAAAAAAAH/8AAAfP///AAAAsHAAAAAAAAAAAAAH/+AAAAB///AAAA8OAAAAAAAAAAAAAP/+AAAAD//8AAAAc+mAAAAAAAAAAAAf//wAAAD//4AAAAM/hgAAAAAAAAAAAf//+AAAB//wAAAAOfi/AAAAAAAAAAAf///AAAA//wAAAAGBwfsAAAAAAAAAAP///gAAA//wAAAAB4AHwAAAAAAAAAAP///gAAA//wAAAAAMoDYIAAAAAAAAAH///AAAAf/4AAAAAAABAAAAAAAAAAAH//+AAAA//4gAAAAAB7AAAAAAAAAAAD//+AAAA//5wAAAAAPzgAA////////+/////////v////////gAAAAAAAAAAAf/8AAAA//DgAAAAA//wAAAAAAAAAAAf/8AAAA//DgAAAAD//4CAAAAAAAAAAf/4AAAAf/HAAAAAH//8AAAAAAAAAAA//gAAAAf/DAAAAAH//+AAAAAAAAAAA//AAAAAf+AAAAAAH//+AAAAAAAAAAA//AAAAAP8AAAAAAH//+AAAAAAAAAAA/+AAAAAH4AAAAAAH//+AAAAAAAAAAA/8AAAAAHwAAAAAAD4f8AAAAAAAAAAA/4AAAAAGAAAAAAADAP8AIAAAAAAAAB/wAAAAAAAAAAAAAAAD4AEAAAAAAAAB/gAAAAAAAAAAAAAAABwAGAAAAAAAAB+AAAAAAAAAAAAAAAAAwAMAAAAAAAAB8AAAAAAAAAAAAAAAAAwAYAAAAAAAAD8AAAAAAAAAAAAAAAAAABwAAAAAAAAD4AAAAAAAAAAAAAAAAAAAgAAAAAAAAD4AAAAAAAAAACAAAAAAAAAAAAAAAAADwgAAAAAAAAAAAAAAAAAAAAAAAAAAADwAAAAAAAAAAAAAAAAAAAAAAAAAAAAA4AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgAAAAAAAAAAAAAAAAAAAAAAAAAAAACAAAAAAAAAAAAAAAAAAAAAAAAAAAAAOAAAAAAAAB/IB/////gAAAAAAAAAAA+AAAAAAADv/+P//////wAAAAAAAAAD/AAAA//////8////////wAAAAFAP/9/AAAD///////////////gAAB//////+AAAP//////////////+AAB///////gAAP///////////////8AAN//////8AD4////////////////+AAAf//////9H3////////////////4AAAf//////////////////////////gnH///////////////////////////+AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // 陸地のビットマップ(緯度・経度 2° ごと、横 180 × 縦 90 のマスを北西の端から 1 ビットずつ。もとは Natural Earth の陸地)
const SPIN = 3, FADE = { in: 0.2, out: 0.15 }; // 放したあと勢いが減る速さ(1/秒)と、ラベルの出入りの秒数
const reduceMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
const rad = THREE.MathUtils.degToRad;
const onSphere = (lat, lon, r = 1) => new THREE.Vector3(Math.cos(rad(lat)) * Math.sin(rad(lon)) * r, Math.sin(rad(lat)) * r, Math.cos(rad(lat)) * Math.cos(rad(lon)) * r);
document.querySelectorAll(".globe").forEach((section) => {
const css = (name) => getComputedStyle(section).getPropertyValue(name).trim();
const canvas = section.querySelector(".globe__canvas"), label = section.querySelector(".globe__label"), sites = [...section.querySelectorAll(".globe__site")];
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, alpha: true }); renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); // 背景は CSS
const scene = new THREE.Scene(), camera = new THREE.PerspectiveCamera(30, 1, 0.1, 50), globe = new THREE.Group(), parts = [];
const keep = (...items) => (parts.push(...items), items[0]); scene.add(globe);
const sphere = new THREE.Mesh(keep(new THREE.SphereGeometry(0.995, 64, 32)), keep(new THREE.MeshBasicMaterial({ color: css("--face") }))); // 裏側の点とピンを隠す
const bits = Uint8Array.from(atob(LAND), (c) => c.charCodeAt(0)), isLand = (lat, lon) => { const i = Math.min(89, Math.floor((90 - lat) / 2)) * 180 + Math.min(179, Math.floor((lon + 180) / 2)); return (bits[i >> 3] >> (7 - (i & 7))) & 1; };
const land = [], sea = [], golden = Math.PI * (3 - Math.sqrt(5)), grid = [], deg = THREE.MathUtils.radToDeg;
for (let i = 0; i < DOTS; i++) { const y = 1 - (2 * (i + 0.5)) / DOTS, r = Math.sqrt(1 - y * y), p = new THREE.Vector3(Math.cos(golden * i) * r, y, Math.sin(golden * i) * r); (isLand(deg(Math.asin(y)), deg(Math.atan2(p.x, p.z))) ? land : sea).push(p); } // 球に均等に並べ(フィボナッチの螺旋)、陸と海に分ける
for (let lat = -90 + GRID; lat < 90; lat += GRID) for (let lon = 0; lon < 360; lon += 3) grid.push(onSphere(lat, lon, 1.001), onSphere(lat, lon + 3, 1.001));
for (let lon = 0; lon < 360; lon += GRID) for (let lat = -90; lat < 90; lat += 3) grid.push(onSphere(lat, lon, 1.001), onSphere(lat + 3, lon, 1.001));
const disc = Object.assign(document.createElement("canvas"), { width: 32, height: 32 }), g = disc.getContext("2d"); g.fillStyle = "#fff"; g.arc(16, 16, 14, 0, Math.PI * 2); g.fill(); // 点を丸く描く
const map = keep(new THREE.CanvasTexture(disc)), dotsOf = (points, size, opacity) => new THREE.Points(keep(new THREE.BufferGeometry().setFromPoints(points)), keep(new THREE.PointsMaterial({ color: css("--dot"), size, sizeAttenuation: false, map, transparent: true, opacity, depthWrite: false })));
globe.add(sphere, dotsOf(sea, 1.8, 0.12), dotsOf(land, 3, 0.82), new THREE.LineSegments(keep(new THREE.BufferGeometry().setFromPoints(grid)), keep(new THREE.LineBasicMaterial({ color: css("--dot"), transparent: true, opacity: 0.16 }))));
// ピン: 細い棒と丸い頭。頭より少し大きい見えない球を当たりにする
const stick = keep(new THREE.CylinderGeometry(0.005, 0.005, 0.09, 8).translate(0, 0.045, 0)), headShape = keep(new THREE.SphereGeometry(0.022, 20, 12)), hitShape = keep(new THREE.SphereGeometry(HIT, 8, 6));
const base = new THREE.Color(css("--pin")), hover = new THREE.Color(css("--pin-hover"));
const pins = sites.map((site, i) => {
const pin = new THREE.Group(), head = new THREE.Mesh(headShape, keep(new THREE.MeshBasicMaterial({ color: base, transparent: true })));
const bar = new THREE.Mesh(stick, keep(new THREE.MeshBasicMaterial({ color: css("--stick"), transparent: true }))), hit = new THREE.Mesh(hitShape, keep(new THREE.MeshBasicMaterial({ visible: false })));
pin.position.copy(onSphere(Number(site.dataset.lat), Number(site.dataset.lon)));
pin.quaternion.setFromUnitVectors(new THREE.Vector3(0, 1, 0), pin.position.clone().normalize());
head.position.y = hit.position.y = 0.1; hit.userData.site = i; pin.add(bar, head, hit); globe.add(pin);
return { pin, head, bar, hit, alpha: 1 };
});
const view = { L: rad(START), phi: rad(TILT), velocity: 0, shown: -1, alpha: 0 }, raycaster = new THREE.Raycaster(), ndc = new THREE.Vector2(), p = new THREE.Vector3();
let grab = null, hovered = -1, chosen = -1, turn = null, last = performance.now(), size = 1;
const pick = (e) => { // ポインタの先のピン。先に球に当たったら、裏側のピンとみなす
const r = canvas.getBoundingClientRect(); ndc.set(((e.clientX - r.left) / r.width) * 2 - 1, -((e.clientY - r.top) / r.height) * 2 + 1); raycaster.setFromCamera(ndc, camera);
const hit = raycaster.intersectObjects([sphere, ...pins.map((pin) => pin.hit)], false)[0];
return hit && hit.object !== sphere && pins[hit.object.userData.site].alpha > 0.5 ? hit.object.userData.site : -1;
};
const frame = (ms) => {
const dt = Math.min(ms - last, 100) / 1000; last = ms; if (!grab && !turn?.isActive()) { view.L += view.velocity * dt; view.velocity *= Math.exp(-SPIN * dt); }
globe.rotation.set(view.phi, -view.L, 0); globe.updateMatrixWorld(true);
const horizon = 1 / camera.position.z; // 球の点が見えるのは、カメラ向きの成分がこれより大きいとき。縁の向こうへ回ったピンは薄くして消す
// ラベル: 重ねているピン(無ければ一覧で選んだ拠点)へ。替わるときは、前のラベルを消しきってから出す
const want = grab ? -1 : hovered >= 0 ? hovered : chosen;
if (view.shown !== want && view.alpha > 0) view.alpha = reduceMotion ? 0 : Math.max(0, view.alpha - dt / FADE.out);
else { view.shown = want; view.alpha = want < 0 ? 0 : reduceMotion ? 1 : Math.min(1, view.alpha + dt / FADE.in); }
const a = 1 - (1 - view.alpha) ** 2;
pins.forEach((item, i) => {
item.alpha = THREE.MathUtils.smoothstep(item.pin.getWorldPosition(p).z, horizon - 0.08, horizon + 0.06);
item.pin.visible = item.alpha > 0; item.head.material.opacity = item.alpha; item.bar.material.opacity = 0.7 * item.alpha;
item.head.scale.setScalar(1 + 0.35 * (i === view.shown ? a : 0)); item.head.material.color.lerpColors(base, hover, i === view.shown ? a : 0);
});
label.style.visibility = view.shown >= 0 && a > 0 ? "inherit" : "hidden";
if (view.shown >= 0) {
const site = sites[view.shown]; pins[view.shown].head.getWorldPosition(p).project(camera);
if (label.firstElementChild.textContent !== site.textContent) { label.firstElementChild.textContent = site.textContent; label.lastElementChild.textContent = site.dataset.en; }
label.style.opacity = String(a);
label.style.transform = `translate(${Math.round(((p.x + 1) / 2) * size)}px, ${Math.round(((1 - p.y) / 2) * size - 16 + 6 * (1 - a))}px) translate(-50%, -100%)`;
}
renderer.render(scene, camera);
};
canvas.addEventListener("pointerdown", (e) => {
if (pick(e) < 0 && raycaster.intersectObject(sphere, false).length === 0) return; // 地球儀の外では回さない
turn?.kill(); chosen = -1; view.velocity = 0; grab = { x: e.clientX, y: e.clientY, time: e.timeStamp }; canvas.setPointerCapture(e.pointerId); canvas.classList.add("is-grabbing");
});
canvas.addEventListener("pointermove", (e) => {
if (!grab) { hovered = pick(e); if (hovered >= 0) chosen = -1; canvas.classList.toggle("is-pin", hovered >= 0); return; }
const radius = RADIUS * canvas.clientWidth, dL = -(e.clientX - grab.x) / radius; // 横の移動量 ÷ 半径(px)だけ回すと、つかんだ所がポインタに付いてくる
view.L += dL; view.phi = gsap.utils.clamp(rad(-10), rad(60), view.phi + (e.clientY - grab.y) / radius);
view.velocity = reduceMotion ? 0 : dL / Math.max((e.timeStamp - grab.time) / 1000, 0.008); // 放したときに残す勢い(ラジアン毎秒)
Object.assign(grab, { x: e.clientX, y: e.clientY, time: e.timeStamp });
});
const release = (e) => { if (grab && e.timeStamp - grab.time > 80) view.velocity = 0; grab = null; canvas.classList.remove("is-grabbing"); }; // 止めてから放したら、勢いは残さない
canvas.addEventListener("pointerup", release); canvas.addEventListener("pointercancel", release); canvas.addEventListener("pointerleave", () => { if (!grab) hovered = -1; });
// 一覧の拠点名を押すかフォーカスすると、その拠点が正面へ回ってラベルが表示される(近い向きへ回す)
const choose = (i) => {
const site = sites[i], target = rad(Number(site.dataset.lon)), turnBy = ((((target - view.L + Math.PI) % (Math.PI * 2)) + Math.PI * 2) % (Math.PI * 2)) - Math.PI;
turn?.kill(); chosen = i; view.velocity = 0;
turn = gsap.to(view, { L: view.L + turnBy, phi: rad(gsap.utils.clamp(-10, 45, Number(site.dataset.lat))), duration: reduceMotion ? 0 : 1.1, ease: "power3.inOut" });
};
sites.forEach((site, i) => { site.addEventListener("click", () => choose(i)); site.addEventListener("focus", () => choose(i)); });
const sizes = new ResizeObserver(() => { // 半径 1 の球が RADIUS × 幅 に見える距離へカメラを置く
size = canvas.clientWidth; renderer.setSize(size, size, false);
const tanA = (RADIUS * size * Math.tan(rad(15))) / (size / 2); camera.position.z = Math.sqrt(1 / (tanA * tanA) + 1); camera.updateProjectionMatrix();
});
const visible = new IntersectionObserver(([e]) => { last = performance.now(); renderer.setAnimationLoop(e.isIntersecting ? frame : null); }); // 画面の外では描かない
sizes.observe(canvas); visible.observe(canvas);
section.destroy = () => { // PJAX でセクションごと破棄するときに呼ぶ。放っておくと WebGL のコンテキストが積み上がる
renderer.setAnimationLoop(null); sizes.disconnect(); visible.disconnect(); turn?.kill();
parts.forEach((part) => part.dispose()); renderer.dispose(); renderer.forceContextLoss();
};
});