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