diff --git a/app.config.ts b/app.config.ts
index 744eff0..4b7460e 100644
--- a/app.config.ts
+++ b/app.config.ts
@@ -69,6 +69,7 @@ const config: ExpoConfig = {
},
plugins: [
"expo-router",
+ "react-native-maps",
[
"expo-splash-screen",
{
diff --git a/app/(tabs)/_layout.tsx b/app/(tabs)/_layout.tsx
index 1702e8e..b26c41c 100644
--- a/app/(tabs)/_layout.tsx
+++ b/app/(tabs)/_layout.tsx
@@ -42,6 +42,13 @@ export default function TabLayout() {
tabBarIcon: ({ color }) => ,
}}
/>
+ ,
+ }}
+ />
{
return {
- height: heightValue.value,
+ maxHeight: maxHeightValue.value,
opacity: opacityValue.value,
overflow: "hidden" as const,
};
@@ -87,10 +87,10 @@ export default function LogsScreen() {
return Array.from(deviceSet).sort();
}, [state.logs]);
- // デバイスフィルターがある場合は高さを増やす
- const contentHeight = logDeviceIds.length > 0
- ? ACCORDION_CONTENT_HEIGHT_WITH_DEVICE
- : ACCORDION_CONTENT_HEIGHT_BASE;
+ // デバイスフィルターがある場合は最大高さを増やす
+ const maxContentHeight = logDeviceIds.length > 0
+ ? ACCORDION_MAX_HEIGHT_WITH_DEVICE
+ : ACCORDION_MAX_HEIGHT_BASE;
// フィルタリングされたログ
const filteredLogs = useMemo(() => {
@@ -226,9 +226,9 @@ export default function LogsScreen() {
};
rotateValue.value = withTiming(newValue ? 180 : 0, animConfig);
- heightValue.value = withTiming(newValue ? contentHeight : 0, animConfig);
+ maxHeightValue.value = withTiming(newValue ? maxContentHeight : 0, animConfig);
opacityValue.value = withTiming(newValue ? 1 : 0, { duration: newValue ? 250 : 150 });
- }, [isFilterExpanded, rotateValue, heightValue, opacityValue, contentHeight]);
+ }, [isFilterExpanded, rotateValue, maxHeightValue, opacityValue, maxContentHeight]);
// 検索クリア
const handleClearSearch = useCallback(() => {
diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx
new file mode 100644
index 0000000..cde46a7
--- /dev/null
+++ b/app/(tabs)/map.tsx
@@ -0,0 +1,327 @@
+import { useState, useCallback, useRef, useEffect, Fragment } from "react";
+import {
+ Text,
+ View,
+ TouchableOpacity,
+ StyleSheet,
+ Platform,
+ ScrollView,
+} from "react-native";
+import * as Haptics from "expo-haptics";
+import Animated, {
+ useAnimatedStyle,
+ withTiming,
+ useSharedValue,
+ Easing,
+} from "react-native-reanimated";
+
+import { ScreenContainer } from "@/components/screen-container";
+import { ConnectionStatusBadge } from "@/components/connection-status";
+import { useLocation } from "@/lib/location-store";
+import { cn } from "@/lib/utils";
+import {
+ useDeviceTrajectory,
+ getAllCoordinates,
+} from "@/hooks/use-device-trajectory";
+import { getDeviceColor } from "@/constants/map-colors";
+
+// react-native-maps は Web では使えないので条件付きインポート
+let MapView: typeof import("react-native-maps").default | null = null;
+let Polyline: typeof import("react-native-maps").Polyline | null = null;
+let Marker: typeof import("react-native-maps").Marker | null = null;
+
+if (Platform.OS !== "web") {
+ // eslint-disable-next-line @typescript-eslint/no-require-imports
+ const Maps = require("react-native-maps");
+ MapView = Maps.default;
+ Polyline = Maps.Polyline;
+ Marker = Maps.Marker;
+}
+
+type MapViewRef = import("react-native-maps").default;
+
+// アコーディオンコンテンツの最大高さ(アニメーション用)
+const ACCORDION_MAX_HEIGHT = 200;
+
+export default function MapScreen() {
+ const { state } = useLocation();
+ const [selectedDevices, setSelectedDevices] = useState>(new Set());
+ const [isFilterExpanded, setIsFilterExpanded] = useState(true);
+ const mapRef = useRef(null);
+
+ // アニメーション用の共有値(開いた状態で初期化)
+ const rotateValue = useSharedValue(180);
+ const maxHeightValue = useSharedValue(ACCORDION_MAX_HEIGHT);
+ const opacityValue = useSharedValue(1);
+
+ // 軌跡データを計算
+ const trajectories = useDeviceTrajectory(state.updates, selectedDevices);
+
+ // 矢印の回転アニメーション
+ const arrowStyle = useAnimatedStyle(() => {
+ return {
+ transform: [{ rotate: `${rotateValue.value}deg` }],
+ };
+ });
+
+ // コンテンツの高さアニメーション
+ const contentStyle = useAnimatedStyle(() => {
+ return {
+ maxHeight: maxHeightValue.value,
+ opacity: opacityValue.value,
+ overflow: "hidden" as const,
+ };
+ });
+
+ // デバイス選択が変わったらカメラ調整
+ useEffect(() => {
+ if (Platform.OS === "web" || !mapRef.current) return;
+
+ const allCoords = getAllCoordinates(trajectories);
+ if (allCoords.length === 0) return;
+
+ mapRef.current.fitToCoordinates(allCoords, {
+ edgePadding: { top: 50, right: 50, bottom: 50, left: 50 },
+ animated: true,
+ });
+ }, [trajectories]);
+
+ // デバイスフィルターの選択/解除
+ const handleDeviceSelect = useCallback((value: string | null) => {
+ if (Platform.OS !== "web") {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+ if (value === null) {
+ setSelectedDevices(new Set());
+ } else {
+ setSelectedDevices((prev) => {
+ const newSet = new Set(prev);
+ if (newSet.has(value)) {
+ newSet.delete(value);
+ } else {
+ newSet.add(value);
+ }
+ return newSet;
+ });
+ }
+ }, []);
+
+ const toggleFilter = useCallback(() => {
+ if (Platform.OS !== "web") {
+ Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light);
+ }
+
+ const newValue = !isFilterExpanded;
+ setIsFilterExpanded(newValue);
+
+ // アニメーション設定
+ const animConfig = {
+ duration: 250,
+ easing: Easing.bezier(0.4, 0, 0.2, 1),
+ };
+
+ rotateValue.value = withTiming(newValue ? 180 : 0, animConfig);
+ maxHeightValue.value = withTiming(newValue ? ACCORDION_MAX_HEIGHT : 0, animConfig);
+ opacityValue.value = withTiming(newValue ? 1 : 0, { duration: newValue ? 250 : 150 });
+ }, [isFilterExpanded, rotateValue, maxHeightValue, opacityValue]);
+
+ // Web用のフォールバック
+ if (Platform.OS === "web") {
+ return (
+
+
+
+ マップ
+
+
+
+ 🗺️
+ Web未対応
+
+ マップ機能はiOS/Androidアプリでのみ利用可能です
+
+
+
+
+ );
+ }
+
+ return (
+
+
+ {/* Header */}
+
+ マップ
+
+
+
+ {/* Device Filter Accordion */}
+
+ {/* Accordion Header */}
+
+
+
+
+ デバイス
+
+ {selectedDevices.size > 0 && (
+
+
+ {selectedDevices.size}件選択中
+
+
+ )}
+
+
+ ▼
+
+
+
+
+ {/* Accordion Content with Animation */}
+
+
+
+ {state.deviceIds.length > 0 ? (
+
+ {/* 選択解除ボタン */}
+ handleDeviceSelect(null)}
+ activeOpacity={0.7}
+ style={styles.filterButton}
+ >
+
+
+ 選択解除
+
+
+
+ {state.deviceIds.map((device) => {
+ const deviceColor = getDeviceColor(device, state.deviceIds);
+ const isSelected = selectedDevices.has(device);
+ return (
+ handleDeviceSelect(device)}
+ activeOpacity={0.7}
+ style={styles.filterButton}
+ >
+
+
+ {device}
+
+
+
+ );
+ })}
+
+ ) : (
+ デバイスがありません
+ )}
+
+
+
+
+
+ {/* Map View */}
+
+ {selectedDevices.size === 0 ? (
+
+ 📍
+
+ デバイスを選択してください
+
+
+ 上のフィルターからデバイスを選択すると軌跡が表示されます
+
+
+ ) : MapView ? (
+
+ {trajectories.map((trajectory) => (
+
+ {Polyline && trajectory.coordinates.length > 1 && (
+
+ )}
+ {Marker && trajectory.latestPosition && (
+
+ )}
+
+ ))}
+
+ ) : null}
+
+
+
+ );
+}
+
+const styles = StyleSheet.create({
+ accordionHeader: {
+ minHeight: 48,
+ justifyContent: "center",
+ },
+ arrowIcon: {
+ fontSize: 12,
+ color: "#687076",
+ },
+ filterScrollContent: {
+ gap: 8,
+ },
+ filterButton: {
+ flexShrink: 0,
+ marginBottom: 4,
+ },
+ map: {
+ flex: 1,
+ },
+});
diff --git a/app/(tabs)/timeline.tsx b/app/(tabs)/timeline.tsx
index 4d14596..35c05ca 100644
--- a/app/(tabs)/timeline.tsx
+++ b/app/(tabs)/timeline.tsx
@@ -34,9 +34,9 @@ const MOVING_STATES: { value: MovingState; label: string }[] = [
{ value: "moving", label: "移動中" },
];
-// アコーディオンコンテンツの高さ
-const ACCORDION_CONTENT_HEIGHT_BASE = 80; // 状態フィルターのみ
-const ACCORDION_CONTENT_HEIGHT_WITH_DEVICE = 150; // 状態 + デバイスフィルター
+// アコーディオンコンテンツの最大高さ(アニメーション用)
+const ACCORDION_MAX_HEIGHT_BASE = 200; // 状態フィルターのみ
+const ACCORDION_MAX_HEIGHT_WITH_DEVICE = 300; // 状態 + デバイスフィルター
export default function TimelineScreen() {
const { state, clearUpdates } = useLocation();
@@ -50,7 +50,7 @@ export default function TimelineScreen() {
// アニメーション用の共有値
const rotateValue = useSharedValue(0);
- const heightValue = useSharedValue(0);
+ const maxHeightValue = useSharedValue(0);
const opacityValue = useSharedValue(0);
// 矢印の回転アニメーション
@@ -63,16 +63,16 @@ export default function TimelineScreen() {
// コンテンツの高さアニメーション
const contentStyle = useAnimatedStyle(() => {
return {
- height: heightValue.value,
+ maxHeight: maxHeightValue.value,
opacity: opacityValue.value,
overflow: "hidden" as const,
};
});
- // デバイスフィルターがある場合は高さを増やす
- const contentHeight = state.deviceIds.length > 0
- ? ACCORDION_CONTENT_HEIGHT_WITH_DEVICE
- : ACCORDION_CONTENT_HEIGHT_BASE;
+ // デバイスフィルターがある場合は最大高さを増やす
+ const maxContentHeight = state.deviceIds.length > 0
+ ? ACCORDION_MAX_HEIGHT_WITH_DEVICE
+ : ACCORDION_MAX_HEIGHT_BASE;
// Filter updates by search query, selected states and devices
const filteredUpdates = useMemo(() => {
@@ -184,9 +184,9 @@ export default function TimelineScreen() {
};
rotateValue.value = withTiming(newValue ? 180 : 0, animConfig);
- heightValue.value = withTiming(newValue ? contentHeight : 0, animConfig);
+ maxHeightValue.value = withTiming(newValue ? maxContentHeight : 0, animConfig);
opacityValue.value = withTiming(newValue ? 1 : 0, { duration: newValue ? 250 : 150 });
- }, [isFilterExpanded, rotateValue, heightValue, opacityValue, contentHeight]);
+ }, [isFilterExpanded, rotateValue, maxHeightValue, opacityValue, maxContentHeight]);
// 検索クリア
const handleClearSearch = useCallback(() => {
diff --git a/components/ui/icon-symbol.tsx b/components/ui/icon-symbol.tsx
index ec3909b..ee612e1 100644
--- a/components/ui/icon-symbol.tsx
+++ b/components/ui/icon-symbol.tsx
@@ -21,6 +21,7 @@ const MAPPING = {
"clock.fill": "schedule",
"location.fill": "location-on",
"doc.text.fill": "article",
+ "map.fill": "map",
} as IconMapping;
/**
diff --git a/constants/map-colors.ts b/constants/map-colors.ts
new file mode 100644
index 0000000..bf02052
--- /dev/null
+++ b/constants/map-colors.ts
@@ -0,0 +1,27 @@
+/**
+ * マップ上でデバイス軌跡を表示する際の色パレット
+ */
+export const MAP_TRAJECTORY_COLORS = [
+ "#FF6B6B", // 赤
+ "#4ECDC4", // ティール
+ "#45B7D1", // 水色
+ "#96CEB4", // ミントグリーン
+ "#FFEAA7", // 黄色
+ "#DDA0DD", // プラム
+ "#98D8C8", // シーグリーン
+ "#F7DC6F", // ゴールド
+ "#BB8FCE", // 紫
+ "#85C1E9", // スカイブルー
+] as const;
+
+/**
+ * デバイスIDからPolylineの色を取得
+ * デバイスIDのハッシュ値を使って色を決定する
+ */
+export function getDeviceColor(deviceId: string, deviceIds: string[]): string {
+ const index = deviceIds.indexOf(deviceId);
+ if (index === -1) {
+ return MAP_TRAJECTORY_COLORS[0];
+ }
+ return MAP_TRAJECTORY_COLORS[index % MAP_TRAJECTORY_COLORS.length];
+}
diff --git a/hooks/use-device-trajectory.ts b/hooks/use-device-trajectory.ts
new file mode 100644
index 0000000..4d8e577
--- /dev/null
+++ b/hooks/use-device-trajectory.ts
@@ -0,0 +1,70 @@
+import { useMemo } from "react";
+import type { LocationUpdate } from "@/lib/types/location";
+
+export interface Coordinate {
+ latitude: number;
+ longitude: number;
+}
+
+export interface DeviceTrajectory {
+ deviceId: string;
+ coordinates: Coordinate[];
+ latestPosition: Coordinate | null;
+}
+
+/**
+ * 選択されたデバイスの軌跡データを計算するフック
+ */
+export function useDeviceTrajectory(
+ updates: LocationUpdate[],
+ selectedDevices: Set
+): DeviceTrajectory[] {
+ return useMemo(() => {
+ if (selectedDevices.size === 0) {
+ return [];
+ }
+
+ const deviceMap = new Map();
+
+ // デバイスごとにupdatesをグループ化
+ for (const update of updates) {
+ if (!selectedDevices.has(update.device)) {
+ continue;
+ }
+ const existing = deviceMap.get(update.device) || [];
+ existing.push(update);
+ deviceMap.set(update.device, existing);
+ }
+
+ // 各デバイスの軌跡を作成
+ const trajectories: DeviceTrajectory[] = [];
+
+ for (const [deviceId, deviceUpdates] of deviceMap) {
+ // timestampでソート(古い順)
+ const sorted = [...deviceUpdates].sort((a, b) => a.timestamp - b.timestamp);
+
+ const coordinates: Coordinate[] = sorted.map((u) => ({
+ latitude: u.coords.latitude,
+ longitude: u.coords.longitude,
+ }));
+
+ const latestPosition =
+ coordinates.length > 0 ? coordinates[coordinates.length - 1] : null;
+
+ trajectories.push({
+ deviceId,
+ coordinates,
+ latestPosition,
+ });
+ }
+
+ return trajectories;
+ }, [updates, selectedDevices]);
+}
+
+/**
+ * 全ての座標を含む領域を計算
+ */
+export function getAllCoordinates(trajectories: DeviceTrajectory[]): Coordinate[] {
+ return trajectories.flatMap((t) => t.coordinates);
+}
diff --git a/lib/location-store.tsx b/lib/location-store.tsx
index ebf9d5b..8b929e3 100644
--- a/lib/location-store.tsx
+++ b/lib/location-store.tsx
@@ -36,9 +36,9 @@ function locationReducer(state: LocationState, action: LocationAction): Location
case "ADD_UPDATE": {
const update = action.payload;
const newUpdates = [update, ...state.updates].slice(0, MAX_UPDATES);
- const deviceIds = Array.from(
- new Set([update.device, ...state.deviceIds])
- );
+ const deviceIds = state.deviceIds.includes(update.device)
+ ? state.deviceIds
+ : [...state.deviceIds, update.device];
return {
...state,
updates: newUpdates,
diff --git a/package.json b/package.json
index 634e255..22c4545 100644
--- a/package.json
+++ b/package.json
@@ -7,7 +7,7 @@
"scripts": {
"dev": "concurrently -k \"pnpm dev:server\" \"pnpm dev:metro\"",
"dev:server": "cross-env NODE_ENV=development tsx watch server/_core/index.ts",
- "dev:metro": "cross-env EXPO_USE_METRO_WORKSPACE_ROOT=1 npx expo start --web --port ${EXPO_PORT:-8081}",
+ "dev:metro": "cross-env EXPO_USE_METRO_WORKSPACE_ROOT=1 npx expo start --port ${EXPO_PORT:-8081}",
"build": "esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist",
"start": "NODE_ENV=production node dist/index.js",
"check": "tsc --noEmit",
@@ -59,6 +59,7 @@
"react-dom": "19.1.0",
"react-native": "0.81.5",
"react-native-gesture-handler": "~2.28.0",
+ "react-native-maps": "^1.26.20",
"react-native-reanimated": "~4.1.6",
"react-native-safe-area-context": "~5.6.2",
"react-native-screens": "~4.16.0",
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f12d2a6..d324edb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -125,6 +125,9 @@ importers:
react-native-gesture-handler:
specifier: ~2.28.0
version: 2.28.0(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
+ react-native-maps:
+ specifier: ^1.26.20
+ version: 1.26.20(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
react-native-reanimated:
specifier: ~4.1.6
version: 4.1.6(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0)
@@ -2190,6 +2193,9 @@ packages:
'@types/express@4.17.25':
resolution: {integrity: sha512-dVd04UKsfpINUnK0yBoYHDF3xu7xVH4BuDotC/xGuycx4CgbP48X/KF/586bcObxT0HENHXEU8Nqtu6NR+eKhw==}
+ '@types/geojson@7946.0.16':
+ resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==}
+
'@types/graceful-fs@4.1.9':
resolution: {integrity: sha512-olP3sd1qOEe5dXTSaFvQG+02VdRXcdytWLAZsAq1PecU8uqQAhkrnbli7DagjtXKW/Bl7YJbUsa8MPcuc8LHEQ==}
@@ -5121,6 +5127,17 @@ packages:
react: '*'
react-native: '*'
+ react-native-maps@1.26.20:
+ resolution: {integrity: sha512-kWibDz6wLLQ0685gOEFz5jdzm4miD7PMeVdtZV7ilgftDcusC2iy7SueBJpHF0LKCoOSa1BEUiKqpx1dBMSNpA==}
+ engines: {node: '>= 20.19.4'}
+ peerDependencies:
+ react: '>= 18.3.1'
+ react-native: '>= 0.76.0'
+ react-native-web: '>= 0.11'
+ peerDependenciesMeta:
+ react-native-web:
+ optional: true
+
react-native-reanimated@4.1.6:
resolution: {integrity: sha512-F+ZJBYiok/6Jzp1re75F/9aLzkgoQCOh4yxrnwATa8392RvM3kx+fiXXFvwcgE59v48lMwd9q0nzF1oJLXpfxQ==}
peerDependencies:
@@ -8123,6 +8140,8 @@ snapshots:
'@types/qs': 6.14.0
'@types/serve-static': 1.15.10
+ '@types/geojson@7946.0.16': {}
+
'@types/graceful-fs@4.1.9':
dependencies:
'@types/node': 22.19.3
@@ -11432,6 +11451,14 @@ snapshots:
react: 19.1.0
react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
+ react-native-maps@1.26.20(react-native-web@0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
+ dependencies:
+ '@types/geojson': 7946.0.16
+ react: 19.1.0
+ react-native: 0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0)
+ optionalDependencies:
+ react-native-web: 0.21.2(react-dom@19.1.0(react@19.1.0))(react@19.1.0)
+
react-native-reanimated@4.1.6(@babel/core@7.28.5)(react-native-worklets@0.5.1(@babel/core@7.28.5)(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0))(react-native@0.81.5(@babel/core@7.28.5)(@types/react@19.1.17)(react@19.1.0))(react@19.1.0):
dependencies:
'@babel/core': 7.28.5