BLK-84Blocks

3D地球儀(取引国・対応地域を弧線で結ぶ)

海外対応・越境の説明に使う、回せる3D地球儀のブロック。本拠地(東京)から取引国・対応地域の7都市へ大圏の弧線を伸ばし、右の一覧で国を選ぶと、その経路の中点が正面に来るよう地球が向きを変える。陸地は地図ファイルや画像を使わず、経度・緯度の粗い多角形を自前で持ち、緯度ごとに経度方向の間隔を 1/cos(緯度) で広げた点の格子を内外判定でふるった点描で描く(点描が輪郭の粗さを隠すので、簡略図でも地球として読める)。弧線は2点の単位ベクトルを球面線形補間(slerp)し、中心角に比例して持ち上げるので遠い経路ほど高く弧を描き、描き出しは setDrawRange で頂点数を増やすだけ。取引国は塗りの四角、対応地域は枠の四角、本拠地はアンバーの四角と広がる輪で区別し、選択中の経路だけアンバーになって光点が進む。地名ラベルは球上の点を画面座標へ投影したDOMで重ね、裏側に回ると消える。操作はドラッグのほか、地球儀にフォーカスして矢印キーで15度ずつ回転・Enterで経路へ戻り、一覧は矢印キー・Home・Endで移動と選択ができるので、キーボードだけで完結する。距離(大圏距離・100km丸め)と時差(標準時)は一覧と地球儀の下に文字で出し、aria-live で読み上げる。3Dライブラリ(three)は画面に入った実寸表示のときだけ useEffect 内の動的importで読み込み、一覧の縮小カード・WebGL非対応・コンテキスト喪失・低速環境では、同じ地点と弧を正距円筒図法の平面図(SVG)で表示する。prefers-reduced-motion ではループを回さず、選択やキー操作のたびに最終状態を1枚だけ描く。角丸・末尾の矢印リンク・英字の小見出しは使わず、数字の台帳と hairline 罫線で節を組んでいる。

追加日:
2026-09-29
依存:
three
tags
#3d #globe #map #data-visualization #international #webgl #interactive #keyboard #no-image #dynamic-import

プレビュー

東京から、7つの国と地域へ

取引のある国と、制作・保守を受け付けている地域を地球儀に置きました。一覧で国を選ぶと、東京からの経路が正面に来るよう地球が向きを変えます。

掲載の国・地域と業務内容は、構成を示すためのサンプルです(実在の取引実績ではありません)

取引国
4か国
対応地域
3地域
最長の経路
8,900km

ドラッグ、または矢印キーで回転(Enterで経路に戻る)

東京と台北(台湾)約2,100km・時差 −1時間

  • 本拠地
  • 取引国
  • 対応地域
  • 選択中の経路

国・地域を選ぶ

国・地域と業務内容はサンプルです。自社の実績に差し替えてお使いください。距離は都市間の大圏距離を100km単位で丸めた概算、時差は標準時での差です。大陸の輪郭は簡略化した図で、国境は描いていません。

海外向けサイトの相談をする
"use client";

import { useEffect, useId, useRef, useState, type KeyboardEvent } from "react";
import { Montserrat } from "next/font/google";
// 型だけの import。tsc が消すので 3Dライブラリのコードは1バイトも入らない(実体は下の動的 import)。
import type * as THREE from "three";

const montserrat = Montserrat({ weight: ["600", "700"], subsets: ["latin"], display: "swap" });

/**
 * 3D地球儀(取引国・対応地域を弧線で結ぶ)
 *
 * 差別化: 既存の3D標本は物体の質感を見せる単体展示、対応エリア図は国内の平面の可否台帳だった。
 * 本ブロックは球面上の座標データ(本拠地1点と相手先N点)を大圏の弧で結び、一覧で国を選ぶと
 * 地球がその経路の中点を正面に向ける「越境の説明図」。角丸・末尾の矢印リンク・英字の小見出しは置かない。
 *
 * 汎用技法メモ:
 * 1. **地図データのファイルを持たない**。大陸は経度・緯度の粗い多角形(数十頂点)で持ち、
 *    緯度ごとに経度方向の間隔を 1/cos(緯度) で広げた点の格子を内外判定でふるい、点描の陸地にする。
 *    点描は輪郭の粗さを隠すので、簡略図でも地球として読める。日付変更線をまたぐ陸地は
 *    経度を 180 超で書き、判定側で lon と lon+360 の両方を試せば分割しなくていい。
 * 2. **弧線は球面線形補間(slerp)で作る**。2点の単位ベクトル a・b と中心角 ω から
 *    p(t) = (sin((1-t)ω)a + sin(tω)b) / sin ω で大圏上の点が出る。半径に 1 + h·sin(πt) を掛けて
 *    持ち上げ、h を ω に比例させると遠い経路ほど高く弧を描く。描き出しは `setDrawRange` で頂点数を増やすだけ。
 * 3. **ある地点を正面に向ける回転は2つの角だけ**。点 (lat, lon) を x=cos·sin, y=sin, z=cos·cos に置くと、
 *    `rotation.set(lat, -lon, 0)`(既定の XYZ 順)でその点がカメラ正面 (0,0,1) に来る。
 *    2点を同時に見せたいときは a+b を正規化した中点を向けるが、真正面からだと大圏が一直線に潰れるので、
 *    弧の面の法線(a×b を北向きにそろえる)と逆側へ 24° ほど倒して向ける。yaw は ±π の範囲で最短側へ寄せる。
 * 4. **線は常に1px**(WebGL の線幅は実質固定)。これを欠点と見ず、hairline 罫線の紙面に揃える。
 *    輪郭の円も「カメラ距離 d から見た球の縁」= z=1/d・半径 √(1-1/d²) の円を1本置くだけで引ける。
 * 5. **地名ラベルは DOM で重ねる**。球上の点を `project(camera)` で画面座標にし、
 *    ラベルの transform だけを書き換える(React の再描画を毎フレーム起こさない)。裏側に回ったら opacity 0。
 * 6. 起動条件は「画面内」かつ「縮小プレビューでない」(rect.width ÷ offsetWidth)。WebGL が無い・
 *    コンテキストを失った・フレームが間に合わないときは、同じ点と弧を正距円筒の平面図(SVG)で出す。
 * 7. prefers-reduced-motion ではループを回さず、選択やキー操作のたびに最終状態を1枚だけ描く。
 *    画面外で選択が変わったときも同じ経路で即時に置き換える(止まったループの補間を待たない)。
 *
 * 文言・地点・配色はすべて First CH のオリジナル。
 */

type ThreeModule = typeof import("three");

const AMBER = 0xd97706;
const INK = 0x292524;

/* ------------------------------------------------------------------ 地点 */

type Kind = "取引国" | "対応地域";

type Place = {
  id: string;
  country: string;
  city: string;
  kind: Kind;
  lat: number;
  lon: number;
  /** 標準時の UTC オフセット(夏時間は考えない) */
  utc: number;
  note: string;
};

const HOME = { city: "東京", lat: 35.68, lon: 139.69, utc: 9 };

const PLACES: Place[] = [
  {
    id: "tw",
    country: "台湾",
    city: "台北",
    kind: "取引国",
    lat: 25.03,
    lon: 121.57,
    utc: 8,
    note: "繁体字サイトの運用と、現地代理店向けの営業資料",
  },
  {
    id: "vn",
    country: "ベトナム",
    city: "ホーチミン",
    kind: "取引国",
    lat: 10.82,
    lon: 106.63,
    utc: 7,
    note: "開発パートナーとの共同制作。週1回の定例あり",
  },
  {
    id: "sg",
    country: "シンガポール",
    city: "シンガポール",
    kind: "対応地域",
    lat: 1.35,
    lon: 103.82,
    utc: 8,
    note: "英語サイトの制作と保守に対応",
  },
  {
    id: "au",
    country: "オーストラリア",
    city: "シドニー",
    kind: "対応地域",
    lat: -33.87,
    lon: 151.21,
    utc: 10,
    note: "時差が小さく、日中のうちに連絡がつきます",
  },
  {
    id: "ae",
    country: "アラブ首長国連邦",
    city: "ドバイ",
    kind: "対応地域",
    lat: 25.2,
    lon: 55.27,
    utc: 4,
    note: "英語と、右から左に組むアラビア語の紙面に対応",
  },
  {
    id: "de",
    country: "ドイツ",
    city: "ベルリン",
    kind: "取引国",
    lat: 52.52,
    lon: 13.4,
    utc: 1,
    note: "欧州向けのEC。同意バナーと多言語の切り替えを運用",
  },
  {
    id: "us",
    country: "アメリカ",
    city: "ロサンゼルス",
    kind: "取引国",
    lat: 34.05,
    lon: -118.24,
    utc: -8,
    note: "越境EC。配送と決済の表示を現地向けに調整",
  },
];

