PageSourceSearch

https://lagrange-566.pages.dev/js/earthmap.js?b=1786340351

js lagrange-566.pages.dev collected 2026-10-03 05:21:48 UTC 29,558 bytes, 518 lines download raw bytes

1// ============================================================
2// LAGRANGE earthmap — 地球都市マップ(都市間トランジット)
3//   ・3D地球儀 ⇔ メルカトル図法 をシームレスにモーフ切替
4//     (球メッシュの各頂点に平面座標 aPlane を持たせ、シェーダーで mix)
5//   ・都市はSVGアイコンのマーカー。クリックで右パネル(PC=右1/3)に
6//     統計・名産・概要を表示 → 「移動する」で航路アニメ → 現地の都市ビューへ
7//   ・出発点は種子島(着陸地)。以後はどの都市からでも開ける
8//   検証口: window.__emap = { open, view, select, travel, render }
9// ============================================================
10import * as THREE from 'three';
11import { on, emit } from './core.js?b=1786340351';
12import { CITIES, TANEGASHIMA, gotoCity } from './earth_cities.js?b=1786340351';
13
14// 都市の地理・統計・アイコン種(CITIES と同順・末尾=種子島)
15const GEO = [
16  { ll: [35.68, 139.77], icon: 'tower', pop: '790万', mac: '118万機', ind: '行政・電脳・観光', mei: '提灯ホロサイン/寿司' },
17  { ll: [34.69, 135.50], icon: 'bridge', pop: '380万', mac: '52万機', ind: '商業・食品', mei: '道頓堀看板/たこ焼き' },
18  { ll: [35.18, 136.91], icon: 'kiln', pop: '210万', mac: '340万機', ind: '窯業・機械', mei: '常滑焼/八丁味噌' },
19  { ll: [31.23, 121.47], icon: 'pearl', pop: '1,150万', mac: '160万機', ind: '交易・金融', mei: '茶葉/外灘の夜景' },
20  { ll: [48.86, 2.35], icon: 'eiffel', pop: '460万', mac: '38万機', ind: '芸術・観光', mei: 'バゲット/似顔絵' },
21  { ll: [40.71, -74.01], icon: 'skyline', pop: '870万', mac: '85万機', ind: '金融・文化', mei: 'ホットドッグ/地下鉄' },
22  { ll: [34.05, -118.24], icon: 'palm', pop: '640万', mac: '64万機', ind: '映像・港湾', mei: 'タコス/夕陽' },
23  { ll: [-22.91, -43.17], icon: 'statue', pop: '580万', mac: '40万機', ind: '観光・漁業', mei: 'カーニバル/鮮魚' },
24  { ll: [28.61, 77.21], icon: 'gate', pop: '1,380万', mac: '120万機', ind: '花卉・交易', mei: 'マリーゴールド/チャイ' },
25  { ll: [1.35, 103.82], icon: 'tree', pop: '330万', mac: '45万機', ind: '中継貿易・園芸', mei: 'ラクサ/スーパーツリー' },
26  { ll: [55.76, 37.62], icon: 'dome', pop: '560万', mac: '52万機', ind: '学術・書庫', mei: '紙の本/ジャム紅茶' },
27  { ll: [24.71, 46.68], icon: 'palmtower', pop: '390万', mac: '33万機', ind: '天文・農園', mei: 'デーツ/夜の星見' },
28  { ll: [41.01, 28.98], icon: 'mosque', pop: '740万', mac: '58万機', ind: '海峡交易', mei: '香辛料/シミット' },
29  { ll: [41.90, 12.50], icon: 'colosseum', pop: '260万', mac: '21万機', ind: '遺跡・食', mei: 'カルボナーラ/丘のワイン' },
30  { ll: [30.37, 130.96], icon: 'rocket', pop: '3.8万', mac: '1.2万機', ind: '宇宙港・漁業', mei: '安納芋/発射台跡巡礼' },
31];
32// SVGラインアイコン(24x24・stroke描画)
33const ICONS = {
34  rocket: '<path d="M12 3c2.6 2 3.4 6 3.4 9l-3.4 4-3.4-4c0-3 .8-7 3.4-9z"/><path d="M8.6 12l-2.6 4h4M15.4 12l2.6 4h-4M12 16v4"/>',
35  tower: '<path d="M12 3l4.5 15h-9L12 3z"/><path d="M8.8 13h6.4M12 18v3"/>',
36  bridge: '<path d="M3 15c3-5 15-5 18 0"/><path d="M5 15v4M12 12v7M19 15v4"/>',
37  kiln: '<path d="M6 19c0-6 2.5-10 6-12 3.5 2 6 6 6 12z"/><path d="M9 19c0-4 1.2-7 3-8.6 1.8 1.6 3 4.6 3 8.6"/>',
38  pearl: '<path d="M12 2v5M12 12v9"/><circle cx="12" cy="9" r="2.4"/><circle cx="12" cy="15" r="1.5"/>',
39  eiffel: '<path d="M12 3c1 5 3.5 12 7 17M12 3c-1 5-3.5 12-7 17"/><path d="M8 14h8M9.5 9.5h5"/>',
40  skyline: '<path d="M5 20V9h4v11M11 20V4h4v16M17 20v-8h3v8"/>',
41  palm: '<path d="M12 9c0 4-.5 8-1.5 11"/><path d="M12 9C10 6 7 5.5 4.5 7c2.5.5 5 1 7.5 2zM12 9c2-3 5-3.5 7.5-2-2.5.5-5 1-7.5 2zM12 9c0-3 2-5 4-5.5-1.5 1.5-2.5 3.5-2.7 5"/>',
42  statue: '<path d="M12 6v9M6 8l6 3 6-3"/><circle cx="12" cy="4.4" r="1.6"/><path d="M9 20h6l-1-5h-4z"/>',
43  gate: '<path d="M5 20V9a7 7 0 0 1 14 0v11"/><path d="M9 20v-7a3 3 0 0 1 6 0v7M3.5 20h17"/>',
44  tree: '<path d="M12 21V9"/><path d="M12 9c-4 0-6-2.5-6-5 3 0 5.5 1 6 4 .5-3 3-4 6-4 0 2.5-2 5-6 5z"/><path d="M8.5 13c1 .8 2.3 1.3 3.5 1.3s2.5-.5 3.5-1.3"/>',
45  dome: '<path d="M12 3c1.8 2 3 4.5 3 7H9c0-2.5 1.2-5 3-7z"/><path d="M8 10h8v4H8zM7 20v-6M17 20v-6M7 20h10"/>',
46  palmtower: '<path d="M10 21c.6-4 .6-9 0-13"/><path d="M10 8C8.5 6 6.5 5.6 5 6.6c1.7.4 3.4.8 5 1.4zM10 8c1.5-2 3.5-2.4 5-1.4-1.7.4-3.4.8-5 1.4z"/><path d="M17 12a4.5 4.5 0 1 0 3 7.9 4 4 0 1 1-3-7.9z"/>',
47  mosque: '<path d="M12 4c2.5 2 4 4 4 6H8c0-2 1.5-4 4-6z"/><path d="M6 20V10M18 20v-10M4.5 20h15M6 10V7M18 10V7"/>',
48  colosseum: '<path d="M4 10c2.5-1.6 13.5-1.6 16 0v9H4z"/><path d="M7 19v-5M11 19v-5M15 19v-5M19 19v-5M4 14h16"/>',
49};
50const iconSvg = (k, sz, cls) => `<svg class="${cls || ''}" width="${sz}" height="${sz}
50" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">${ICONS[k] || ICONS.tower}</svg>`;
51
52const MK = Math.log(Math.tan(Math.PI / 4 + (78 * Math.PI / 180) / 2));   // メルカトルy最大(±78°クランプ=極の過伸長を抑える)
53const PW = 3.3;                                                           // 平面の幅
54const PH = PW * (MK / Math.PI);                                           // 平面の高さ(≈2.13・実用世界地図の横長比)
55
56let built = false, isOpen = false, view3d = true;   // view3d=false → メルカトル
57let curIdx = TANEGASHIMA, selIdx = -1, traveling = false;
58let renderer = null, scene = null, camera = null, mesh = null, mat = null, routeLine = null;
59let dragging = false, dragX = 0, dragY = 0, autoRot = true, zoomJp = false;
60const $id = id => document.getElementById(id);
61
62// ---------- 座標系(シェーダーと同一の式をJS側でも使う) ----------
63function sphPos(lat, lon, out) {
64  // three.SphereGeometry と同じ写像: u=(lon+180)/360, phi=u*2π
65  const phi = ((lon + 180) / 360) * Math.PI * 2;
66  const th = (90 - lat) * Math.PI / 180;
67  out.set(-Math.cos(phi) * Math.sin(th), Math.cos(th), Math.sin(phi) * Math.sin(th));
68  return out;
69}
70function plnPos(lat, lon, out) {
71  const la = Math.max(-78, Math.min(78, lat)) * Math.PI / 180;
72  out.set(((lon + 180) / 360 - 0.5) * PW, Math.log(Math.tan(Math.PI / 4 + la / 2)) / MK * (PH / 2), 0);
73  return out;
74}
75const _s = new THREE.Vector3(), _p = new THREE.Vector3(), _w = new THREE.Vector3();
76function morphPos(lat, lon, m, out) {
77  sphPos(lat, lon, _s); plnPos(lat, lon, _p);
78  // 球側だけメッシュの回転(ヨー+ピッチ)を受ける。平面側は正面固定
79  //(モーフ中は回転→0を並走させるので繋ぎ目は連続)
80  if (mesh) _s.applyEuler(mesh.rotation);
81  out.lerpVectors(_s, _p, m);
82  return out;
83}
84
85// ---------- DOM ----------
86function build() {
87  if (built) return; built = true;
88  const css = document.createElement('style');
89  css.textContent = `
90#emOv{position:fixed;inset:0;z-index:1255;display:none;background:rgba(3,7,9,.96);backdrop-filter:blur(6px)}
91#emOv.on{display:flex;flex-direction:column}
92#emHead{display:flex;align-items:center;gap:14px;padding:10px 18px;border-bottom:1px solid rgba(121,231,221,.25);background:rgba(6,10,12,.9)}
93#emHead b{color:#ffe9a8;font-size:13px;letter-spacing:.22em}
94#emCur{color:#79e7dd;font-size:11px;letter-spacing:.08em}
95#emViewSw{display:flex;margin-left:auto;border:1px solid rgba(121,231,221,.35)}
96#emViewSw button{background:none;border:none;color:#9fc9c9;padding:7px 16px;font-size:11px;letter-spacing:.14em;cursor:pointer;font-family:var(--maru)}
97#emViewSw button.on{background:rgba(121,231,221,.16);color:#dffdf9}
98#emClose{background:none;border:1px solid rgba(232,185,77,.5);color:#ffe9a8;padding:7px 18px;cursor:pointer;font-size:11px;letter-spacing:.14em}
99#emClose:hover{background:rgba(232,185,77,.15)}
100#emBody{flex:1;display:flex;min-height:0}
101#emMapWrap{position:relative;flex:1;min-width:0;overflow:hidden}
102#emMapWrap canvas{display:block;width:100%;height:100%;cursor:grab}
103#emMapWrap.drag canvas{cursor:grabbing}
104#emMarks{position:absolute;inset:0;pointer-events:none;overflow:hidden}
105.emMk{position:absolute;transform:translate(-50%,-100%);pointer-events:auto;cursor:pointer;display:flex;flex-direction:column;align-items:center;gap:2px;color:#8fe8de;transition:color .2s}
106.emMk:hover{color:#dffdf9}
107.emMk .mkIc{background:rgba(5,12,14,.82);border:1px solid rgba(121,231,221,.45);padding:4px;display:grid;place-items:center}
108.emMk small{font-size:10px;letter-spacing:.1em;background:rgba(5,12,14,.75);padding:1px 6px;white-space:nowrap;font-family:var(--maru)}
109.emMk.cur{color:#ffd98a}.emMk.cur .mkIc{border-color:rgba(232,185,77,.7)}
110.emMk.sel{color:#fff}.emMk.sel .mkIc{border-color:#79e7dd;box-shadow:0 0 14px rgba(121,231,221,.5)}
111.emMk.dim{opacity:0;pointer-events:none}
112#emShuttle{position:absolute;pointer-events:none;color:#ffe9a8;display:none;transform:translate(-50%,-50%)}
113#emTravelTag{position:absolute;
113left:50%;top:14px;transform:translateX(-50%);display:none;color:#dffdf9;font-family:var(--mono);font-size:11px;letter-spacing:.24em;background:rgba(5,12,14,.8);border:1px solid rgba(121,231,221,.4);border-left:3px solid #e8b94d;padding:6px 16px}
114#emInfo{width:min(33vw,390px);flex:none;border-left:1px solid rgba(121,231,221,.22);background:rgba(7,11,13,.92);overflow-y:auto;display:flex;flex-direction:column}
115#emInfo .noSel{margin:auto;color:#5d8f8f;font-size:12px;letter-spacing:.1em;text-align:center;line-height:2}
116#emInfoBody{display:none;flex-direction:column}
117#emInfoBody.on{display:flex}
118#emInfoBody img{width:100%;aspect-ratio:16/9;object-fit:cover;display:block}
119.emIhead{display:flex;align-items:center;gap:10px;padding:12px 16px 4px;color:#ffe9a8}
120.emIhead h3{font-size:16px;letter-spacing:.14em;font-family:var(--maru)}
121.emIhead small{color:#79e7dd;font-size:10px;letter-spacing:.2em}
122.emDesc{padding:8px 16px;color:#b8d4d2;font-size:12px;line-height:1.9;font-family:var(--maru)}
123.emStats{margin:6px 16px;border-top:1px solid rgba(121,231,221,.2)}
124.emStats div{display:flex;gap:10px;padding:7px 2px;border-bottom:1px solid rgba(121,231,221,.12);font-size:12px}
125.emStats b{flex:none;width:64px;color:#79e7dd;font-weight:400;letter-spacing:.12em;font-size:11px}
126.emStats span{color:#e6f2f0;font-family:var(--maru)}
127.emPeople{padding:4px 16px 8px;color:#9fbcbc;font-size:11.5px;line-height:1.8;font-family:var(--maru)}
128#emBtns{display:flex;gap:10px;padding:10px 16px 18px;margin-top:auto}
129#emGoBtn{flex:1;background:rgba(232,185,77,.14);border:1px solid rgba(232,185,77,.6);color:#ffe9a8;padding:12px;cursor:pointer;font-size:13px;letter-spacing:.2em;font-family:var(--maru);font-weight:700}
130#emGoBtn:hover{background:rgba(232,185,77,.26)}
131#emGoBtn[disabled]{opacity:.4;cursor:default}
132#emHereTag{flex:1;display:grid;place-items:center;border:1px dashed rgba(121,231,221,.4);color:#79e7dd;font-size:12px;letter-spacing:.2em}
133@media (max-width:900px){
134  #emBody{flex-direction:column}
135  #emInfo{width:100%;height:44vh;border-left:none;border-top:1px solid rgba(121,231,221,.25)}
136  #emViewSw button{padding:7px 10px}
137}`;
138  document.head.appendChild(css);
139  const el = document.createElement('div');
140  el.id = 'emOv';
141  el.innerHTML = `
142    <div id="emHead"><b>EARTH MAP — 地球都市マップ</b><span id="emCur"></span>
143      <div id="emViewSw"><button id="emV3d" class="on">3D球体</button><button id="emVmap">メルカトル</button><button id="emVjp">日本拡大</button></div>
144      <button id="emClose">閉じる [ESC]</button></div>
145    <div id="emBody">
146      <div id="emMapWrap"><div id="emMarks"></div>
147        <div id="emShuttle">${iconSvg('rocket', 22)}</div>
148        <div id="emTravelTag"></div>
149      </div>
150      <aside id="emInfo">
151        <div class="noSel">都市マーカーを選択してください<br>MERCATOR / GLOBE 切替可</div>
152        <div id="emInfoBody">
153          <img id="emImg" alt="">
154          <div class="emIhead"><span id="emIcon"></span><div><h3 id="emName"></h3><small id="emEn"></small></div></div>
155          <div class="emDesc" id="emDesc"></div>
156          <div class="emStats">
157            <div><b>人口</b><span id="emPop"></span></div><div><b>機械</b><span id="emMac"></span></div>
158            <div><b>主産業</b><span id="emInd"></span></div>
159            <div><b>名産</b><span id="emMei"></span></div>
160          </div>
161          <div class="emPeople" id="emPeople"></div>
162          <div id="emBtns"></div>
163        </div>
164      </aside>
165    </div>`;
166  document.body.appendChild(el);
167  $id('emClose').onclick = close;
168  $id('emV3d').onclick = () => setView(true);
169  $id('emVmap').onclick = () => setView(false);
170  $id('emVjp').onclick = () => zoomJapan();
171  addEventListener('keydown', e => { if (e.code === 'Escape' && isOpen && !traveling) close(); });
172  buildScene();
173  buildMarkers();
174  addEventListener('resize', onResize);
175}
176
177// ---------- three.js シーン(球⇔平面モーフ) ----------
178function buildScene() {
179  const wrap = $id('emMapWrap');
180  renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true });
181  renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
182  wrap.insertBefore(renderer.domElement, wrap.firstChild);
183  scene = new THREE.Scene();
184  camera = new THREE.PerspectiveCamera(45, 1, .1, 20);
185  camera.position.set(0, 0, 3.6);
186  const geo = new THREE.SphereGeometry(1, 128, 80);
187  // 各頂点の平面(メルカトル)ターゲット座標を uv から焼き込む
188  const uv = geo.attributes.uv, n = uv.count;
189  const aPlane = new Float32Array(n * 3);
190  for (let i = 0; i < n; i++) {
191    const u = uv.getX(i), v = uv.getY(i);
192    const lat = (v - 0.5) * 180, la = Math.max(-78, Math.min(78, lat)) * Math.PI / 180;
193    aPlane[i * 3] = (u - 0.5) * PW;
194    aPlane[i * 3 + 1] = Math.log(Math.tan(Math.PI / 4 + la / 2)) / MK * (PH / 2);
195    aPlane[i * 3 + 2] = 0;
196  }
197  geo.setAttribute('aPlane', new THREE.BufferAttribute(aPlane, 3));
198  // 注: ShaderMaterialは色空間の自動再エンコードが無いので、あえてデコード無し(リニア扱い)で
199  //     JPEGの見た目の色をそのまま使う(SRGB指定すると海が黒く沈む)
200  const tex = new THREE.TextureLoader().load('assets/earth_atmos_2048.jpg');
201  mat = new THREE.ShaderMaterial({
202    uniforms: { uM: { value: 0 }, map: { value: tex }, uLight: { value: new THREE.Vector3(.5, .4, 1).normalize() } },
203    vertexShader: `
204      attribute vec3 aPlane; uniform float uM; varying vec2 vUv; varying vec3 vN;
205      void main(){
206        vUv = uv;
207        vec3 p = mix(position, aPlane, uM);
208        vN = normalize(normalMatrix * normalize(mix(normalize(position), vec3(0.,0.,1.), uM)));
209        gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.);
210      }`,
211    fragmentShader: `
212      uniform sampler2D map; uniform vec3 uLight; varying vec2 vUv; varying vec3 vN;
213      void main(){
214        vec3 c = texture2D(map, vUv).rgb;
215        float li = .62 + .48 * max(dot(normalize(vN), normalize(uLight)), 0.);
216        gl_FragColor = vec4(c * li, 1.);
217      }`,
218  });
219  mesh = new THREE.Mesh(geo, mat);
220  scene.add(mesh);
221  // 航路ライン(移動アニメ用・64点)
222  const lg = new THREE.BufferGeometry();
223  lg.setAttribute('position', new THREE.BufferAttribute(new Float32Array(65 * 3), 3));
224  routeLine = new THREE.Line(lg, new THREE.LineBasicMaterial({ color: 0x8fe8de, transparent: true, op
224acity: 0 }));
225  routeLine.frustumCulled = false;
226  scene.add(routeLine);
227  // ドラッグで地球儀を回す(球体ビューのみ)
228  const cv = renderer.domElement;
229  cv.addEventListener('pointerdown', e => { if (!view3d || traveling) return; dragging = true; autoRot = false; dragX = e.clientX; dragY = e.clientY; $id('emMapWrap').classList.add('drag'); });
230  addEventListener('pointermove', e => {
231    if (!dragging) return;
232    mesh.rotation.y += (e.clientX - dragX) * .005;                                   // 左右
233    mesh.rotation.x = Math.max(-1.25, Math.min(1.25, mesh.rotation.x + (e.clientY - dragY) * .005));   // 上下
234    dragX = e.clientX; dragY = e.clientY;
235  });
236  addEventListener('pointerup', () => { dragging = false; const w = $id('emMapWrap'); if (w) w.classList.remove('drag'); });
237}
238function onResize() {
239  if (!isOpen || !renderer) return;
240  const wrap = $id('emMapWrap');
241  const w = wrap.clientWidth || 800, h = wrap.clientHeight || 500;
242  renderer.setSize(w, h, false);
243  camera.aspect = w / h;
244  camera.updateProjectionMatrix();
245}
246
247// ---------- マーカー ----------
248const mkEls = [];
249function buildMarkers() {
250  const box = $id('emMarks');
251  CITIES.forEach((c, i) => {
252    const g = GEO[i]; if (!g) return;
253    const d = document.createElement('div');
254    d.className = 'emMk';
255    d.innerHTML = `<span class="mkIc">${iconSvg(g.icon, i === TANEGASHIMA ? 18 : 15)}</span><small>${c[0]}</small>`;
256    d.onclick = () => { select(i); emit('sfx', ['uiSel', 1]); };
257    box.appendChild(d);
258    mkEls.push(d);
259  });
260}
261function placeMarkers() {
262  const wrap = $id('emMapWrap');
263  const w = wrap.clientWidth, h = wrap.clientHeight;
264  const m = mat.uniforms.uM.value;
265  mkEls.forEach((el, i) => {
266    const g = GEO[i];
267    morphPos(g.ll[0], g.ll[1], m, _w);
268    // 球体ビューでは裏側を隠す(morphが進むほど全表示へ)
269    const backside = _w.z < 0.08 && m < 0.55;
270    _w.multiplyScalar(1.02);
271    const v = _w.project(camera);
272    el.style.left = ((v.x * .5 + .5) * w) + 'px';
273    el.style.top = ((-v.y * .5 + .5) * h) + 'px';
274    el.classList.toggle('dim', backside);
275    el.classList.toggle('cur', i === curIdx);
276    el.classList.toggle('sel', i === selIdx);
277  });
278}
279
280// ---------- ビュー切替(3D ⇔ メルカトル) ----------
281function setView(to3d) {
282  if (traveling) return;
283  zoomJp = false;
284  gsap.to(camera.position, { z: 3.6, duration: 1.1, ease: 'power2.inOut' });
285  if (view3d === to3d) { view3d = to3d; syncSw(); return; }
286  view3d = to3d;
287  syncSw();
288  autoRot = false;
289  // 平面へは回転を0へ・球体へ戻るときは現在地を正面に
290  const ty = to3d ? faceRot(curIdx) : nearestZero(mesh.rotation.y);
291  const tx = to3d ? faceTilt(curIdx) : 0;
292  gsap.to(mat.uniforms.uM, { value: to3d ? 0 : 1, duration: 1.25, ease: 'power2.inOut' });
293  gsap.to(mesh.rotation, { y: ty, x: tx, duration: 1.25, ease: 'power2.inOut', onComplete: () => { autoRot = to3d; } });
294  emit('sfx', ['mapOpen', .7]);
295}
296// 日本拡大 — 球体のまま日本(種子島・名古屋・大阪・東京)へ寄る
297function zoomJapan() {
298  if (traveling) return;
299  if (!view3d) {   // 平面中なら先に球体へ戻す
300    view3d = true;
301    gsap.to(mat.uniforms.uM, { value: 0, duration: .9, ease: 'power2.inOut' });
302  }
303  zoomJp = true;
304  autoRot = false;
305  syncSw();
306  const phi = ((137 + 180) / 360) * Math.PI * 2;
307  let ry = Math.PI / 2 - phi;
308  while (ry < mesh.rotation.y - Math.PI) ry += Math.PI * 2;
309  while (ry > mesh.rotation.y + Math.PI) ry -= Math.PI * 2;
310  gsap.to(mesh.rotation, { y: ry, x: 35 * Math.PI / 180, duration: 1.2, ease: 'power2.inOut' });
311  gsap.to(camera.position, { z: 1.52, duration: 1.2, ease: 'power2.inOut' });
312  emit('sfx', ['mapOpen', .8]);
313}
314function syncSw() {
315  $id('emV3d').classList.toggle('on', view3d && !zoomJp);
316  $id('emVmap').classList.toggle('on', !view3d);
317  $id('emVjp').classList.toggle('on', zoomJp);
318}
319function faceRot(i) {
320  // 都市iを正面(z+)に向ける回転(現回転から最短経路)
321  const lon = GEO[i].ll[1];
322  const phi = ((lon + 180) / 360) * Math.PI * 2;
323  let r = Math.PI / 2 - phi;
324  const cur = mesh.rotation.y;
325  while (r < cur - Math.PI) r += Math.PI * 2;
326  while (r > cur + Math.PI) r -= Math.PI * 2;
327  return r;
328}
329function nearestZero(r) { return Math.round(r / (Math.PI * 2)) * Math.PI * 2; }   // 最寄りの一周ゼロ点(平面展開時の余計な回転を防ぐ)
330function faceTilt(i) { return Math.max(-1.1, Math.min(1.1, GEO[i].ll[0] * Math.PI / 180)); }   // 都市の緯度を画面中央へ
331
332// ---------- 都市選択 → 右パネル ----------
333function select(i) {
334  selIdx = i;
335  const c = CITIES[i], g = GEO[i];
336  $id('emInfo').querySelector('.noSel').style.display = 'none';
337  const b = $id('emInfoBody');
338  b.classList.add('on');
339  $id('emImg').src = `assets/earth/city_${c[2]}.webp`;
340  $id('emIcon').innerHTML = iconSvg(g.icon, 26);
341  $id('emIcon').style.color = '#ffe9a8';
342  $id('emName').textContent = c[0];
343  $id('emEn').textContent = c[1];
344  $id('emDesc').textContent = c[3];
345  $id('emPop').textContent = g.pop;
346  $id('emMac').textContent = g.mac || '—';
347  $id('emInd').textContent = g.ind;
348  $id('emMei').textContent = g.mei;
349  $id('emPeople').innerHTML = '【会える人】 ' + c[4].map(p => `<b style="color:#dff3f3">${p[0]}</b>`).join(' ');
350  const btns = $id('emBtns');
351  if (i === curIdx) {
352    btns.innerHTML = '<div id="emHereTag">現在地</div>';
353  } else {
354    btns.innerHTML = '<button id="emGoBtn">移動する ▸</button>';
355    $id('emGoBtn').onclick = () => travel(i);
356  }
357  if (view3d && !traveling) { autoRot = false; gsap.to(mesh.rotation, { y: faceRot(i), x: faceTilt(i), duration: .9, ease: 'power2.inOut' }); }
357   // 選択都市を中央に
358}
359
360// ---------- 移動アニメ(航路+シャトル) ----------
361const travelState = { p: 0, from: 0, to: 0 };
362const _a = new THREE.Vector3(), _b = new THREE.Vector3(), _q = new THREE.Vector3();
363function pathPoint(t, out) {
364  // 大円スラープ → lat/lon に戻して現在のモーフ量で配置(両ビューで正しい航跡になる)
365  sphPos(GEO[travelState.from].ll[0], GEO[travelState.from].ll[1], _a);
366  sphPos(GEO[travelState.to].ll[0], GEO[travelState.to].ll[1], _b);
367  const ang = _a.angleTo(_b) || .0001;
368  _q.copy(_a).multiplyScalar(Math.sin((1 - t) * ang) / Math.sin(ang))
369    .addScaledVector(_b, Math.sin(t * ang) / Math.sin(ang)).normalize();
370  const lat = Math.asin(Math.max(-1, Math.min(1, _q.y))) * 180 / Math.PI;
371  const lon = (Math.atan2(_q.z, -_q.x) / (Math.PI * 2)) * 360 - 180;
372  morphPos(lat, lon, mat.uniforms.uM.value, out);
373  // 弧の高さ(球体ビューのみ膨らませる)
374  const lift = Math.sin(t * Math.PI) * .12 * (1 - mat.uniforms.uM.value);
375  out.multiplyScalar(1.015 + lift);
376  return out;
377}
378function updateRoute() {
379  const pos = routeLine.geometry.attributes.position;
380  for (let k = 0; k <= 64; k++) {
381    pathPoint(k / 64, _w);
382    pos.setXYZ(k, _w.x, _w.y, _w.z);
383  }
384  pos.needsUpdate = true;
385}
386// ---------- 搭乗券(出発/到着の現地時刻・所要・距離で「旅」を可視化する) ----------
387function buildTicket(from, dest) {
388  let tk = $id('emTicket');
389  if (!tk) {
390    const css = document.createElement('style');
391    css.textContent = `
392#emTicket{position:absolute;right:16px;top:60px;z-index:6;width:min(300px,82vw);background:rgba(8,13,12,.93);border:1px solid rgba(232,185,77,.6);box-shadow:0 8px 30px rgba(0,0,0,.5);backdrop-filter:blur(6px);opacity:0;transform:translateY(-8px);transition:all .5s cubic-bezier(.2,.9,.3,1);pointer-events:none}
393#emTicket.on{opacity:1;transform:none}
394#emTicket .tkHead{display:flex;justify-content:space-between;align-items:baseline;padding:8px 12px;border-bottom:1px dashed rgba(232,185,77,.45)}
395#emTicket .tkHead b{font-family:var(--mono,monospace);font-size:10px;letter-spacing:.24em;color:#e8c37a}
396#emTicket .tkHead span{font-family:var(--mono,monospace);font-size:10px;color:#79e7dd}
397#emTicket .tkBody{padding:10px 12px;display:grid;grid-template-columns:1fr auto 1fr;gap:4px;align-items:center}
398#emTicket .tkCity{font-family:var(--maru);font-size:14.5px;color:#eafafa;font-weight:700}
399#emTicket .tkTime{font-family:var(--mono,monospace);font-size:17px;color:#ffe9a8;letter-spacing:.06em}
400#emTicket .tkSub{font-size:9.5px;color:#5d8f8f;letter-spacing:.14em;font-family:var(--mono,monospace)}
401#emTicket .tkArrow{color:#79e7dd;font-family:var(--mono,monospace);font-size:13px;padding:0 6px}
402#emTicket .tkFoot{padding:7px 12px;border-top:1px dashed rgba(232,185,77,.45);display:flex;justify-content:space-between;font-family:var(--mono,monospace);font-size:10px;color:#9fc9c5;letter-spacing:.1em}`;
403    document.head.appendChild(css);
404    tk = document.createElement('div');
405    tk.id = 'emTicket';
406    $id('emOv').appendChild(tk);
407  }
408  // 大円距離と現地時刻(経度から時帯を推定)
409  const a = GEO[from].ll, b = GEO[dest].ll, R = Math.PI / 180;
410  const ang = Math.acos(Math.min(1, Math.sin(a[0] * R) * Math.sin(b[0] * R) + Math.cos(a[0] * R) * Math.cos(b[0] * R) * Math.cos((b[1] - a[1]) * R)));
411  const km = Math.round(ang * 6371);
412  const durH = km / 1150 + 0.5;                                    // 準軌道ライナー+乗降
413  const tzA = Math.round(a[1] / 15), tzB = Math.round(b[1] / 15);
414  const nowUtc = Date.now() + new Date().getTimezoneOffset() * 60000;
415  const fmt = ms => { const d2 = new Date(ms); return String(d2.getHours()).padStart(2, '0') + ':' + String(d2.getMinutes()).padStart(2, '0'); };
416  const dep = fmt(nowUtc + tzA * 3600000);
417  const arr = fmt(nowUtc + tzB * 3600000 + durH * 3600000);
418  const h = Math.floor(durH), m = Math.round((durH - h) * 60);
419  tk.innerHTML = `
420    <div class="tkHead"><b>BOARDING PASS</b><span>ET-${String(from * 20 + dest).padStart(3, '0')}</span></div>
421    <div class="tkBody">
422      <div><div class="tkCity">${CITIES[from][0]}</div><div class="tkTime">${dep}</div><div class="tkSub">出発・現地時刻</div></div>
423      <div class="tkArrow">&gt;&gt;</div>
424      <div style="text-align:right"><div class="tkCity">${CITIES[dest][0]}</div><div class="tkTime">${arr}</div><div class="tkSub">到着・現地時刻</div></div>
425    </div>
426    <div class="tkFoot"><span>所要 ${h}時間${String(m).padStart(2, '0')}分</span><span>${km.toLocaleString()} km / 準軌道便</span></div>`;
427  setTimeout(() => tk.classList.add('on'), 30);   // rAFは非表示タブで発火しないためsetTimeout
428  return tk;
429}
430function travel(dest) {
431  if (traveling || dest === curIdx) return;
432  traveling = true;
433  autoRot = false;
434  travelState.from = curIdx; travelState.to = dest; travelState.p = 0;
435  const tag = $id('emTravelTag');
436  tag.style.display = 'block';
437  tag.textContent = `TRANSIT — ${CITIES[curIdx][0]} → ${CITIES[dest][0]}`;
438  $id('emShuttle').style.display = 'block';
439  routeLine.material.opacity = .85;
440  emit('sfx', ['launch', .8]);
441  const tk = buildTicket(curIdx, dest);
442  // 地球へ寄る(拡大)→ 大円移動 → 到着都市を正面に
443  if (camera) gsap.to(camera.position, { z: 2.15, duration: 1.2, ease: 'power2.inOut' });
444  if (view3d && mesh) gsap.to(mesh.rotation, { y: faceRot(dest), x: faceTilt(dest), duration: 4.8, ease: 'power1.inOut' });
445  gsap.to(travelState, {
446    p: 1, duration: 5.4, ease: 'power1.inOut',
447    onComplete: () => {
448      if (camera) gsap.to(camera.position, { z: 2.9, duration: .9, ease: 'power2.out' });
449      setTimeout(() => tk.classList.remove('on'), 1400);
450      traveling = false;
451      routeLine.material.opacity = 0;
452      $id('emShuttle').style.display = 'none';
453      tag.style.display = 'none';
454      curIdx = dest;
455      $id('emCur').textContent = `現在地: ${CITIES[curIdx][0]}`;
456      emit('sfx', ['uiOk', 1]);
457      emit('toast', `${CITIES[dest][0]} に到着しました`);
458      gotoCity(dest);   // 背後の都市ビューを差し替え
459      select(dest);     // パネルは現在地表示に
460      setTimeout(close, 1900);   // 搭乗券と到着ズームを見せてから閉じる
461    },
462  });
463}
464function placeShuttle() {
465  if (!traveling) return;
466  updateRoute();
467  const wrap = $id('emMapWrap');
468  pathPoint(travelState.p, _w);
469  const v = _w.project(camera);
470  const sh = $id('emShuttle');
471  const x = (v.x * .5 + .5) * wrap.clientWidth, y = (-v.y * .5 + .5) * wrap.clientHeight;
472  // 進行方向へ回頭
473  pathPoint(Math.min(1, travelState.p + .02), _q);
474  const v2 = _q.project(camera);
475  const ang = Math.atan2((-v2.y + v.y) * wrap.clientHeight, (v2.x - v.x) * wrap.clientWidth) * 180 / Math.PI;
476  sh.style.left = x + 'px'; sh.style.top = y + 'px';
477  sh.style.transform = `translate(-50%,-50%) rotate(${ang + 90}deg)`;
478}
479
480// ---------- ループ / 開閉 ----------
481function render() {
482  if (!renderer) return;
483  if (autoRot && view3d && !dragging && !traveling) mesh.rotation.y += .0016;
484  placeMarkers();
485  placeShuttle();
486  renderer.render(scene, camera);
487}
488function loop() { if (!isOpen) return; render(); requestAnimationFrame(loop); }
489
490function open(fromIdx) {
491  build();
492  curIdx = (fromIdx == null ? curIdx : fromIdx);
493  selIdx = -1;
494  $id('emOv').classList.add('on');
495  $id('emCur').textContent = `現在地: ${CITIES[curIdx][0]}`;
496  $id('emInfo').querySelector('.noSel').style.display = '';
497  $id('emInfoBody').classList.remove('on');
498  isOpen = true;
499  onResize();
500  mesh.rotation.y = faceRot(curIdx);
501  mesh.rotation.x = faceTilt(curIdx);
502  camera.position.z = 3.6; zoomJp = false;
503  mat.uniforms.uM.value = view3d ? 0 : 1;
504  autoRot = view3d;
505  loop();
506  emit('sfx', ['mapOpen', 1]);
507}
508function close() {
509  if (traveling) return;
510  isOpen = false;
511  $id('emOv').classList.remove('on');
512  emit('sfx', ['uiSel', 1]);
513}
514
515export function initEarthMap() {
516  on('earthMapOpen', i => open(typeof i === 'number' ? i : undefined));
517  window.__emap = { open, close, select, travel, render, setView, zoomJapan, get state() { return { cur: curIdx, sel: selIdx, view3d, zoomJp, camZ: camera ? camera.position.z : 0, rotX: mesh ? mesh.rotation.x : 0, traveling, morph: mat ? mat.uniforms.uM.value : null }; } };
518}

Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.