
Table of Contents
開発合宿!
5/16 に開発合宿が今宿の SALT 開催されました!
Google の Photorealistic 3D Tiles を使って何かできないかなー、と考えてたのですが、
社内には長距離ウォーキングに参加する方が結構いるので、どこを歩いているか 3D で見れたら面白いかも、と思い Photorealistic 3D Tiles 上に位置情報の時系列データをプロットするみまもりアプリを作ってみました。
Photorealistic 3D Tiles を使う
Photorealistic 3D Tiles は、高解像度の画像でテクスチャ化された 3D メッシュです。世界中の多くの人口密集地域で、高解像度の 3D 地図を提供しています。
CesiumJS から使われることが多いかと思いますが、個人的に Three.js の方が使い慣れているので 3DTilesRenderer を使って、Three.js で Photorealistic 3D Tiles をレンダリングしました。
Photorealistic 3D Tiles を使うには API キー が必要です。
スクリーンショット
会社近くを歩いたログはこんな感じで見れます!
緑色のマーカーで居た場所を示しています。
GPS での高度測定は難しいのか、ちょっと空飛んでるみたいになっているところもありますが。。。
主な構成
バックエンド
Amplify Gen 2 で Cognito と DynamoDB を立てています。
DynamoDB の Schema は以下のような感じです。
const schema = a.schema({
Location: a
.model({
userId: a.string().required(),
timestamp: a.float().required(),
latitude: a.float().required(),
longitude: a.float().required(),
altitude: a.float(),
})
.authorization((allow) => [allow.owner()])
.identifier(["userId", "timestamp"]),
});
フロントエンド
Vite と React で作りました。
Three.js 周りは React Three Fiberを使いました 。
主なバージョン
- Node.js: 22.14.0
- Vite: 6.3.5
- React: 19.1.0
- TanStack Router: 1.120.3
- Three.js: 0.176.0
- React Three Fiber: 9.1.2
- 3DTilesRenderer: 0.4.9
- AWS Amplify: 6.14.4
主な実装
3D Tiles の実装
3D Tiles の実装は以下です。
- API キーは環境変数で渡します。
- 緯度・経度を引数で渡して
tiles
を作成します。 useEffect
でcamera
とrenderer
を設定します。useFrame
で毎フレームtiles.update()
をします。
import { loadGoogleTiles } from '@/utils/threeUtils';
import { useFrame } from '@react-three/fiber';
import { useThree } from '@react-three/fiber';
import { useEffect, useMemo } from 'react';
type TilesProps = {
latitude: number;
longitude: number;
};
export function Tiles({ latitude, longitude }: TilesProps) {
const apiKey = import.meta.env.VITE_GOOGLE_MAPS_API_KEY;
if (!apiKey) {
throw new Error(
'Google Maps API key is not set. Please set VITE_GOOGLE_MAPS_API_KEY.',
);
}
const camera = useThree((state) => state.camera);
const renderer = useThree((state) => state.gl);
const tiles = useMemo(
() => loadGoogleTiles(apiKey, latitude, longitude),
[latitude, longitude],
);
useEffect(() => {
tiles.fetchOptions = {};
tiles.setCamera(camera);
tiles.setResolutionFromRenderer(camera, renderer);
tiles.update();
}, [tiles, camera, renderer]);
useFrame((_state, _delta) => {
tiles.update();
});
return <primitive object={tiles.group} />;
}
Three.js 周りの実装
Tiles を含んだ、3D 周りの実装は、以下のようにしました。
- カメラの設定、ライトの設定はテキトーです。
- 複数ユーザに対応できるようにしています。
CameraControls
では、OrbitControls
を使っています。target
が指定されるとカメラがその位置に移動するようにします。
MarkerWithLine
では、- マーカーには
OctahedronGeometry
を使っています。 - マーカー間を
ConnectingLine
で結んでいます。
- マーカーには
import { CameraControls } from "@/components/three/CameraControls";
import MarkersWithLine from "@/components/three/MarkersWithLine";
import { Tiles } from "@/components/three/Tiles";
import type { Location, UserPosition } from "@/types";
import { Canvas } from "@react-three/fiber";
type Props = {
location: Location | undefined;
users: string[] | undefined;
allUserPositions: Map<string, UserPosition[]> | undefined;
target: UserPosition | undefined;
userColors: Map<string, number> | undefined;
selectedUser: string | undefined;
};
export function MapCanvas({
location,
users,
allUserPositions,
target,
userColors,
selectedUser,
}: Props) {
return (
<>
{location && (
<Canvas
camera={{
position: [
0,
location.altitude != null ? location.altitude + 300 : 300,
300,
],
fov: 60,
far: 100000,
}}
>
<mesh>
<ambientLight intensity={1} />
<directionalLight position={[0, 100000, 0]} intensity={1} />
<CameraControls target={target?.position} />
<Tiles
latitude={location.latitude}
longitude={location.longitude}
/>
{users?.map((userId) => (
<group key={userId}>
<MarkersWithLine
positions={allUserPositions?.get(userId) || []}
color={userColors?.get(userId) || "red"}
opacity={selectedUser === userId ? 1 : 0.5}
/>
</group>
))}
</mesh>
</Canvas>
)}
</>
);
}
データ取得周りの実装
データ取得周りの実装は以下のようにしています。
- ユーザー毎に降順で位置情報を取得します。
- 全ての位置情報の平均の緯度・経度・高度を設定し、
Tiles
の緯度・経度に使います。 observeQuery
を使って、リアルタイムに追加された位置情報を反映します。locationsToUserPositions
では、- 緯度・経度・高度を x, y, z 座標に変換します。
- ダブりを避けつつ、ユーザー ID で分けて、timestamp 昇順に並び替えます。
import type { Location, LocationData, UserPosition } from "@/types";
import { locationsToUserPositions } from "@/utils/convertUtils";
import type { Schema } from "@amplify/data/resource";
import { generateClient } from "aws-amplify/api";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import type { Subscription } from "rxjs";
export function useLocations() {
const client = useMemo(() => generateClient<Schema>(), []);
const [location, setLocation] = useState<Location>();
const [allLocations, setAllLocations] = useState<LocationData[]>();
const [allUserPositions, setAllUserPositions] =
useState<Map<string, UserPosition[]>>();
const subscriptionRef = useRef<Subscription>(undefined);
const nowTimestamp = useMemo(() => new Date().getTime(), []);
// 全ユーザーの位置情報をリセット
const resetAllUserPositionsAsync = useCallback(
async (users: string[]) => {
const locations = await Promise.all(
users.map(async (userId) => {
const { data } = await client.models.Location.list({
userId: userId,
sortDirection: "DESC",
});
return data;
})
);
const allLocations = locations.flat();
setAllLocations(allLocations);
// 平均の緯度・経度を計算
const averageLat = allLocations.reduce((acc, location) => acc + location.latitude, 0) / allLocations.length;
const averageLon = allLocations.reduce((acc, location) => acc + location.longitude, 0) / allLocations.length;
// 平均の高度を計算
let altCount = 0;
let averageAlt = 0;
for (const location of allLocations) {
if (location.altitude != null) {
averageAlt += location.altitude;
altCount++;
}
}
averageAlt /= altCount;
setLocation({ latitude: averageLat, longitude: averageLon, altitude: averageAlt });
},
[client]
);
// 位置情報の監視を開始
const startObserve = useCallback((users: string[]) => {
if (subscriptionRef.current != null) return;
subscriptionRef.current = client.models.Location.observeQuery({
filter: {
and: [
{
timestamp: { ge: nowTimestamp },
or: users.map((userId) => ({
userId: { eq: userId },
})),
},
],
},
}).subscribe({
next: ({ items, isSynced }) => {
if (!isSynced) return;
setAllLocations((prev) => {
if (prev == null) return items;
return [...prev, ...items];
});
},
});
}, [client, nowTimestamp]);
// 位置情報の監視を停止
const stopObserve = useCallback(() => {
if (subscriptionRef.current == null) return;
subscriptionRef.current.unsubscribe();
subscriptionRef.current = undefined;
}, []);
// 位置情報を更新
useEffect(() => {
if (location == null || allLocations == null) return;
const positions = locationsToUserPositions(location, allLocations);
setAllUserPositions(positions);
}, [allLocations, location]);
return {
location,
allUserPositions,
resetAllUserPositionsAsync,
startObserve,
stopObserve,
};
}
データ送信周りの実装
GPS トラッカー使いたかったですが、とりあえず別ページを作って、位置情報を送信しました。
- TanStack Router で別ページを作成しています。
setInterval
で定期的にnavigator.geolocation.getCurrentPosition
を取得・送信します。
import { LOCATION_UPDATE_INTERVAL } from '@/consts';
import type { LocationData } from '@/types';
import type { Schema } from '@amplify/data/resource';
import { createFileRoute } from '@tanstack/react-router';
import { generateClient } from 'aws-amplify/api';
import { getCurrentUser } from 'aws-amplify/auth';
import { useEffect, useMemo, useState } from 'react';
export const Route = createFileRoute('/location/')({
component: RouteComponent,
});
/* 現在の位置情報を取得 */
function getCurrentPosition(): Promise<GeolocationPosition> {
return new Promise((resolve, reject) => {
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: LOCATION_UPDATE_INTERVAL,
maximumAge: 0,
});
});
}
/* 位置情報を送信 */
async function sendLocation(
client: ReturnType<typeof generateClient<Schema>>,
userId: string,
position: GeolocationPosition,
) {
return await client.models.Location.create({
userId,
timestamp: position.timestamp,
latitude: position.coords.latitude,
longitude: position.coords.longitude,
altitude: position.coords.altitude,
});
}
function RouteComponent() {
const [location, setLocation] = useState<LocationData | null | undefined>();
const client = useMemo(() => generateClient<Schema>(), []);
// 位置情報を定期的に更新
useEffect(() => {
const intervalId = setInterval(async () => {
const { userId } = await getCurrentUser();
const position = await getCurrentPosition();
const { data, errors } = await sendLocation(client, userId, position);
if (errors != null) {
console.error(errors);
}
setLocation(data);
}, LOCATION_UPDATE_INTERVAL);
return () => {
clearInterval(intervalId);
};
}, [client]);
return (
<div>
<h1>Location</h1>
<p>latitude: {location?.latitude}</p>
<p>longitude: {location?.longitude}</p>
</div>
);
}
定義した型
以下のような型を使っています。
- 分かりにくいですが、
location
は緯度,経度,高度、position
は x, y, z 座標を示しています。
import type { Schema } from '@amplify/data/resource';
import type { Vector3 } from 'three';
export interface Location {
latitude: number;
longitude: number;
altitude?: number | null;
}
export type LocationData = Schema['Location']['type'];
export interface UserPosition {
name: string;
location: Location;
position: Vector3;
timestamp: number;
}
動画
新しいデータが来たら、こんな感じで、リアルタイムに更新されます。
まとめ
- 3D マップを使えば、周囲の状況が見えて、距離感とかも分かりやすい!
- Photorealistic 3D Tiles を使えば簡単に 3D マップが表示できる!
- React Three Fiber を使えば、簡単に 3D 上にオブジェクトが置ける!
- Amplify Gen 2 を使えば、リアルタイムデータも楽に反映できる!