const RAD = Math.PI / 180;

function unit(lat: number, lon: number): [number, number, number] {
  const la = lat * RAD;
  const lo = lon * RAD;
  return [Math.cos(la) * Math.sin(lo), Math.sin(la), Math.cos(la) * Math.cos(lo)];
}

/** 2地点の中心角(ラジアン) */
function centralAngle(aLat: number, aLon: number, bLat: number, bLon: number) {
  const a = unit(aLat, aLon);
  const b = unit(bLat, bLon);
  const dot = Math.min(1, Math.max(-1, a[0] * b[0] + a[1] * b[1] + a[2] * b[2]));
  return Math.acos(dot);
}

/** 3桁区切り。toLocaleString はサーバとブラウザで結果が割れうるので使わない */
function groupDigits(n: number) {
  return String(n).replace(/\B(?=(\d{3})+(?!\d))/g, ",");
}

type Route = Place & { km: number; kmLabel: string; diffLabel: string };

const ROUTES: Route[] = PLACES.map((p) => {
  const km = Math.round((centralAngle(HOME.lat, HOME.lon, p.lat, p.lon) * 6371) / 100) * 100;
  const d = p.utc - HOME.utc;
  return {
    ...p,
    km,
    kmLabel: groupDigits(km),
    diffLabel: d === 0 ? "時差なし" : `時差 ${d > 0 ? "+" : "−"}${Math.abs(d)}時間`,
  };
});

const TRADE_COUNT = ROUTES.filter((r) => r.kind === "取引国").length;
const FAR_KM = groupDigits(Math.max(...ROUTES.map((r) => r.km)));

/* ------------------------------------------------------------ 陸地(簡略) */

type LonLat = [number, number];

