diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index efeb638..fe97af3 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback, useRef, useEffect, useMemo, Fragment } from "react"; +import { useState, useCallback, useRef, useEffect, useMemo, useReducer, Fragment } from "react"; import { Text, View, @@ -17,7 +17,7 @@ import Animated, { import { ScreenContainer } from "@/components/screen-container"; import { ConnectionStatusBadge } from "@/components/connection-status"; -import { stateConfig, defaultStateConfig, formatSpeed, formatAccuracy, formatBatteryLevel } from "@/components/location-card"; +import { stateConfig, defaultStateConfig, formatSpeed, formatAccuracy, formatBatteryLevel, formatTimeAgo } from "@/components/location-card"; import { useLocation } from "@/lib/location-store"; import { useColors } from "@/hooks/use-colors"; import { cn } from "@/lib/utils"; @@ -47,6 +47,70 @@ if (Platform.OS !== "web") { type MapViewRef = import("react-native-maps").default; type MapMarkerRef = { showCallout: () => void; hideCallout: () => void }; +// 吹き出し内コンテンツ(経過時間をライブ更新するため独立コンポーネント化) +const TIME_AGO_INTERVAL_MS = 10_000; + +function CalloutContent({ + deviceId, + stateLabel, + stateConf, + borderColor, + timestamp, + lineId, + lineNames, + latestSpeed, + latestAccuracy, + latestBatteryLevel, + isActive, +}: { + deviceId: string; + stateLabel: string; + stateConf: { bgClass: string; textClass: string }; + borderColor: string; + timestamp: number | null; + lineId: string | null; + lineNames: Record; + latestSpeed: number | null | undefined; + latestAccuracy: number | null | undefined; + latestBatteryLevel: number | null; + isActive: boolean; +}) { + const [, forceUpdate] = useReducer((x: number) => x + 1, 0); + + useEffect(() => { + if (!isActive || timestamp == null) return; + const id = setInterval(forceUpdate, TIME_AGO_INTERVAL_MS); + return () => clearInterval(id); + }, [isActive, timestamp]); + + return ( + + + {deviceId} + + + {stateLabel} + + + + + 最新位置{timestamp != null ? `・${formatTimeAgo(timestamp)}` : ""} + + {lineId && lineNames[lineId] && ( + 🚆 {lineNames[lineId]} + )} + + 🏎️ {formatSpeed(latestSpeed)} + 🎯 {formatAccuracy(latestAccuracy)} + 🔋 {latestBatteryLevel != null ? formatBatteryLevel(latestBatteryLevel) : "-"} + + + ); +} + // アコーディオンコンテンツの最大高さ(アニメーション用) const ACCORDION_MAX_HEIGHT_BASE = 100; const ACCORDION_MAX_HEIGHT_WITH_ROUTES = 200; @@ -60,6 +124,7 @@ export default function MapScreen() { const lineColors = useLineColors(state.lineIds); const mapRef = useRef(null); const [isFollowing, setIsFollowing] = useState(true); + const [activeCalloutId, setActiveCalloutId] = useState(null); const selectedMarkerIdRef = useRef(null); const markerRefs = useRef>(new Map()); @@ -123,6 +188,7 @@ export default function MapScreen() { // マーカーをタップしたら選択状態にする const handleMarkerPress = useCallback((deviceId: string) => { selectedMarkerIdRef.current = deviceId; + setActiveCalloutId(deviceId); }, []); // マップ背景をタップしたら吹き出しを明示的に閉じる @@ -134,11 +200,13 @@ export default function MapScreen() { } } selectedMarkerIdRef.current = null; + setActiveCalloutId(null); }, []); // 吹き出しをタップしたら閉じる const handleCalloutPress = useCallback((deviceId: string) => { selectedMarkerIdRef.current = null; + setActiveCalloutId(null); const marker = markerRefs.current.get(deviceId); if (marker) { marker.hideCallout(); @@ -515,28 +583,19 @@ export default function MapScreen() { > {Callout && ( - - - {trajectory.deviceId} - - - {stateLabel} - - - - 最新位置 - {trajectory.latestLineId && lineNames[trajectory.latestLineId] && ( - 🚆 {lineNames[trajectory.latestLineId]} - )} - - 🏎️ {formatSpeed(trajectory.latestSpeed)} - 🎯 {formatAccuracy(trajectory.latestAccuracy)} - 🔋 {trajectory.latestBatteryLevel != null ? formatBatteryLevel(trajectory.latestBatteryLevel) : "-"} - - + )} diff --git a/components/location-card.tsx b/components/location-card.tsx index 403a3c3..1e564e9 100644 --- a/components/location-card.tsx +++ b/components/location-card.tsx @@ -62,6 +62,37 @@ export function formatBatteryLevel(level: number): string { return `${Math.round(level * 100)}%`; } +export function formatTimeAgo(timestamp: number): string { + const now = Date.now(); + const diffMs = now - timestamp; + if (diffMs <= 0) { + return "たった今"; + } + const diffSec = Math.floor(diffMs / 1000); + + if (diffSec < 60) { + return `${diffSec}秒前`; + } + const diffMin = Math.floor(diffSec / 60); + if (diffMin < 60) { + return `${diffMin}分前`; + } + const diffHour = Math.floor(diffMin / 60); + if (diffHour < 24) { + return `${diffHour}時間前`; + } + const diffDay = Math.floor(diffHour / 24); + if (diffDay < 30) { + return `${diffDay}日前`; + } + const diffMonth = Math.floor(diffDay / 30); + if (diffMonth < 12) { + return `${diffMonth}ヶ月前`; + } + const diffYear = Math.floor(diffMonth / 12); + return `${diffYear}年前`; +} + const batteryStateLabels: Record = { charging: "充電中", unplugged: "未接続", diff --git a/hooks/use-device-trajectory.ts b/hooks/use-device-trajectory.ts index c082729..b3ef676 100644 --- a/hooks/use-device-trajectory.ts +++ b/hooks/use-device-trajectory.ts @@ -22,6 +22,7 @@ export interface DeviceTrajectory { latestBatteryLevel: number | null; latestBatteryState: BatteryState | number | null; latestLineId: string | null; + latestTimestamp: number | null; } /** @@ -95,6 +96,7 @@ export function useDeviceTrajectory( latestBatteryLevel: latestUpdate?.battery_level ?? null, latestBatteryState: latestUpdate?.battery_state ?? null, latestLineId: latestUpdate?.line_id ?? null, + latestTimestamp: latestUpdate?.timestamp ?? null, }); }