コンテンツにスキップ
JP

React と TypeScript で ISS Tracker プラグインを作成する

更新日

Re:Earth Visualizer の React・TypeScript プラグインテンプレートから始めます。ISS Tracker ウィジェットを設定し、ISS の現在位置データと接続して、Visualizer のカメラを操作します。最後に、プラグインをパッケージ化してプロジェクトにインストールします。各ステップで画面の結果を確認しながら進められます。

完成した ISS Tracker プラグインには、ISS の現在の経度、緯度、高度が表示されます。Update は最新の位置を取得し、Jump は表示中の位置へカメラを移動します。Follow は5秒ごとに ISS の位置を取得し、カメラを移動します。完成したプラグインを ZIP ファイルにパッケージ化し、Visualizer プロジェクトにウィジェットとしてインストールします。

作業中のプロジェクトは、完成版の ISS Tracker ソースコードと比較できます。

  • Node.js 20.11.0 以降
  • Yarn 4.5.1
  • Git
  • Visual Studio Code などのコードエディター
  • React と TypeScript の基本的な知識
  • 開発用プラグインを動かすローカルの Re:Earth Visualizer フロントエンド

ステップ1・開発環境を準備する

Section titled “ステップ1・開発環境を準備する”

プラグインテンプレートをインストールする

Section titled “プラグインテンプレートをインストールする”

ターミナルを開き、次のコマンドを実行してください。<plugin-parent-directory> は、プロジェクトを作成するディレクトリに置き換えます。

Terminal window
cd <plugin-parent-directory>
git clone https://github.com/reearth-plugins/reearth-visualizer-plugin-shadcn-template.git
cd reearth-visualizer-plugin-shadcn-template
corepack enable
yarn install

テンプレートは、package.json で指定されたバージョンの Yarn を使います。Corepack を有効にすると、互換性のないグローバル版ではなく、指定されたバージョンを使えます。

Visualizer プラグインテンプレートを複製して Visual Studio Code で開いた画面

ローカルの Visualizer フロントエンドを設定する

Section titled “ローカルの Visualizer フロントエンドを設定する”

ローカル開発では、http://localhost:5005 のプレビューサーバーからプラグインを読み込みます。

  1. ローカルの Re:Earth Visualizer リポジトリで web/.env を開きます
  2. 次の環境変数を追加します
REEARTH_WEB_DEV_PLUGIN_URLS='["http://localhost:5005"]'
  1. ファイルを保存して、ローカルの Visualizer 開発サーバーを再起動します

プラグインマニフェストを設定する

Section titled “プラグインマニフェストを設定する”

プラグインプロジェクトで public/reearth.yml を開き、ファイル全体を次の内容に置き換えてください。

id: iss-tracker-plugin
name: ISS Tracker Plugin
version: 1.0.0
extensions:
- id: iss_tracker
type: widget
name: ISS Tracker

拡張 ID、拡張ディレクトリ、エントリーファイル名、ビルドスクリプト名には、すべて iss_tracker を使います。

プラグインプロジェクトで package.json を開き、既存の scripts オブジェクトだけを次の内容に置き換えてください。

"scripts": {
"dev:iss_tracker:main": "cross-env EXTENSION_NAME=iss_tracker UI_NAME=main vite -c configs/ui.ts",
"build:iss_tracker:main": "cross-env EXTENSION_NAME=iss_tracker UI_NAME=main vite build -c configs/ui.ts",
"build:iss_tracker:extension": "cross-env EXTENSION_NAME=iss_tracker vite build -c configs/extension.ts",
"build:iss_tracker": "run-s build:iss_tracker:main build:iss_tracker:extension",
"build": "run-s build:iss_tracker zip",
"preview": "vite preview --port 5005 --strict-port",
"zip": "node ./scripts/zip.mjs",
"manage": "node ./scripts/manage.mjs",
"type": "tsc -b",
"lint": "eslint .",
"fix": "eslint --fix .",
"format": "prettier --write .",
"dev-build": "concurrently 'yarn dev:iss_tracker:main' 'yarn build:iss_tracker:main --watch' 'yarn build:iss_tracker:extension --watch' 'vite preview --port 5005 --strict-port'"
}

package.json のほかの部分は置き換えないでください。

拡張ファイルの名前を変更する

Section titled “拡張ファイルの名前を変更する”

プラグインプロジェクトのルートで、テンプレートの拡張ディレクトリとエントリーファイルの名前を変更します。

Terminal window
mv src/extensions/demo src/extensions/iss_tracker
mv src/extensions/iss_tracker/demo.ts src/extensions/iss_tracker/iss_tracker.ts

