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