/** 大陸・主要な島の粗い輪郭。精度より「地球として読めること」を優先した自作の簡略図 */
const LAND: LonLat[][] = [
  // 北アメリカ
  [
    [-166, 68.8], [-156, 71.3], [-141, 69.7], [-128, 70], [-115, 68.8], [-105, 68], [-95, 68.5],
    [-94, 65], [-94, 59], [-92, 57], [-86, 55.5], [-82, 52.5], [-79, 54.5], [-77, 60], [-78, 62.5],
    [-72, 62], [-68, 60.5], [-65, 60], [-61.5, 56.5], [-56, 52], [-59, 48], [-64.5, 48.5], [-66, 45],
    [-70, 43.5], [-70, 41.7], [-74, 40.5], [-76, 37], [-75.5, 35.2], [-78, 33.8], [-81, 31.5],
    [-80, 26.5], [-80.5, 25.2], [-82, 26.5], [-82.8, 28.5], [-84, 30], [-86, 30.4], [-89, 30.2],
    [-90, 29.1], [-94, 29.6], [-97, 27.5], [-97.5, 24], [-97.5, 21.5], [-96, 19], [-94, 18.3],
    [-91, 18.7], [-90.5, 21], [-87, 21.5], [-88, 16], [-84, 15.5], [-83.5, 11], [-81, 8.8],
    [-79, 9.5], [-77.3, 8.5], [-78, 7.2], [-80, 7.3], [-82, 8.2], [-85.7, 10], [-87.5, 13],
    [-91, 13.9], [-94.5, 16], [-97, 15.7], [-101, 17.5], [-105.5, 20.5], [-105.6, 23], [-109, 26],
    [-112.5, 29.5], [-114.7, 31.7], [-113, 29], [-110, 24], [-109.5, 23], [-112, 25], [-114, 28],
    [-117, 32.5], [-120.5, 34.5], [-122.5, 37.5], [-124, 40.5], [-124.5, 43], [-124, 46.3],
    [-124.7, 48.4], [-127, 50.5], [-130, 54], [-133, 57], [-137, 58.5], [-141, 60], [-146, 60.8],
    [-151, 59.5], [-154, 57], [-158, 56], [-162, 55], [-164.5, 54.5], [-161, 58.5], [-164, 60],
    [-165.5, 62.5], [-164.5, 64.5], [-166, 65.5],
  ],
  // 北極圏の島々・グリーンランド
  [[-90, 72], [-80, 73.5], [-72, 71], [-66, 68.5], [-62, 66.5], [-65, 63], [-72, 63.5], [-78, 65], [-85, 68], [-90, 70]],
  [[-120, 71.5], [-110, 73], [-100, 72.8], [-102, 69], [-110, 68.5], [-118, 69.3]],
  [[-90, 76], [-75, 78], [-65, 81.5], [-80, 83], [-95, 81], [-95, 77]],
  [
    [-73, 78], [-66, 81], [-40, 83.5], [-22, 82.5], [-18, 79.5], [-20, 75], [-22, 70.5], [-32, 68],
    [-40, 65], [-43, 60], [-48, 61], [-52, 65], [-54, 69], [-58, 75.5], [-68, 77],
  ],
  [[-24, 65.5], [-22, 66.4], [-15, 66.5], [-13.5, 65], [-18, 63.4], [-22.5, 63.8]],
  // カリブ
  [[-85, 21.8], [-82, 23.1], [-77, 21.5], [-74.2, 20.2], [-77.5, 19.9], [-80, 21.8], [-84, 21.9]],
  [[-74.4, 18.5], [-72.8, 19.9], [-68.5, 19], [-68.4, 18.4], [-71.5, 17.7]],
  // 南アメリカ
  [
    [-77, 8.2], [-76, 9.3], [-75, 11], [-71.5, 12.4], [-68, 10.6], [-62, 10.7], [-60, 8.5], [-57, 6],
    [-52, 5], [-50, 1.8], [-48, -1], [-44, -2.5], [-39, -3.7], [-35.2, -5.5], [-35, -8.5], [-37, -12],
    [-39, -16], [-40, -20.5], [-42, -23], [-45, -23.8], [-48.5, -26.5], [-50.5, -31], [-53, -34],
    [-56.5, -36.5], [-57.5, -38.5], [-62, -39], [-65, -41], [-64, -43], [-66, -47], [-68, -50.5],
    [-69, -52.5], [-68.5, -54.8], [-72, -53.8], [-75, -50], [-74, -44], [-73.5, -40], [-73.5, -37],
    [-71.5, -32], [-71.3, -27], [-70.3, -20], [-72, -17], [-76, -14], [-78.5, -9], [-81.2, -5.5],
    [-81, -2.5], [-80, 0], [-79.5, 2], [-78, 3.5], [-77.4, 7],
  ],
  // ユーラシア(アラビア半島を含む)
  [
    [-9, 43], [-9.3, 39], [-8.9, 37], [-6, 36.2], [-2, 36.7], [0, 38.5], [0.5, 40.5], [3.2, 42],
    [3.2, 43.3], [6, 43], [8.5, 44.3], [10.2, 43.9], [12, 42], [15.6, 40], [16.1, 38], [17, 39.3],
    [18.5, 40.2], [16, 41.4], [13.5, 43.6], [12.3, 45.2], [13.7, 45.7], [15, 45], [19.4, 41.8],
    [19.4, 40.3], [21, 38], [22.5, 36.5], [23.5, 38], [24, 40.5], [26.2, 40.6], [26.5, 39.5],
    [26.3, 38.2], [27.3, 37], [28.5, 36.6], [30.6, 36.7], [32.5, 36.1], [34.5, 36.8], [36, 36],
    [35.8, 34.5], [35, 33], [34.3, 31.2], [34.9, 29.5], [35.2, 28], [37, 25.5], [39, 21.5], [41, 17],
    [43, 13], [45, 12.8], [48.5, 14], [52, 15.8], [55, 17.5], [57, 18.8], [58.5, 20.5], [59.8, 22.5],
    [58.8, 23.6], [56.4, 24.9], [56, 26], [54.5, 24.3], [52, 24], [51.4, 25.9], [50.8, 24.8],
    [50, 26.5], [48.5, 28.3], [48, 29.9], [50, 30.2], [51, 28.8], [54.8, 26.5], [57.3, 25.8],
    [61.5, 25.2], [66.6, 25.4], [67.5, 24], [70, 22.8], [72.7, 21], [73, 19], [73.5, 16],
    [74.8, 12.8], [76.3, 9.5], [77.5, 8], [78.2, 8.9], [79.9, 10.3], [80.3, 13.5], [80.1, 15.5],
    [82.3, 17], [85, 19.5], [87, 21.5], [89, 21.8], [91.8, 22.5], [92.5, 20.5], [94.3, 18.8],
    [94.4, 16], [95.5, 15.8], [97.6, 16.5], [98.5, 13], [98.3, 9], [98.7, 8], [100.3, 5.5], [101, 3],
    [103.5, 1.3], [104.2, 1.4], [103.4, 4.3], [102.2, 6.2], [100.5, 7.4], [100, 9.2], [99.2, 10.5],
    [100, 13.4], [101, 12.7], [103, 11], [104.5, 10.5], [105, 8.7], [106.6, 9.5], [109.3, 11.5],
    [109.2, 13.5], [108.7, 15.5], [106.6, 17.5], [105.7, 19], [106.8, 20.7], [108.5, 21.6],
    [110.3, 20.8], [111, 21.5], [113.5, 22.2], [116.5, 22.9], [119, 25.4], [120, 27], [121.5, 29.8],
    [121.9, 31], [120.9, 32.8], [119.2, 34.4], [120.5, 36.2], [122.5, 37], [121.2, 37.8],
    [119, 37.2], [118, 38.3], [117.7, 39], [119.5, 39.8], [121.2, 40.8], [121.8, 39.1],
    [122.5, 40.3], [124.3, 39.9], [125.3, 37.7], [126.5, 34.5], [127.5, 34.6], [129.3, 35.2],
    [129.4, 36.8], [128.4, 38.5], [127.5, 39.7], [129.7, 40.9], [130.8, 42.3], [133, 42.8],
    [135.4, 43.8], [138.5, 47], [140.2, 48.5], [140.5, 51.5], [141.4, 53.3], [139, 54], [137, 54],
    [135.2, 54.8], [138, 56.4], [141, 58.6], [145, 59.4], [149, 59.6], [152.5, 59], [155, 59.3],
    [160, 61.6], [157, 57.5], [156, 53], [156.7, 51], [158.5, 52.9], [160, 54.5], [162, 56.2],
    [163.3, 58], [164.8, 60], [170, 60], [173, 61.5], [178, 62.5], [179.5, 64.5], [182, 65],
    [186, 65.5], [190, 66], [186, 67.2], [182, 68.9], [178, 69.3], [170, 70], [160, 69.7],
    [150, 71.5], [141, 72.7], [130, 71], [128, 73], [120, 73], [113, 73.7], [107, 76.8],
    [104, 77.7], [95, 76], [88, 75.5], [80, 73.5], [80.5, 72], [78, 72.3], [75, 72.8], [72.5, 72.8],
    [72.5, 71], [73.5, 68.5], [70, 66.8], [66, 69], [60.5, 69.8], [58, 68.5], [53, 68.3], [44, 68.3],
    [44.2, 66], [40, 64.5], [37.5, 64.3], [35, 66], [40, 66.3], [41, 67.5], [38, 68.8], [33, 69.4],
    [28, 71], [23, 70.6], [18, 69.8], [14, 68], [12.3, 65.5], [10.5, 64], [5.2, 62], [5, 59.5],
    [6.5, 58.1], [8, 58], [10.5, 59.3], [11.2, 58.7], [12.5, 56.5], [12.8, 55.5], [14.2, 55.4],
    [16.5, 56.8], [18.6, 59.5], [17.5, 61], [17.3, 62.5], [21.5, 64.8], [25.4, 65.5], [22.3, 63.2],
    [21.4, 61], [23, 59.9], [28, 60.5], [29.5, 60], [24.5, 59.4], [23.5, 58.3], [24.3, 57.2],
    [21.1, 56.8], [21, 55.5], [19.7, 54.4], [18.5, 54.8], [14.5, 53.9], [11, 54], [10.3, 55.5],
    [10.6, 57.7], [8.3, 56.8], [8.1, 55.5], [8.6, 53.9], [7, 53.5], [5, 53], [4, 51.7], [1.7, 51],
    [-1.6, 49.7], [-4.5, 48.5], [-2.2, 47.3], [-1.3, 44.5], [-1.8, 43.4], [-8, 43.7],
  ],
  // ヨーロッパの島・北極海の島
  [[-5.7, 50], [1.4, 51.2], [1.7, 52.7], [0.2, 53.4], [-0.2, 54.5], [-1.6, 55.6], [-2, 57.6], [-3.3, 58.6], [-5, 58.6], [-6.2, 56.5], [-5, 55], [-3, 54], [-4.8, 52.9], [-4.2, 51.6]],
  [[-10, 51.6], [-6, 52.2], [-6, 54], [-7.5, 55.2], [-10, 54.2]],
  [[11, 78.5], [16, 80], [27, 80.2], [22, 77.5], [16, 76.6]],
  [[52, 71.5], [56, 75.5], [68, 76.8], [57, 70.6]],
  // アフリカ・マダガスカル
  [
    [-17, 21], [-16.5, 19.5], [-16.3, 16], [-17.5, 14.7], [-16.7, 12.5], [-15, 11], [-13, 9],
    [-11, 6.8], [-8, 4.4], [-4, 5.2], [0, 5.6], [2.5, 6.3], [5, 5.8], [6.2, 4.3], [8.5, 4.5],
    [9.7, 3.5], [9.4, 1], [8.8, -1], [11.5, -4], [12.2, -6], [13.4, -9], [13.8, -12], [12.3, -15],
    [11.8, -17.5], [13, -20], [14.5, -22.5], [15.2, -27], [16.5, -28.6], [18.4, -32.5], [18.5, -34.2],
    [20, -34.8], [22.5, -34], [25.7, -34], [27.5, -33.2], [30, -31.2], [31.5, -29], [32.8, -26],
    [32.6, -25.5], [35.5, -24], [35.5, -21.5], [34.7, -19.8], [36.9, -17.8], [40.5, -15], [40.5, -11],
    [39.5, -8], [39.2, -4.5], [41.5, -1.8], [43.5, 0.6], [46.5, 3], [48.5, 6], [51, 10.5],
    [51.2, 11.8], [48, 11.2], [45, 10.5], [43.3, 11.5], [42.5, 13.5], [40, 15.9], [39, 18],
    [37.3, 21], [36.9, 22], [35.5, 24], [34, 26.7], [32.6, 29.9], [32.3, 31.2], [30, 31.3], [28, 31],
    [25, 31.6], [20, 32], [20, 30.8], [18, 30.7], [15.3, 32.3], [11.5, 33.2], [10.2, 34], [11, 35.2],
    [10.2, 37.2], [8, 36.9], [3, 36.8], [-1, 35.2], [-5.5, 35.9], [-6, 35.5], [-9.8, 31],
    [-9.6, 29.8], [-13, 27.6], [-14.5, 26], [-16.1, 23.8],
  ],
  [[44, -25], [47, -25], [49.5, -16], [50.4, -15.4], [49.3, -12], [47, -15.5], [44.3, -16.5], [43.3, -22]],
  [[79.8, 6.3], [81.8, 7.3], [80.4, 9.8]],
  // 日本・東アジアの島
  [[130.9, 34], [132.5, 35.4], [135.5, 35.6], [136.8, 37.2], [138.5, 37.7], [140, 39.8], [140, 41.3], [141.5, 41.4], [142, 39], [141, 38], [140.8, 35.8], [139.8, 34.9], [139, 34.6], [137, 34.6], [135.5, 33.5], [133, 34.3], [131, 33.9]],
  [[129.7, 33.3], [131.8, 33.5], [131.3, 31.3], [130.2, 31.2]],
  [[140, 41.6], [141.3, 45.4], [142, 45.4], [145.5, 43.3], [143.5, 42], [141, 41.9]],
  [[142, 46], [143.5, 49], [143, 51], [143.3, 54.2], [142.3, 54.3], [142, 51], [141.8, 48]],
  [[120.1, 23], [121, 21.9], [121.9, 24.8], [121.4, 25.3], [120.2, 23.8]],
  // 東南アジアの島
  [[120, 18.5], [122.3, 18.5], [122, 16], [124, 13.5], [123, 13], [121.8, 14], [120.5, 14.5], [120, 16.2]],
  [[122, 7], [126.5, 7.2], [126, 9.5], [123.5, 8.5]],
  [[109, 1.5], [109.6, -1], [110.2, -3], [114.5, -4], [116.5, -2.3], [117.8, 0.8], [119, 5], [117, 7], [115.5, 5], [113, 3.2], [111, 1.7]],
  [[95.3, 5.6], [97.5, 5.2], [100.4, 2.2], [103.8, -1], [106, -3.3], [105.8, -5.8], [104, -5], [102, -4], [100, -1], [98.5, 1.8]],
  [[105.2, -6.8], [108.3, -6.3], [110.5, -6.9], [114.5, -7.8], [114.4, -8.7], [110, -8.1], [106, -7.4]],
  [[119.5, -5.5], [120.5, -1.5], [121, 1], [124.8, 1.5], [123, -1], [121.5, -2.5], [122.8, -5]],
  [[131, -1], [134, -0.8], [138, -1.6], [141, -2.6], [145.8, -5], [147.5, -6], [147.8, -8], [150, -10.3], [147, -10], [143.5, -9], [142, -9.2], [139, -8], [138, -7.5], [137.8, -5], [135, -4.2], [132.3, -4], [132, -2.7]],
  // オセアニア
  [
    [113.5, -22], [114, -26.5], [115, -31.5], [115, -34.2], [117.8, -35], [120, -33.9], [123.5, -33.9],
    [126, -32.3], [129, -31.6], [131.2, -31.5], [134.2, -32.8], [135.9, -34.8], [137.8, -33],
    [138, -35.6], [139.7, -37], [140.8, -38], [143.5, -38.8], [146.3, -39], [148, -37.8], [150, -37.5],
    [150.9, -34.4], [152.5, -32], [153.5, -28.5], [153, -25.5], [150.8, -22.5], [149, -20.5],
    [146.3, -18.8], [145.4, -15], [143.8, -14.2], [143.5, -12.5], [142.5, -10.8], [141.6, -12.8],
    [141.5, -15.5], [140.8, -17.4], [139.3, -17.4], [137, -15.9], [135.5, -14.8], [136.8, -12.2],
    [134, -11.9], [132.7, -11.5], [131, -12.2], [129.5, -14.9], [127.8, -14.3], [125.5, -14.5],
    [123.5, -16.4], [122.2, -18], [121, -19.5], [118.8, -20.3], [116.7, -20.6], [114.5, -21.8],
  ],
  [[144.6, -40.7], [148.3, -40.9], [148, -43.2], [146, -43.6]],
  [[172.7, -34.5], [174.8, -36.8], [178.5, -37.7], [177.9, -39.2], [175, -41.6], [174.6, -39.8], [173.8, -39.2], [174.5, -37.5]],
  [[172.7, -40.5], [174.3, -41.7], [172.8, -43.9], [171, -44.5], [169, -46.6], [166.5, -46], [168.4, -44], [171.3, -41.8]],
];