src/extensions/iss_tracker/iss_tracker.ts を開き、HTML の import を次のように変更してください。

import html from "@distui/iss_tracker/main/index.html?raw";

関連するソースファイルが次の構成になっていることを確認してください。

src/extensions/iss_tracker/
├── iss_tracker.ts
└── main/
├── App.tsx
├── app.css
├── hooks.ts
├── index.html
└── main.tsx

名前を変更した iss_tracker 拡張ディレクトリとエントリーファイル

開発用プラグインを起動してインストールする

Section titled “開発用プラグインを起動してインストールする”

必要な出力を作るためにプラグインを一度ビルドしてから、開発プロセスを起動します。

Terminal window
yarn build
yarn dev-build
  1. http://localhost:3000/ を開き、ローカルの Visualizer でプロジェクトを選択します
  2. エディターのヘッダーで Install Dev Plugins と Reload Dev Plugin Extensions を確認します

ローカルの Visualizer エディターにある開発用プラグインの操作項目

  1. Install Dev Plugins を選択します。初めてプラグインをインストールするときと、reearth.yml を変更したあとに使います

Install Dev Plugins の操作項目

  1. 拡張または UI のコードを変更したら、Reload Dev Plugin Extensions を選択して最新のビルドを読み込みます

Reload Dev Plugin Extensions の操作項目

  1. 開発用プラグインがインストールされたことを確認します

Visualizer にインストールされた ISS Tracker 開発用プラグイン

  1. Widgets タブを開き、シーンに ISS Tracker を追加します

Widgets パネルに表示された ISS Tracker ウィジェット

変更前のテンプレートウィジェットには、サンプルの UI が表示されます。次のステップでこの UI を置き換えます。

Visualizer で動作する変更前のテンプレートウィジェット

ステップ2・ISS Tracker を作成する

Section titled “ステップ2・ISS Tracker を作成する”

完成したウィジェットには、次の3つの操作があります。

  • Update は ISS の現在位置を取得します
  • Jump は表示中の ISS の位置へ Visualizer のカメラを移動します
  • Follow は5秒ごとに位置を取得してカメラを移動します

Update、Jump、Follow の操作を備えた完成後の ISS Tracker UI

主に src ディレクトリ内のファイルを編集します。

iss-tracker-plugin/
├── node_modules/
├── public/
│ └── reearth.yml # Plugin definition
├── src/
│ ├── extensions/
│ │ └── iss_tracker/ # Extension directory named after its ID
│ │ ├── main/ # UI project for the main view
│ │ └── iss_tracker.ts # Extension script
│ └── shared/
│ ├── components/ # Shared shadcn/ui components
│ ├── lib/ # Shared shadcn/ui utilities
│ ├── reearthTypes/ # Visualizer Plugin API types
│ ├── global.css # Shared Tailwind CSS
│ └── utils.ts
├── dist/ # Plugin build output
├── dist-ui/ # UI build output
├── package/ # Installable ZIP output
├── configs/ # Vite configurations
├── scripts/
├── package.json
└── README.md

src/extensions/iss_tracker/main/App.tsx を開きます。

既存の CardHeader を次の内容に置き換えてください。

<CardHeader>
<CardTitle>ISS Tracker</CardTitle>
<CardDescription>
View, move, and update the current location of the ISS
</CardDescription>
</CardHeader>

テーブル本体で、行ラベルを Mouse から ISS に変更します。

<TableCell className="font-semibold">ISS</TableCell>

既存の CardFooter 全体を、次の3つの仮の操作に置き換えてください。

<CardFooter className="justify-center gap-3 p-4 border-t">
<Button size="sm" className="gap-1" onClick={handleFlyToTokyo}>
Update
</Button>
<Button size="sm" className="gap-1">
Jump
</Button>
<Button size="sm" className="gap-1">
Follow
</Button>
</CardFooter>

この時点では、すべてのボタンが動作するわけではありません。新しい動作を実装する間も既存のハンドラーが参照されるように、handleFlyToTokyo を一時的に Update に設定します。

Visualizer で Reload Dev Plugin Extensions を選択します。UI 開発サーバーの起動中に http://localhost:5173/ を開くと、UI の変更がホットリロードされます。

ISS Tracker のラベルと3つの操作を追加したテンプレート

src/extensions/iss_tracker/main/hooks.ts を開き、ファイル全体を次の内容に置き換えてください。

