diff --git a/app/(tabs)/map.tsx b/app/(tabs)/map.tsx index 6de047f..ada35f7 100644 --- a/app/(tabs)/map.tsx +++ b/app/(tabs)/map.tsx @@ -25,9 +25,9 @@ import { useDeviceTrajectory, getAllCoordinates, } from "@/hooks/use-device-trajectory"; -import { getDeviceColor } from "@/constants/map-colors"; +import { MAP_TRAJECTORY_COLORS } from "@/constants/map-colors"; import type { MovingState } from "@/lib/types/location"; -import { useLineNames } from "@/hooks/use-line-names"; +import { useLineNames, useLineColors } from "@/hooks/use-line-names"; // react-native-maps は Web では使えないので条件付きインポート let MapView: typeof import("react-native-maps").default | null = null; @@ -57,6 +57,7 @@ export default function MapScreen() { const [selectedRoutes, setSelectedRoutes] = useState>(new Set()); const [isFilterExpanded, setIsFilterExpanded] = useState(true); const lineNames = useLineNames(state.lineIds); + const lineColors = useLineColors(state.lineIds); const mapRef = useRef(null); const [isFollowing, setIsFollowing] = useState(true); const selectedMarkerIdRef = useRef(null); @@ -325,7 +326,6 @@ export default function MapScreen() { {state.deviceIds.map((device) => { - const deviceColor = getDeviceColor(device, state.deviceIds); const isSelected = selectedDevices.has(device); return ( {device} @@ -393,33 +393,42 @@ export default function MapScreen() { - {state.lineIds.map((lineId) => ( - handleRouteSelect(lineId)} - activeOpacity={0.7} - style={styles.filterButton} - > - { + const lineColor = lineColors[lineId]; + const isSelected = selectedRoutes.has(lineId); + return ( + handleRouteSelect(lineId)} + activeOpacity={0.7} + style={styles.filterButton} > - - {lineNames[lineId] ? <>{lineNames[lineId]}({lineId}) : lineId} - - - - ))} + + {lineNames[lineId] ? <>{lineNames[lineId]}({lineId}) : lineId} + + + + ); + })} )} @@ -456,12 +465,29 @@ export default function MapScreen() { > {trajectories.map((trajectory) => ( - {Polyline && trajectory.coordinates.length > 1 && ( - + {Polyline && trajectory.segments.map((segment, i) => + segment.coordinates.length > 1 && ( + + ) + )} + {Marker && trajectory.coordinates.length > 0 && ( + + + )} {Marker && trajectory.latestPosition && (() => { const stateConf = trajectory.latestState @@ -482,7 +508,7 @@ export default function MapScreen() { } }} coordinate={trajectory.latestPosition} - pinColor={getDeviceColor(trajectory.deviceId, state.deviceIds)} + pinColor={(trajectory.latestLineId && lineColors[trajectory.latestLineId]) || MAP_TRAJECTORY_COLORS[0]} stopPropagation onPress={() => handleMarkerPress(trajectory.deviceId)} onCalloutPress={() => handleCalloutPress(trajectory.deviceId)} @@ -578,6 +604,13 @@ const styles = StyleSheet.create({ fontSize: 14, fontWeight: "600", }, + startMarker: { + width: 14, + height: 14, + borderRadius: 7, + backgroundColor: "#FFFFFF", + borderWidth: 3, + }, calloutContainer: { minWidth: 140, padding: 8, diff --git a/constants/map-colors.ts b/constants/map-colors.ts index bf02052..688e147 100644 --- a/constants/map-colors.ts +++ b/constants/map-colors.ts @@ -12,16 +12,4 @@ export const MAP_TRAJECTORY_COLORS = [ "#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]; -} +] as const; \ No newline at end of file diff --git a/hooks/use-device-trajectory.ts b/hooks/use-device-trajectory.ts index 07c6288..c082729 100644 --- a/hooks/use-device-trajectory.ts +++ b/hooks/use-device-trajectory.ts @@ -6,9 +6,15 @@ export interface Coordinate { longitude: number; } +export interface LineSegment { + lineId: string | null; + coordinates: Coordinate[]; +} + export interface DeviceTrajectory { deviceId: string; coordinates: Coordinate[]; + segments: LineSegment[]; latestPosition: Coordinate | null; latestState: MovingState | null; latestSpeed: number | null | undefined; @@ -54,6 +60,25 @@ export function useDeviceTrajectory( longitude: u.coords.longitude, })); + // 路線ごとにセグメントを分割(連続する同一路線をまとめる) + const segments: LineSegment[] = []; + for (const update of sorted) { + const coord: Coordinate = { + latitude: update.coords.latitude, + longitude: update.coords.longitude, + }; + const current = segments[segments.length - 1]; + if (current && current.lineId === update.line_id) { + current.coordinates.push(coord); + } else { + // 前セグメントの末尾座標を引き継いで接続を維持 + const initial = current?.coordinates.length + ? [current.coordinates[current.coordinates.length - 1], coord] + : [coord]; + segments.push({ lineId: update.line_id, coordinates: initial }); + } + } + const latestUpdate = sorted.length > 0 ? sorted[sorted.length - 1] : null; const latestPosition = coordinates.length > 0 ? coordinates[coordinates.length - 1] : null; @@ -62,6 +87,7 @@ export function useDeviceTrajectory( trajectories.push({ deviceId, coordinates, + segments, latestPosition, latestState, latestSpeed: latestUpdate?.coords.speed ?? null, diff --git a/hooks/use-line-names.ts b/hooks/use-line-names.ts index f4e267d..e3fe8a0 100644 --- a/hooks/use-line-names.ts +++ b/hooks/use-line-names.ts @@ -5,6 +5,7 @@ let gqlUrl = Constants.expoConfig?.extra?.trainlcdGqlUrl || ""; // モジュールレベルのキャッシュ(アプリ全体で共有) let cache: Record = {}; +let colorCache: Record = {}; const fetchedIds = new Set(); const listeners = new Set<() => void>(); @@ -12,6 +13,10 @@ function getSnapshot(): Record { return cache; } +function getColorSnapshot(): Record { + return colorCache; +} + function subscribe(listener: () => void): () => void { listeners.add(listener); return () => listeners.delete(listener); @@ -31,26 +36,32 @@ export function fetchLineNames(ids: string[]) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ - query: `query Lines($lineIds: [Int!]!) { lines(lineIds: $lineIds) { id nameShort } }`, + query: `query Lines($lineIds: [Int!]!) { lines(lineIds: $lineIds) { id nameShort color } }`, variables: { lineIds: newIds.map(Number) }, }), }) .then((res) => res.json()) .then((json) => { const lines = json?.data?.lines as - | { id: number; nameShort: string }[] + | { id: number; nameShort: string; color: string | null }[] | undefined; if (!lines) return; let updated = false; - const next = { ...cache }; + const nextNames = { ...cache }; + const nextColors = { ...colorCache }; for (const line of lines) { if (line.nameShort) { - next[String(line.id)] = line.nameShort; + nextNames[String(line.id)] = line.nameShort; + updated = true; + } + if (line.color) { + nextColors[String(line.id)] = line.color.startsWith("#") ? line.color : `#${line.color}`; updated = true; } } if (updated) { - cache = next; + cache = nextNames; + colorCache = nextColors; notify(); } }) @@ -67,6 +78,16 @@ export function useLineNames(lineIds: string[]): Record { return lineNames; } +export function useLineColors(lineIds: string[]): Record { + const lineColors = useSyncExternalStore(subscribe, getColorSnapshot, getColorSnapshot); + + useEffect(() => { + fetchLineNames(lineIds); + }, [lineIds]); + + return lineColors; +} + export function formatLineName( lineId: string, lineNames: Record @@ -78,6 +99,7 @@ export function formatLineName( /** テスト用: キャッシュとfetchedIdsをリセット */ export function _resetCache() { cache = {}; + colorCache = {}; fetchedIds.clear(); listeners.clear(); } @@ -87,6 +109,11 @@ export function _getCache(): Record { return cache; } +/** テスト用: 現在のカラーキャッシュを返す */ +export function _getColorCache(): Record { + return colorCache; +} + /** テスト用: GQL URLを上書き */ export function _setGqlUrl(url: string) { gqlUrl = url; diff --git a/lib/__tests__/use-line-names.test.ts b/lib/__tests__/use-line-names.test.ts index 0d27227..613c795 100644 --- a/lib/__tests__/use-line-names.test.ts +++ b/lib/__tests__/use-line-names.test.ts @@ -14,6 +14,7 @@ import { fetchLineNames, _resetCache, _getCache, + _getColorCache, _setGqlUrl, } from "../../hooks/use-line-names"; @@ -27,7 +28,7 @@ afterEach(() => { vi.restoreAllMocks(); }); -function mockFetchResponse(lines: { id: number; nameShort: string }[]) { +function mockFetchResponse(lines: { id: number; nameShort: string; color?: string | null }[]) { fetchMock.mockResolvedValueOnce({ json: () => Promise.resolve({ data: { lines } }), }); @@ -144,4 +145,43 @@ describe("fetchLineNames", () => { // キャッシュが更新されたことで正しい値が入っている expect(_getCache()["11302"]).toBe("山手線"); }); + + it("colorフィールドがある場合はカラーキャッシュに#付きで保存される", async () => { + mockFetchResponse([ + { id: 11302, nameShort: "山手線", color: "80C241" }, + { id: 24006, nameShort: "京王井の頭線", color: "DD0077" }, + ]); + + fetchLineNames(["11302", "24006"]); + await vi.waitFor(() => expect(_getColorCache()).toHaveProperty("11302")); + + expect(_getColorCache()).toEqual({ + "11302": "#80C241", + "24006": "#DD0077", + }); + }); + + it("colorが既に#付きの場合は二重に付けない", async () => { + mockFetchResponse([ + { id: 11302, nameShort: "山手線", color: "#80C241" }, + ]); + + fetchLineNames(["11302"]); + await vi.waitFor(() => expect(_getColorCache()).toHaveProperty("11302")); + + expect(_getColorCache()).toEqual({ "11302": "#80C241" }); + }); + + it("colorがnullの場合はカラーキャッシュに入れない", async () => { + mockFetchResponse([ + { id: 11302, nameShort: "山手線", color: "80C241" }, + { id: 24006, nameShort: "京王井の頭線", color: null }, + ]); + + fetchLineNames(["11302", "24006"]); + await vi.waitFor(() => expect(_getColorCache()).toHaveProperty("11302")); + + expect(_getColorCache()).toEqual({ "11302": "#80C241" }); + expect(_getColorCache()).not.toHaveProperty("24006"); + }); });