/** 陸地の多角形に内包される内海(黒海・カスピ海) */
const WATER: LonLat[][] = [
  [[27.5, 42], [28.5, 43.7], [30, 45.8], [33, 46.1], [35.5, 45.3], [38, 47], [39.5, 46.3], [38, 44.5], [41.5, 41.6], [38, 41], [34, 42], [30, 41.2], [28.9, 41.1]],
  [[47, 43], [48.5, 41.8], [49.8, 40.3], [49, 38.2], [51, 36.8], [54, 37.2], [53.9, 40.8], [53, 42.2], [51.3, 45.2], [53, 46.8], [51, 47.2], [49, 46.5], [47, 45]],
];

type Poly = { pts: LonLat[]; minX: number; maxX: number; minY: number; maxY: number };

function withBounds(pts: LonLat[]): Poly {
  const xs = pts.map((p) => p[0]);
  const ys = pts.map((p) => p[1]);
  return { pts, minX: Math.min(...xs), maxX: Math.max(...xs), minY: Math.min(...ys), maxY: Math.max(...ys) };
}

const LAND_POLYS = LAND.map(withBounds);
const WATER_POLYS = WATER.map(withBounds);

function inside(poly: Poly, x: number, y: number) {
  if (x < poly.minX || x > poly.maxX || y < poly.minY || y > poly.maxY) return false;
  let hit = false;
  const p = poly.pts;
  for (let i = 0, j = p.length - 1; i < p.length; j = i++) {
    const [xi, yi] = p[i];
    const [xj, yj] = p[j];
    if (yi > y !== yj > y && x < ((xj - xi) * (y - yi)) / (yj - yi) + xi) hit = !hit;
  }
  return hit;
}

function isLand(lon: number, lat: number) {
  if (lat < -70) return true; // 南極は緯度で塗る(簡略)
  const test = (x: number) =>
    LAND_POLYS.some((p) => inside(p, x, lat)) && !WATER_POLYS.some((p) => inside(p, x, lat));
  // 日付変更線をまたぐ陸地は経度 180 超で書いてあるので、+360 側も試す
  return test(lon) || (lon < 0 && test(lon + 360));
}

/* ------------------------------------------------------ 平面図(代替表示) */

/** 正距円筒図法。経度 -30 を左端に置き、東京が中央寄り・欧州が左・北米が右に来る */
const MAP_W = 360;
const MAP_H = 150;
const MAP_TOP = 84;

function mapX(lon: number) {
  return (((lon + 30) % 360) + 360) % 360;
}
function mapY(lat: number) {
  return MAP_TOP - lat;
}

/** 平面図の点描を1本の path にまとめる(SVG 要素を数千個並べない) */
const FLAT_DOTS = (() => {
  let d = "";
  for (let lat = 82.5; lat > MAP_TOP - MAP_H; lat -= 3) {
    for (let lon = -178.5; lon < 180; lon += 3) {
      if (!isLand(lon, lat)) continue;
      d += `M${(mapX(lon) - 0.8).toFixed(1)} ${(mapY(lat) - 0.8).toFixed(1)}h1.6v1.6h-1.6z`;
    }
  }
  return d;
})();

function flatArc(r: Route) {
  const x1 = mapX(HOME.lon);
  const y1 = mapY(HOME.lat);
  const x2 = mapX(r.lon);
  const y2 = mapY(r.lat);
  const lift = Math.hypot(x2 - x1, y2 - y1) * 0.28;
  const cx = (x1 + x2) / 2;
  const cy = Math.min(y1, y2) - lift;
  return `M${x1.toFixed(1)} ${y1.toFixed(1)}Q${cx.toFixed(1)} ${cy.toFixed(1)} ${x2.toFixed(1)} ${y2.toFixed(1)}`;
}

/* ------------------------------------------------------------- 3D 地球儀 */

type GlobeApi = {
  select: (index: number) => void;
  nudge: (dYaw: number, dPitch: number) => void;
  recenter: () => void;
  setRunning: (running: boolean) => void;
  dispose: () => void;
};

type GlobeOptions = {
  canvas: HTMLCanvasElement;
  host: HTMLElement;
  homeLabel: HTMLElement;
  destLabel: HTMLElement;
  index: number;
  onFirstFrame: () => void;
  onFail: () => void;
};

const ARC_SEGMENTS = 72;
const CAMERA_DIST = 3.9;

/**
 * 地球儀を組む。React には触れず、返した API 経由でのみ操作する
 * (毎フレームの再レンダーを避けるため、状態は全てクロージャに閉じ込める)。
 */