import { useState } from "react";
type IssPosition = {
lat: number;
lon: number;
height: number;
};
export default () => {
const [issPosition, setIssPosition] = useState<IssPosition | null>(null);
const handleUpdate = async () => {
const position = await fetchIssLocation();
setIssPosition(position);
};
return { issPosition, handleUpdate };
};
const fetchIssLocation = async (): Promise<IssPosition | null> => {
try {
const response = await fetch(
"https://api.wheretheiss.at/v1/satellites/25544",
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return {
lat: data.latitude,
lon: data.longitude,
height: data.altitude * 1000,
};
} catch {
return null;
}
};

このフックは、ISS の位置または null を保持します。fetchIssLocation() は現在の座標を取得し、高度を km から m に変換します。handleUpdate() は結果を React の状態に保存します。

次に src/extensions/iss_tracker/main/App.tsx を開き、既存の useHooks() 呼びだしを次の内容に置き換えてください。

const { issPosition, handleUpdate } = useHooks();

Update が新しいハンドラーを呼びだすように、CardFooter 全体を次の内容に置き換えてください。

<CardFooter className="justify-center gap-3 p-4 border-t">
<Button size="sm" className="gap-1" onClick={handleUpdate}>
Update
</Button>
<Button size="sm" className="gap-1">
Jump
</Button>
<Button size="sm" className="gap-1">
Follow
</Button>
</CardFooter>

テーブル行の3つの座標セルを次の内容に置き換えてください。

<TableCell>
<Label htmlFor="iss-lng" className="sr-only">
Longitude
</Label>
<Input id="iss-lng" type="number" disabled value={issPosition?.lon ?? ""} />
</TableCell>
<TableCell>
<Label htmlFor="iss-lat" className="sr-only">
Latitude
</Label>
<Input id="iss-lat" type="number" disabled value={issPosition?.lat ?? ""} />
</TableCell>
<TableCell>
<Label htmlFor="iss-height" className="sr-only">
Height
</Label>
<Input
id="iss-height"
type="number"
disabled
value={issPosition?.height ?? ""}
/>
</TableCell>

プラグインを再読み込みして、Update を選択します。ウィジェットに現在の経度、緯度、高度が表示されることを確認してください。

Update の選択後に座標を表示する ISS Tracker

src/extensions/iss_tracker/main/hooks.ts を開き、ファイル全体を次の内容に置き換えてください。

import { useState } from "react";
import { postMsg } from "@/shared/utils";
type IssPosition = {
lat: number;
lon: number;
height: number;
};
export default () => {
const [issPosition, setIssPosition] = useState<IssPosition | null>(null);
const handleUpdate = async () => {
const position = await fetchIssLocation();
setIssPosition(position);
};
const handleJump = () => {
flyTo(issPosition);
};
return { issPosition, handleUpdate, handleJump };
};
const fetchIssLocation = async (): Promise<IssPosition | null> => {
try {
const response = await fetch(
"https://api.wheretheiss.at/v1/satellites/25544",
);
if (!response.ok) {
throw new Error(`HTTP error! Status: ${response.status}`);
}
const data = await response.json();
return {
lat: data.latitude,
lon: data.longitude,
height: data.altitude * 1000,
};
} catch {
return null;
}
};
const flyTo = (position: IssPosition | null): void => {
if (!position) {
console.warn("No position available to fly to.");
return;
}
postMsg("flyTo", position);
};

flyTo() は位置があることを確認し、postMsg() を使ってウィジェットの iframe から拡張へ位置を送ります。

src/extensions/iss_tracker/iss_tracker.ts を開き、ファイル全体を次の内容に置き換えてください。

import html from "@distui/iss_tracker/main/index.html?raw";
import { GlobalThis } from "@/shared/reearthTypes";
type IssPosition = {
lat: number;
lon: number;
height: number;
};
type FlyToMessage = {
action: "flyTo";
payload: IssPosition;
};
const isFlyToMessage = (message: unknown): message is FlyToMessage => {
if (!message || typeof message !== "object") return false;
const candidate = message as Partial<FlyToMessage>;
const position = candidate.payload;
return (
candidate.action === "flyTo" &&
!!position &&
Number.isFinite(position.lat) &&
Number.isFinite(position.lon) &&
Number.isFinite(position.height)
);
};
const reearth = (globalThis as unknown as GlobalThis).reearth;
reearth.ui.show(html);
reearth.extension.on("message", (message: unknown) => {
if (!isFlyToMessage(message)) return;
reearth.camera.flyTo(
{
lat: message.payload.lat,
lng: message.payload.lon,
height: message.payload.height,
},
{ duration: 1 },
);
});

拡張はメッセージを検証し、reearth.camera.flyTo() を呼びだします。ウィジェットでは lon を使いますが、カメラ API では lng が必要です。

最後に src/extensions/iss_tracker/main/App.tsx を開き、フックの呼びだしに handleJump を含めてください。

const { issPosition, handleUpdate, handleJump } = useHooks();

CardFooter 全体を次の内容に置き換えてください。

<CardFooter className="justify-center gap-3 p-4 border-t">
<Button size="sm" className="gap-1" onClick={handleUpdate}>
Update
</Button>
<Button size="sm" className="gap-1" onClick={handleJump}>
Jump
</Button>
<Button size="sm" className="gap-1">
Follow
</Button>
</CardFooter>

プラグインを再読み込みし、Update、Jump の順に選択します。表示中の ISS の位置へカメラが移動することを確認してください。

src/extensions/iss_tracker/main/hooks.ts を開き、ファイル全体を次の完成版フックに置き換えてください。

import { useCallback, useEffect, useRef, useState } from "react";
import { postMsg } from "@/shared/utils";
type IssPosition = {
lat: number;
lon: number;
height: number;
};
const ISS_API_URL = "https://api.wheretheiss.at/v1/satellites/25544";
const isIssApiResponse = (
value: unknown,
): value is { latitude: number; longitude: number; altitude: number } => {
if (!value || typeof value !== "object") return false;
const data = value as Record<string, unknown>;
return (
Number.isFinite(data.latitude) &&
Number.isFinite(data.longitude) &&
Number.isFinite(data.altitude)
);
};
const fetchIssLocation = async (signal: AbortSignal): Promise<IssPosition> => {
const response = await fetch(ISS_API_URL, { signal });
if (!response.ok) {
throw new Error(`ISS API request failed (${response.status}).`);
}
const data: unknown = await response.json();
if (!isIssApiResponse(data)) {
throw new Error("The ISS API returned an unexpected response.");
}
return {
lat: data.latitude,
lon: data.longitude,
height: data.altitude * 1000,
};
};
const flyTo = (position: IssPosition) => {
postMsg("flyTo", position);
};
export default () => {
const [issPosition, setIssPosition] = useState<IssPosition | null>(null);
const [isFollowing, setIsFollowing] = useState(false);
const [error, setError] = useState<string | null>(null);
const activeRequest = useRef<AbortController | null>(null);
const loadPosition = useCallback(async (): Promise<IssPosition | null> => {
activeRequest.current?.abort();
const controller = new AbortController();
activeRequest.current = controller;
setError(null);
try {
const position = await fetchIssLocation(controller.signal);
setIssPosition(position);
return position;
} catch (error) {
if (error instanceof DOMException && error.name === "AbortError") {
return null;
}
setError(
error instanceof Error
? error.message
: "Unable to load the ISS position.",
);
return null;
} finally {
if (activeRequest.current === controller) activeRequest.current = null;
}
}, []);
const handleUpdate = useCallback(() => {
void loadPosition();
}, [loadPosition]);
const handleJump = useCallback(() => {
if (issPosition) flyTo(issPosition);
}, [issPosition]);
useEffect(() => {
if (!isFollowing) return;
let stopped = false;
let timeoutId: ReturnType<typeof setTimeout> | undefined;
const follow = async () => {
const position = await loadPosition();
if (position) flyTo(position);
if (!stopped) timeoutId = setTimeout(follow, 5000);
};
void follow();
return () => {
stopped = true;
if (timeoutId) clearTimeout(timeoutId);
activeRequest.current?.abort();
};
}, [isFollowing, loadPosition]);
const toggleFollow = useCallback(() => {
setIsFollowing((current) => !current);
}, []);
return {
issPosition,
isFollowing,
error,
handleUpdate,
handleJump,
toggleFollow,
};
};

完成版のフックは API のレスポンスを検証します。新しいリクエストが失敗しても、最後に取得できた位置を表示したままにします。また、リクエストの重複を防ぎ、追跡モードの停止時にタイマーと実行中のリクエストを取り消します。

src/extensions/iss_tracker/main/App.tsx を開き、ファイル全体を次の完成版 UI に置き換えてください。

import useHooks from "./hooks";
import { Button } from "@/shared/components/ui/button";
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle,
} from "@/shared/components/ui/card";
import { Input } from "@/shared/components/ui/input";
import { Label } from "@/shared/components/ui/label";
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from "@/shared/components/ui/table";
function App() {
const {
issPosition,
isFollowing,
error,
handleUpdate,
handleJump,
toggleFollow,
} = useHooks();
return (
<Card>
<CardHeader>
<CardTitle>ISS Tracker</CardTitle>
<CardDescription>
View, move, and update the current location of the ISS
</CardDescription>
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[100px]" />
<TableHead>Longitude</TableHead>
<TableHead>Latitude</TableHead>
<TableHead>Height (m)</TableHead>
</TableRow>
</TableHeader>
<TableBody>
<TableRow>
<TableCell className="font-semibold">ISS</TableCell>
<TableCell>
<Label htmlFor="iss-lng" className="sr-only">
Longitude
</Label>
<Input id="iss-lng" disabled value={issPosition?.lon ?? ""} />
</TableCell>
<TableCell>
<Label htmlFor="iss-lat" className="sr-only">
Latitude
</Label>
<Input id="iss-lat" disabled value={issPosition?.lat ?? ""} />
</TableCell>
<TableCell>
<Label htmlFor="iss-height" className="sr-only">
Height in meters
</Label>
<Input
id="iss-height"
disabled
value={issPosition ? Math.round(issPosition.height) : ""}
/>
</TableCell>
</TableRow>
</TableBody>
</Table>
{error && (
<p role="alert" className="mt-3 text-sm text-destructive">
{error}
</p>
)}
</CardContent>
<CardFooter className="justify-center gap-3 p-4 border-t">
<Button size="sm" className="gap-1" onClick={handleUpdate}>
Update
</Button>
<Button size="sm" className="gap-1" onClick={handleJump}>
Jump
</Button>
<Button size="sm" className="gap-1" onClick={toggleFollow}>
{isFollowing ? "Unfollow" : "Follow"}
</Button>
</CardFooter>
</Card>
);
}
export default App;

