BLK-84Blocks

3D Globe with Reach Arcs (trade partners and service regions)

A rotatable 3D globe for explaining international reach. Great-circle arcs run from the home base (Tokyo) to seven partner and service cities; choosing a country in the list on the right turns the globe so the midpoint of that route faces the viewer. Land is drawn without map files or images: coarse hand-written longitude/latitude polygons are sampled with a dot grid whose longitude spacing widens by 1/cos(latitude), and a point-in-polygon test keeps the land dots, so a simplified outline still reads as Earth. Each arc is a slerp between two unit vectors, lifted in proportion to the central angle so longer routes rise higher, and it draws itself in by growing setDrawRange. Trade partners are filled squares, service regions are outlined squares, and the home base is an amber square with an expanding ring; only the selected route turns amber and carries a travelling light. City labels are DOM elements positioned by projecting points to screen space and hidden when they rotate to the far side. Besides dragging, the globe takes focus and rotates in 15-degree steps with the arrow keys (Enter recentres on the route), and the list supports arrow keys, Home and End, so everything works from the keyboard alone. Great-circle distance (rounded to 100 km) and standard-time difference are written out under the globe and in the list, announced through aria-live. three is loaded with a dynamic import inside useEffect only when the block is on screen at full size; scaled-down gallery cards, missing WebGL, lost contexts and slow devices get the same cities and arcs on an equirectangular SVG map instead. With prefers-reduced-motion the loop never runs and one final frame is drawn after each selection or key press. No rounded corners, trailing arrow links or small English kickers are used; the section opens with a figures ledger and hairline rules instead.

Added:
2026-09-29
Dependencies:
three
tags
#3d #globe #map #data-visualization #international #webgl #interactive #keyboard #no-image #dynamic-import

Preview

東京から、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>
  );
}

Add to your project via shadcn CLI

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