function createGlobe(T: ThreeModule, opts: GlobeOptions): GlobeApi {
  const { canvas, host, homeLabel, destLabel, onFirstFrame, onFail } = opts;

  const renderer = new T.WebGLRenderer({ canvas, antialias: true, alpha: true, powerPreference: "low-power" });
  let dpr = Math.min(window.devicePixelRatio || 1, 2);
  renderer.setPixelRatio(dpr);

  const scene = new T.Scene();
  const camera = new T.PerspectiveCamera(36, 1, 0.1, 20);
  camera.position.set(0, 0, CAMERA_DIST);
  camera.lookAt(0, 0, 0);

  const geometries: THREE.BufferGeometry[] = [];
  const materials: THREE.Material[] = [];
  const keep = <G extends THREE.BufferGeometry, M extends THREE.Material>(g: G, m: M) => {
    geometries.push(g);
    materials.push(m);
    return [g, m] as const;
  };

  // --- 球の縁(カメラから見た輪郭円)。地球と一緒には回さない -------------------------
  {
    const z = 1 / CAMERA_DIST;
    const r = Math.sqrt(1 - z * z) + 0.002;
    const pts: number[] = [];
    for (let i = 0; i < 160; i++) {
      const a = (i / 160) * Math.PI * 2;
      pts.push(Math.cos(a) * r, Math.sin(a) * r, z);
    }
    const [g, m] = keep(new T.BufferGeometry(), new T.LineBasicMaterial({ color: 0xc9c3ba }));
    g.setAttribute("position", new T.Float32BufferAttribute(pts, 3));
    scene.add(new T.LineLoop(g, m));
  }

  const globe = new T.Group();
  scene.add(globe);

  // --- 球体(白)と経緯線 ----------------------------------------------------------------
  {
    const [g, m] = keep(new T.SphereGeometry(0.994, 64, 48), new T.MeshBasicMaterial({ color: 0xffffff }));
    globe.add(new T.Mesh(g, m));
  }
  {
    const pts: number[] = [];
    const push = (lat1: number, lon1: number, lat2: number, lon2: number) => {
      pts.push(...unit(lat1, lon1).map((v) => v * 1.0008), ...unit(lat2, lon2).map((v) => v * 1.0008));
    };
    for (let lon = -180; lon < 180; lon += 30) {
      for (let lat = -84; lat < 84; lat += 4) push(lat, lon, lat + 4, lon);
    }
    for (const lat of [-60, -30, 0, 30, 60]) {
      for (let lon = -180; lon < 180; lon += 4) push(lat, lon, lat, lon + 4);
    }
    const [g, m] = keep(
      new T.BufferGeometry(),
      new T.LineBasicMaterial({ color: 0xd6d3d1, transparent: true, opacity: 0.55 }),
    );
    g.setAttribute("position", new T.Float32BufferAttribute(pts, 3));
    globe.add(new T.LineSegments(g, m));
  }

  // --- 陸地の点描: 緯度ごとに経度の間隔を 1/cos で広げ、ほぼ等面積に並べる -----------
  {
    const step = 1.7;
    const pts: number[] = [];
    for (let lat = -82; lat <= 83; lat += step) {
      const n = Math.max(1, Math.round((360 * Math.cos(lat * RAD)) / step));
      for (let j = 0; j < n; j++) {
        const lon = -180 + ((j + 0.5) * 360) / n;
        if (isLand(lon, lat)) pts.push(...unit(lat, lon));
      }
    }
    const [g, m] = keep(
      new T.BufferGeometry(),
      new T.PointsMaterial({ color: 0xa8a29e, size: 2.4, sizeAttenuation: false }),
    );
    g.setAttribute("position", new T.Float32BufferAttribute(pts, 3));
    globe.add(new T.Points(g, m));
  }

  // --- 地点の印(本拠地=アンバーの塗り、取引国=墨の塗り、対応地域=墨の枠)-----------
  const orient = (obj: THREE.Object3D, lat: number, lon: number, lift: number) => {
    const [x, y, z] = unit(lat, lon);
    obj.position.set(x * lift, y * lift, z * lift);
    obj.lookAt(x * 2, y * 2, z * 2); // 群はまだ回していないのでローカル=ワールド
  };

  const [squareGeo] = keep(new T.PlaneGeometry(0.036, 0.036), new T.MeshBasicMaterial());
  const [frameGeo] = keep(
    (() => {
      const g = new T.BufferGeometry();
      const h = 0.019;
      g.setAttribute("position", new T.Float32BufferAttribute([-h, -h, 0, h, -h, 0, h, h, 0, -h, h, 0], 3));
      return g;
    })(),
    new T.MeshBasicMaterial(),
  );

  {
    const [g, m] = keep(new T.PlaneGeometry(0.05, 0.05), new T.MeshBasicMaterial({ color: AMBER }));
    const home = new T.Mesh(g, m);
    orient(home, HOME.lat, HOME.lon, 1.006);
    globe.add(home);
  }
  const [haloGeo, haloMat] = keep(
    new T.RingGeometry(0.05, 0.057, 48),
    new T.MeshBasicMaterial({ color: AMBER, transparent: true, opacity: 0.5, depthWrite: false }),
  );
  const halo = new T.Mesh(haloGeo, haloMat);
  orient(halo, HOME.lat, HOME.lon, 1.007);
  globe.add(halo);

  const markers = ROUTES.map((r) => {
    if (r.kind === "取引国") {
      const mat = new T.MeshBasicMaterial({ color: INK, side: T.DoubleSide });
      materials.push(mat);
      const mesh = new T.Mesh(squareGeo, mat);
      orient(mesh, r.lat, r.lon, 1.006);
      globe.add(mesh);
      return { obj: mesh as THREE.Object3D, mat: mat as THREE.MeshBasicMaterial | THREE.LineBasicMaterial };
    }
    const mat = new T.LineBasicMaterial({ color: INK });
    materials.push(mat);
    const loop = new T.LineLoop(frameGeo, mat);
    orient(loop, r.lat, r.lon, 1.006);
    globe.add(loop);
    return { obj: loop as THREE.Object3D, mat: mat as THREE.MeshBasicMaterial | THREE.LineBasicMaterial };
  });

  // --- 弧線(大圏の slerp を持ち上げる)と、その上を進む光点 -----------------------------
  const homeVec = new T.Vector3(...unit(HOME.lat, HOME.lon));
  const arcs = ROUTES.map((r) => {
    const b = new T.Vector3(...unit(r.lat, r.lon));
    const omega = homeVec.angleTo(b);
    const h = 0.05 + 0.22 * (omega / Math.PI);
    const pts: THREE.Vector3[] = [];
    for (let i = 0; i <= ARC_SEGMENTS; i++) {
      const t = i / ARC_SEGMENTS;
      const p = homeVec
        .clone()
        .multiplyScalar(Math.sin((1 - t) * omega))
        .add(b.clone().multiplyScalar(Math.sin(t * omega)))
        .divideScalar(Math.sin(omega))
        .multiplyScalar(1.004 + h * Math.sin(Math.PI * t));
      pts.push(p);
    }
    const geo = new T.BufferGeometry().setFromPoints(pts);
    const mat = new T.LineBasicMaterial({ color: 0x78716c, transparent: true, opacity: 0.6 });
    geometries.push(geo);
    materials.push(mat);
    const line = new T.Line(geo, mat);
    globe.add(line);
    // 向きの目標。a+b を正規化した中点なら2点を同時に見られるが、真正面から見ると大圏が
    // 画面上で一直線に潰れる。弧の面の法線(北向きにそろえる)の反対側へ少し倒し、
    // 南から見上げる角度にすると、持ち上げた弧が山なりに見える。
    const mid = homeVec.clone().add(b).normalize();
    const normal = homeVec.clone().cross(b).normalize();
    if (normal.y < 0) normal.negate();
    const tilt = 24 * RAD;
    const aim = mid.multiplyScalar(Math.cos(tilt)).addScaledVector(normal, -Math.sin(tilt)).normalize();
    return { line, geo, mat, pts, aim, drawn: -1 };
  });

  const pulsePos = new Float32Array(ROUTES.length * 3);
  const pulseCol = new Float32Array(ROUTES.length * 3);
  const [pulseGeo, pulseMat] = keep(
    new T.BufferGeometry(),
    new T.PointsMaterial({ size: 4.5, sizeAttenuation: false, vertexColors: true }),
  );
  pulseGeo.setAttribute("position", new T.BufferAttribute(pulsePos, 3));
  pulseGeo.setAttribute("color", new T.BufferAttribute(pulseCol, 3));
  const pulses = new T.Points(pulseGeo, pulseMat);
  globe.add(pulses);

  const amber = new T.Color(AMBER);
  const muted = new T.Color(0x78716c);

  // --- 状態 ------------------------------------------------------------------------------
  const motionQuery = window.matchMedia("(prefers-reduced-motion: reduce)");
  let motionOK = !motionQuery.matches;
  let current = opts.index;
  let raf = 0;
  let running = false;
  let firstFrame = true;
  let last = 0;
  let clock = 0;
  let yaw = 0;
  let pitch = 0;
  let yawTarget = 0;
  let pitchTarget = 0;
  let dragging = false;
  let pointerId: number | null = null;
  let lastX = 0;
  let lastY = 0;
  let frames = 0;
  let elapsed = 0;
  let degraded = false;
  let width = 1;
  let height = 1;
  /** 弧ごとの描き出し開始時刻(clock 基準)。null は描き切った状態 */
  const arcStart: (number | null)[] = ROUTES.map(() => null);
  let revealed = false;

  const aimAt = (index: number) => {
    const m = arcs[index].aim;
    const lat = Math.asin(m.y);
    const lon = Math.atan2(m.x, m.z);
    let y = -lon;
    while (y - yaw > Math.PI) y -= Math.PI * 2;
    while (y - yaw < -Math.PI) y += Math.PI * 2;
    yawTarget = y;
    pitchTarget = Math.max(-1.1, Math.min(1.1, lat));
  };

  const paintSelection = () => {
    arcs.forEach((a, i) => {
      const on = i === current;
      a.mat.color.set(on ? AMBER : 0x78716c);
      a.mat.opacity = on ? 1 : 0.4;
      a.line.renderOrder = on ? 2 : 1;
      markers[i].mat.color.set(on ? AMBER : INK);
      markers[i].obj.scale.setScalar(on ? 1.5 : 1);
      const c = on ? amber : muted;
      pulseCol[i * 3] = c.r;
      pulseCol[i * 3 + 1] = c.g;
      pulseCol[i * 3 + 2] = c.b;
    });
    pulseGeo.attributes.color.needsUpdate = true;
  };

  const setDrawn = (i: number, count: number) => {
    const a = arcs[i];
    if (a.drawn === count) return;
    a.drawn = count;
    a.geo.setDrawRange(0, count);
  };

  const tmp = new T.Vector3();
  const place = (el: HTMLElement, local: THREE.Vector3) => {
    tmp.copy(local).applyMatrix4(globe.matrixWorld);
    // 手前の半球(z > 1/d)にあるときだけ見せる。縁ぎりぎりは読めないので少し余裕を取る
    const front = tmp.z > 1 / CAMERA_DIST + 0.08;
    tmp.project(camera);
    const x = ((tmp.x + 1) / 2) * width;
    const y = ((1 - tmp.y) / 2) * height;
    el.style.transform = `translate(${x.toFixed(1)}px, ${y.toFixed(1)}px) translate(-50%, -165%)`;
    el.style.opacity = front ? "1" : "0";
  };

  const destLocal = ROUTES.map((r) => new T.Vector3(...unit(r.lat, r.lon)).multiplyScalar(1.006));
  const homeLocal = homeVec.clone().multiplyScalar(1.006);

  const renderOnce = () => {
    globe.rotation.set(pitch, yaw, 0);
    globe.updateMatrixWorld();
    renderer.render(scene, camera);
    place(homeLabel, homeLocal);
    place(destLabel, destLocal[current]);
    if (firstFrame) {
      firstFrame = false;
      onFirstFrame();
    }
  };

  /** ループを使わずに最終状態を置く(モーション低減・画面外・停止中の共通経路) */
  const settle = () => {
    yaw = yawTarget;
    pitch = pitchTarget;
    arcs.forEach((_, i) => {
      arcStart[i] = null;
      setDrawn(i, ARC_SEGMENTS + 1);
    });
    pulses.visible = false;
    halo.scale.setScalar(1.35);
    haloMat.opacity = 0.45;
    renderOnce();
  };

  const resize = () => {
    const rect = host.getBoundingClientRect();
    width = Math.max(1, Math.round(rect.width));
    height = Math.max(1, Math.round(rect.height));
    const aspect = width / height;
    camera.aspect = aspect;
    // 弧の頂点(半径 1.3 程度)まで収まる縦画角を、縦長・横長どちらでも逆算する
    const fit = 1.32;
    const v = 2 * Math.atan(fit / CAMERA_DIST);
    const needed = 2 * Math.atan(fit / (CAMERA_DIST * Math.min(1, aspect)));
    camera.fov = (Math.max(v, needed) * 180) / Math.PI;
    camera.updateProjectionMatrix();
    renderer.setSize(width, height, false);
  };

  const frame = (now: number) => {
    raf = requestAnimationFrame(frame);
    const dt = Math.min((now - last) / 1000, 0.06);
    last = now;
    clock += dt;

    const ease = 1 - Math.pow(0.004, dt);
    yaw += (yawTarget - yaw) * ease;
    pitch += (pitchTarget - pitch) * ease;

    // 初回に画面へ入ったとき、近い経路から順に描き出す
    if (!revealed) {
      revealed = true;
      const order = ROUTES.map((r, i) => [r.km, i] as const).sort((a, b) => a[0] - b[0]);
      order.forEach(([, i], k) => {
        arcStart[i] = clock + k * 0.14;
      });
    }

    arcs.forEach((a, i) => {
      const s = arcStart[i];
      let p = 1;
      if (s !== null) {
        p = Math.min(1, Math.max(0, (clock - s) / 0.9));
        p = 1 - Math.pow(1 - p, 3);
        if (p >= 1) arcStart[i] = null;
      }
      setDrawn(i, Math.round(p * ARC_SEGMENTS) + 1);
      // 光点は描き切った弧の上だけを進む。描き出し中は本拠地に待たせる
      const src = p >= 1 ? a.pts[Math.floor(((clock * 0.28 + i * 0.37) % 1) * ARC_SEGMENTS)] : a.pts[0];
      pulsePos[i * 3] = src.x;
      pulsePos[i * 3 + 1] = src.y;
      pulsePos[i * 3 + 2] = src.z;
    });
    pulseGeo.attributes.position.needsUpdate = true;
    pulses.visible = true;

    const beat = (clock % 2.4) / 2.4;
    halo.scale.setScalar(1 + beat * 1.8);
    haloMat.opacity = 0.7 * (1 - beat);

    renderOnce();

    // 低スペック対策: 立ち上がり10フレームを捨て、続く48フレームの平均で二段階に落とす
    frames += 1;
    if (frames > 10 && frames <= 58) {
      elapsed += dt * 1000;
      if (frames === 58 && elapsed / 48 > 32) {
        if (degraded) {
          onFail();
          stop();
        } else {
          degraded = true;
          dpr = Math.max(1, dpr * 0.6);
          renderer.setPixelRatio(dpr);
          resize();
          frames = 0;
          elapsed = 0;
        }
      }
    }
  };

  const start = () => {
    if (raf || !motionOK) return;
    last = performance.now();
    raf = requestAnimationFrame(frame);
  };

  function stop() {
    if (!raf) return;
    cancelAnimationFrame(raf);
    raf = 0;
  }

  const select = (index: number) => {
    if (index < 0 || index >= ROUTES.length) return;
    current = index;
    aimAt(index);
    paintSelection();
    if (raf) {
      // 選んだ経路だけ描き直して目を引く(初回の描き出しが終わっていれば)
      if (revealed) arcStart[index] = clock;
    } else {
      settle();
    }
  };

  const nudge = (dYaw: number, dPitch: number) => {
    yawTarget += dYaw;
    pitchTarget = Math.max(-1.1, Math.min(1.1, pitchTarget + dPitch));
    if (!raf) settle();
  };

  const applyMotion = () => {
    if (motionOK) start();
    else {
      stop();
      settle();
    }
  };

  const onMotionChange = () => {
    motionOK = !motionQuery.matches;
    if (running) applyMotion();
  };
  motionQuery.addEventListener("change", onMotionChange);

  // --- ポインタ(ユーザー起因の回転はモーション低減時も許可し、1回ずつ描く)--------------
  const onPointerDown = (e: PointerEvent) => {
    if (pointerId !== null) return;
    pointerId = e.pointerId;
    dragging = true;
    lastX = e.clientX;
    lastY = e.clientY;
    canvas.setPointerCapture(e.pointerId);
  };
  const onPointerMove = (e: PointerEvent) => {
    if (!dragging || e.pointerId !== pointerId) return;
    const k = 2.6 / Math.max(width, 1);
    yawTarget += (e.clientX - lastX) * k;
    pitchTarget = Math.max(-1.1, Math.min(1.1, pitchTarget + (e.clientY - lastY) * k));
    // ドラッグ中は追従を待たずに指へ貼り付ける
    yaw = yawTarget;
    pitch = pitchTarget;
    lastX = e.clientX;
    lastY = e.clientY;
    if (!raf) renderOnce();
  };
  const onPointerUp = (e: PointerEvent) => {
    if (e.pointerId !== pointerId) return;
    dragging = false;
    pointerId = null;
    if (canvas.hasPointerCapture(e.pointerId)) canvas.releasePointerCapture(e.pointerId);
  };
  canvas.addEventListener("pointerdown", onPointerDown);
  canvas.addEventListener("pointermove", onPointerMove);
  canvas.addEventListener("pointerup", onPointerUp);
  canvas.addEventListener("pointercancel", onPointerUp);

  const onContextLost = (e: Event) => {
    e.preventDefault();
    stop();
    onFail();
  };
  canvas.addEventListener("webglcontextlost", onContextLost);

  // canvas の寸法を書き換えると中身が消えるので、止まっていても必ず1枚描き直す
  const ro = new ResizeObserver(() => {
    resize();
    renderOnce();
  });
  ro.observe(host);

  resize();
  aimAt(current);
  yaw = yawTarget;
  pitch = pitchTarget;
  paintSelection();
  settle();
  if (motionOK) {
    // 動かせる環境では、弧は画面に入ってから描き出す(最初の1枚は弧なし)
    arcs.forEach((_, i) => setDrawn(i, 0));
    renderOnce();
  }

  return {
    select,
    nudge,
    recenter: () => {
      aimAt(current);
      if (!raf) settle();
    },
    setRunning: (next: boolean) => {
      running = next;
      if (next) applyMotion();
      else stop();
    },
    dispose: () => {
      stop();
      ro.disconnect();
      motionQuery.removeEventListener("change", onMotionChange);
      canvas.removeEventListener("pointerdown", onPointerDown);
      canvas.removeEventListener("pointermove", onPointerMove);
      canvas.removeEventListener("pointerup", onPointerUp);
      canvas.removeEventListener("pointercancel", onPointerUp);
      canvas.removeEventListener("webglcontextlost", onContextLost);
      for (const g of geometries) g.dispose();
      for (const m of materials) m.dispose();
      renderer.dispose();
      renderer.forceContextLoss();
    },
  };
}