プラグインを再読み込みして、Follow を選択します。ボタンが Unfollow に変わり、5秒ごとに ISS の最新位置へカメラが移動することを確認してください。

ISS の現在位置を追跡する完成後の ISS Tracker

ステップ3・プラグインをパッケージ化してインストールする

Section titled “ステップ3・プラグインをパッケージ化してインストールする”

プラグインを検証してビルドする

Section titled “プラグインを検証してビルドする”

プラグインプロジェクトのルートで、次のコマンドを実行してください。

Terminal window
yarn type
yarn lint
yarn build

最後のコマンドは現在のソースをビルドし、package/iss-tracker-plugin-1.0.0.zip を作成します。

package ディレクトリに生成された ISS Tracker の ZIP ファイル

ZIP ファイルを開き、reearth.yml や iss_tracker.js などのインストール用ファイルが別のディレクトリ内ではなく、ZIP のルートにあることを確認してください。

生成したプラグイン ZIP に含まれるファイル

ローカル開発モードを終了する

Section titled “ローカル開発モードを終了する”

ローカルの Re:Earth Visualizer リポジトリで web/.env を開きます。REEARTH_WEB_DEV_PLUGIN_URLS を削除するか、次のようにコメントアウトしてください。

# REEARTH_WEB_DEV_PLUGIN_URLS='["http://localhost:5005"]'

環境ファイルを変更したら、Visualizer 開発サーバーを再起動してください。

ZIP ファイルをインストールする

Section titled “ZIP ファイルをインストールする”
  1. Visualizer プロジェクトを作成するか、既存のプロジェクトを開きます
  2. プロジェクト名を選択し、Plugins を選択します

Visualizer のプロジェクトメニューにある Plugins

  1. Personally Installed を開き、Upload ZIP File from PC を選択します

Personally Installed にある ZIP ファイルのアップロード項目

  1. プラグインプロジェクトの package ディレクトリから iss-tracker-plugin-1.0.0.zip をアップロードします
  2. インストール済みのプラグインに ISS Tracker Plugin が表示されることを確認します

インストール後の一覧に表示された ISS Tracker Plugin

  1. エディターに戻ります

プラグインのインストール後に Visualizer エディターへ戻る画面

  1. Widgets タブを開き、シーンに ISS Tracker を追加します

Widgets タブから ISS Tracker を追加する画面

シーンに表示されたインストール済みの ISS Tracker ウィジェット

完成したプラグインを確認する

Section titled “完成したプラグインを確認する”

次の項目をすべて確認できたら、プラグインは完成です。

  • インストール済みのプラグインに ISS Tracker Plugin が表示される
  • Widgets タブから ISS Tracker ウィジェットを追加できる
  • Update を選択すると、ISS の最新の座標が表示される
  • Jump を選択すると、表示中の位置へカメラが移動する
  • Follow が Unfollow に変わり、ISS の位置が更新されるたびにカメラが移動する