/* ------------------------------------------------------------------ 本体 */

const STEP = 15 * RAD;

export default function GlobeReachArcs() {
  const uid = useId().replace(/:/g, "");
  const headingId = `globe-reach-heading-${uid}`;
  const hintId = `globe-reach-hint-${uid}`;
  const stageRef = useRef<HTMLDivElement>(null);
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const homeLabelRef = useRef<HTMLSpanElement>(null);
  const destLabelRef = useRef<HTMLSpanElement>(null);
  const itemRefs = useRef<(HTMLButtonElement | null)[]>([]);
  const apiRef = useRef<GlobeApi | null>(null);
  const [live, setLive] = useState(false);
  const [inView, setInView] = useState(false);
  const [pageVisible, setPageVisible] = useState(true);
  const [ready, setReady] = useState(false);
  const [failed, setFailed] = useState(false);
  const [active, setActive] = useState(0);

  // 起動条件: 画面内 かつ 実寸表示。一覧のカードは親に scale が掛かっているので
  // rect.width(変形後)÷ offsetWidth(レイアウト幅)が 1 未満になり、3Dライブラリを読み込まない。
  useEffect(() => {
    const el = stageRef.current;
    if (!el) return;
    const rect = el.getBoundingClientRect();
    const fullSize = el.offsetWidth > 0 ? rect.width / el.offsetWidth > 0.75 : true;
    const io = new IntersectionObserver(
      ([entry]) => {
        setInView(entry.isIntersecting);
        if (entry.isIntersecting && fullSize) setLive(true);
      },
      { rootMargin: "160px" },
    );
    io.observe(el);
    const onVisibility = () => setPageVisible(document.visibilityState === "visible");
    document.addEventListener("visibilitychange", onVisibility);
    return () => {
      io.disconnect();
      document.removeEventListener("visibilitychange", onVisibility);
    };
  }, []);

  useEffect(() => {
    if (!live) return;
    const canvas = canvasRef.current;
    const host = stageRef.current;
    const homeLabel = homeLabelRef.current;
    const destLabel = destLabelRef.current;
    if (!canvas || !host || !homeLabel || !destLabel) return;

    let cancelled = false;
    const boot = async () => {
      // 非対応なら重いライブラリを取りに行かず、平面図のまま確定させる
      const probe = document.createElement("canvas");
      const gl = probe.getContext("webgl2") ?? (probe.getContext("webgl") as WebGLRenderingContext | null);
      if (!gl) {
        setFailed(true);
        return;
      }
      gl.getExtension("WEBGL_lose_context")?.loseContext();

      const T = await import("three");
      if (cancelled) return;
      apiRef.current = createGlobe(T, {
        canvas,
        host,
        homeLabel,
        destLabel,
        index: 0,
        onFirstFrame: () => setReady(true),
        onFail: () => setFailed(true),
      });
    };
    boot().catch(() => {
      if (!cancelled) setFailed(true);
    });

    return () => {
      cancelled = true;
      apiRef.current?.dispose();
      apiRef.current = null;
    };
  }, [live]);

  useEffect(() => {
    apiRef.current?.setRunning(inView && pageVisible && !failed);
  }, [inView, pageVisible, failed, ready]);

  useEffect(() => {
    apiRef.current?.select(active);
  }, [active, ready]);

  const choose = (i: number, focus: boolean) => {
    const next = (i + ROUTES.length) % ROUTES.length;
    setActive(next);
    if (focus) itemRefs.current[next]?.focus();
  };

  const onListKey = (e: KeyboardEvent<HTMLButtonElement>, i: number) => {
    const map: Record<string, number> = {
      ArrowDown: i + 1,
      ArrowRight: i + 1,
      ArrowUp: i - 1,
      ArrowLeft: i - 1,
      Home: 0,
      End: ROUTES.length - 1,
    };
    if (!(e.key in map)) return;
    e.preventDefault();
    choose(map[e.key], true);
  };

  const onStageKey = (e: KeyboardEvent<HTMLDivElement>) => {
    const api = apiRef.current;
    if (!api) return;
    const moves: Record<string, [number, number]> = {
      ArrowLeft: [-STEP, 0],
      ArrowRight: [STEP, 0],
      ArrowUp: [0, -STEP],
      ArrowDown: [0, STEP],
    };
    if (e.key in moves) {
      e.preventDefault();
      api.nudge(...moves[e.key]);
    } else if (e.key === "Home" || e.key === "Enter") {
      e.preventDefault();
      api.recenter();
    }
  };

  const route = ROUTES[active];
  const show3d = ready && !failed;

  return (
    <section aria-labelledby={headingId} className="@container w-full max-w-5xl bg-white text-gray-900">
      {/* 冒頭: 見出しと数の帯。小見出しの英字ラベルは置かず、数字の台帳で節を開く */}
      <div className="grid gap-6 @2xl:grid-cols-[minmax(0,1fr)_auto] @2xl:items-end">
        <div>
          <h2
            id={headingId}
            className="text-[clamp(1.45rem,3.4vw,2.1rem)] leading-[1.35] font-bold tracking-tight"
          >
            東京から、
            <span className="whitespace-nowrap">7つの国と地域へ</span>
          </h2>
          <p className="mt-4 max-w-xl text-[14px] leading-[1.9] font-medium text-gray-600">
            取引のある国と、制作・保守を受け付けている地域を地球儀に置きました。一覧で国を選ぶと、東京からの経路が正面に来るよう地球が向きを変えます。
          </p>
          <p className="mt-3 inline-block border border-gray-300 px-2 py-0.5 text-[11.5px] font-bold text-gray-600">
            掲載の国・地域と業務内容は、構成を示すためのサンプルです(実在の取引実績ではありません)
          </p>
        </div>
        <dl className="grid grid-cols-3 border-y border-gray-200 @2xl:w-[19rem]">
          {[
            { term: "取引国", value: String(TRADE_COUNT), unit: "か国" },
            { term: "対応地域", value: String(ROUTES.length - TRADE_COUNT), unit: "地域" },
            { term: "最長の経路", value: FAR_KM, unit: "km" },
          ].map((s, i) => (
            <div key={s.term} className={`py-3 ${i > 0 ? "border-l border-gray-200 pl-3" : ""}`}>
              <dt className="text-[11px] font-bold tracking-wide whitespace-nowrap text-gray-500">{s.term}</dt>
              <dd className="mt-1 flex items-baseline gap-1 whitespace-nowrap">
                <span className={`${montserrat.className} text-[1.35rem] leading-none font-semibold tabular-nums`}>
                  {s.value}
                </span>
                <span className="text-[11px] text-gray-500">{s.unit}</span>
              </dd>
            </div>
          ))}
        </dl>
      </div>

      <div className="mt-8 grid gap-8 border-t border-gray-200 pt-8 @2xl:grid-cols-[minmax(0,1fr)_17rem] @2xl:gap-9">
        <figure className="m-0 min-w-0">
          <div
            ref={stageRef}
            tabIndex={0}
            role="group"
            aria-roledescription="地球儀"
            aria-label={`地球儀。東京と${route.city}を結ぶ経路を表示中`}
            aria-describedby={hintId}
            onKeyDown={onStageKey}
            className="relative mx-auto aspect-square w-full max-w-[30rem] overflow-hidden border border-gray-200 bg-[radial-gradient(70%_70%_at_50%_45%,rgba(217,119,6,0.07)_0%,rgba(255,255,255,0)_70%),linear-gradient(180deg,#ffffff_0%,#f8f6f2_100%)] focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
          >
            {/* 平面図: 3Dが立ち上がるまで・縮小プレビュー・WebGL非対応のときの表示。同じ地点と弧を持つ */}
            <div
              aria-hidden="true"
              className={`absolute inset-0 flex flex-col justify-center px-3 transition-opacity duration-700 motion-reduce:transition-none ${
                show3d ? "opacity-0" : "opacity-100"
              }`}
            >
              <svg viewBox={`0 0 ${MAP_W} ${MAP_H}`} className="block h-auto w-full">
                <path d={FLAT_DOTS} className="fill-stone-300" />
                {ROUTES.map((r, i) => (
                  <path
                    key={r.id}
                    d={flatArc(r)}
                    fill="none"
                    className={i === active ? "stroke-amber-600" : "stroke-stone-400"}
                    strokeWidth={i === active ? 1.1 : 0.6}
                  />
                ))}
                {ROUTES.map((r, i) =>
                  r.kind === "取引国" ? (
                    <rect
                      key={r.id}
                      x={mapX(r.lon) - 2}
                      y={mapY(r.lat) - 2}
                      width="4"
                      height="4"
                      className={i === active ? "fill-amber-600" : "fill-stone-800"}
                    />
                  ) : (
                    <rect
                      key={r.id}
                      x={mapX(r.lon) - 2}
                      y={mapY(r.lat) - 2}
                      width="4"
                      height="4"
                      className={`fill-white ${i === active ? "stroke-amber-600" : "stroke-stone-800"}`}
                      strokeWidth="0.8"
                    />
                  ),
                )}
                <rect x={mapX(HOME.lon) - 2.8} y={mapY(HOME.lat) - 2.8} width="5.6" height="5.6" className="fill-amber-600" />
                <text
                  x={mapX(HOME.lon)}
                  y={mapY(HOME.lat) + 11}
                  textAnchor="middle"
                  className="hidden fill-stone-900 text-[7px] font-bold @md:block"
                >
                  {HOME.city}
                </text>
                <text
                  x={mapX(route.lon)}
                  y={mapY(route.lat) + 11}
                  textAnchor="middle"
                  className="hidden fill-amber-700 text-[7px] font-bold @md:block"
                >
                  {route.city}
                </text>
              </svg>
              {failed && (
                <p className="mt-4 text-center text-[11px] leading-[1.7] text-gray-500">
                  この環境では3D表示が使えないため、平面図で表示しています
                </p>
              )}
            </div>

            <canvas
              ref={canvasRef}
              aria-hidden="true"
              className={`absolute inset-0 block size-full cursor-grab touch-pan-y transition-opacity duration-700 active:cursor-grabbing motion-reduce:transition-none ${
                show3d ? "opacity-100" : "opacity-0"
              }`}
            />

            {/* 地名ラベル(位置は3D側が transform で書き換える) */}
            <div
              aria-hidden="true"
              className={`pointer-events-none absolute inset-0 transition-opacity duration-500 motion-reduce:transition-none ${
                show3d ? "opacity-100" : "opacity-0"
              }`}
            >
              <span
                ref={homeLabelRef}
                className="absolute top-0 left-0 border border-gray-200 bg-white/90 px-1.5 py-0.5 text-[11px] leading-none font-bold whitespace-nowrap text-gray-900 opacity-0"
              >
                {HOME.city}
              </span>
              <span
                ref={destLabelRef}
                className="absolute top-0 left-0 border border-amber-600 bg-white/90 px-1.5 py-0.5 text-[11px] leading-none font-bold whitespace-nowrap text-amber-700 opacity-0"
              >
                {route.city}
              </span>
            </div>

            <p
              id={hintId}
              className={`pointer-events-none absolute top-2.5 left-3 text-[10.5px] leading-[1.6] text-gray-400 transition-opacity duration-500 motion-reduce:transition-none ${
                show3d ? "opacity-100" : "opacity-0"
              }`}
            >
              ドラッグ、または矢印キーで回転(Enterで経路に戻る)
            </p>
          </div>

          <figcaption className="mx-auto mt-4 max-w-[30rem]">
            <p aria-live="polite" className="text-[13px] leading-[1.8] font-bold text-gray-900">
              {HOME.city}と{route.city}({route.country})
              <span className="ml-2 font-medium whitespace-nowrap text-gray-600">
                約<span className={`${montserrat.className} font-semibold tabular-nums`}>{route.kmLabel}</span>km・{route.diffLabel}
              </span>
            </p>
            <ul className="mt-3 flex flex-wrap gap-x-5 gap-y-1.5 border-t border-gray-100 pt-3 text-[11.5px] text-gray-600">
              <li className="flex items-center gap-2">
                <span aria-hidden="true" className="size-2.5 flex-none bg-amber-600" />
                本拠地
              </li>
              <li className="flex items-center gap-2">
                <span aria-hidden="true" className="size-2 flex-none bg-stone-800" />
                取引国
              </li>
              <li className="flex items-center gap-2">
                <span aria-hidden="true" className="size-2 flex-none border border-stone-800 bg-white" />
                対応地域
              </li>
              <li className="flex items-center gap-2">
                <span aria-hidden="true" className="h-px w-4 flex-none bg-amber-600" />
                選択中の経路
              </li>
            </ul>
          </figcaption>
        </figure>

        {/* 一覧(ここが情報の正。地球儀は装飾として従属する) */}
        <div className="min-w-0">
          <p className="text-[12px] font-bold tracking-widest text-gray-500">国・地域を選ぶ</p>
          <ol className="mt-3 border-t border-gray-200">
            {ROUTES.map((r, i) => {
              const on = i === active;
              return (
                <li key={r.id} className="border-b border-gray-200">
                  <button
                    ref={(el) => {
                      itemRefs.current[i] = el;
                    }}
                    type="button"
                    aria-pressed={on}
                    onClick={() => choose(i, false)}
                    onKeyDown={(e) => onListKey(e, i)}
                    className="group relative block w-full py-3 pr-1 pl-4 text-left focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-amber-600"
                  >
                    <span
                      aria-hidden="true"
                      className={`absolute top-3 bottom-3 left-0 w-[3px] origin-top bg-amber-600 transition-transform duration-300 ease-out motion-reduce:transition-none ${
                        on ? "scale-y-100" : "scale-y-0 group-hover:scale-y-50"
                      }`}
                    />
                    <span className="flex items-baseline justify-between gap-3">
                      <span className="min-w-0">
                        <span
                          className={`text-[14px] font-bold transition-colors motion-reduce:transition-none ${
                            on ? "text-gray-900" : "text-gray-600 group-hover:text-gray-900"
                          }`}
                        >
                          {r.country}
                        </span>
                        <span className="ml-2 text-[11.5px] text-gray-400">{r.city}</span>
                      </span>
                      <span className="flex flex-none items-center gap-1.5 text-[11px] font-bold text-gray-500">
                        <span
                          aria-hidden="true"
                          className={`size-2 flex-none ${
                            r.kind === "取引国" ? "bg-stone-800" : "border border-stone-800 bg-white"
                          }`}
                        />
                        {r.kind}
                      </span>
                    </span>
                    <span className="mt-1 block text-[12px] leading-[1.75] text-gray-500">{r.note}</span>
                    <span className="mt-1 block text-[11px] text-gray-400 tabular-nums">
                      約<span className={montserrat.className}>{r.kmLabel}</span>km・{r.diffLabel}
                    </span>
                  </button>
                </li>
              );
            })}
          </ol>
        </div>
      </div>

      <div className="mt-8 flex flex-col gap-4 border-t border-gray-200 pt-6 @2xl:flex-row @2xl:items-baseline @2xl:justify-between">
        <p className="max-w-xl text-[12px] leading-[1.9] text-gray-500">
          国・地域と業務内容はサンプルです。自社の実績に差し替えてお使いください。距離は都市間の大圏距離を100km単位で丸めた概算、時差は標準時での差です。大陸の輪郭は簡略化した図で、国境は描いていません。
        </p>
        <a
          href="#contact"
          className="group inline-flex flex-none items-center text-[13px] font-semibold text-gray-900 focus-visible:outline-2 focus-visible:outline-offset-4 focus-visible:outline-amber-600"
        >
          <span className="relative">
            海外向けサイトの相談をする
            <span
              aria-hidden="true"
              className="absolute -bottom-1 left-0 h-px w-full origin-left scale-x-100 bg-gray-900 transition-transform duration-300 ease-out group-hover:scale-x-0 motion-reduce:transition-none"
            />
            <span
              aria-hidden="true"
              className="absolute -bottom-1 left-0 h-px w-full origin-right scale-x-0 bg-amber-600 transition-transform delay-150 duration-300 ease-out group-hover:origin-left group-hover:scale-x-100 motion-reduce:transition-none"
            />
          </span>
        </a>
      </div>
    </section>
  );
}

shadcn CLI でプロジェクトに追加

npx shadcn@latest add https://designs.first-ch.com/r/globe-reach-arcs.json