Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
119 changes: 76 additions & 43 deletions app/(tabs)/map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -57,6 +57,7 @@ export default function MapScreen() {
const [selectedRoutes, setSelectedRoutes] = useState<Set<string>>(new Set());
const [isFilterExpanded, setIsFilterExpanded] = useState(true);
const lineNames = useLineNames(state.lineIds);
const lineColors = useLineColors(state.lineIds);
const mapRef = useRef<MapViewRef | null>(null);
const [isFollowing, setIsFollowing] = useState(true);
const selectedMarkerIdRef = useRef<string | null>(null);
Expand Down Expand Up @@ -325,7 +326,6 @@ export default function MapScreen() {
</View>
</TouchableOpacity>
{state.deviceIds.map((device) => {
const deviceColor = getDeviceColor(device, state.deviceIds);
const isSelected = selectedDevices.has(device);
return (
<TouchableOpacity
Expand All @@ -335,18 +335,18 @@ export default function MapScreen() {
style={styles.filterButton}
>
<View
className="px-3 py-2 rounded-full"
style={{
backgroundColor: isSelected ? deviceColor : "transparent",
borderWidth: 1,
borderColor: deviceColor,
}}
className={cn(
"px-3 py-2 rounded-full border",
isSelected
? "bg-primary border-primary"
: "bg-background border-border"
)}
>
<Text
className="text-sm font-medium"
style={{
color: isSelected ? "#FFFFFF" : deviceColor,
}}
className={cn(
"text-sm font-medium",
isSelected ? "text-white" : "text-foreground"
)}
>
{device}
</Text>
Expand Down Expand Up @@ -393,33 +393,42 @@ export default function MapScreen() {
</Text>
</View>
</TouchableOpacity>
{state.lineIds.map((lineId) => (
<TouchableOpacity
key={lineId}
onPress={() => handleRouteSelect(lineId)}
activeOpacity={0.7}
style={styles.filterButton}
>
<View
className={cn(
"px-3 py-2 rounded-full border",
selectedRoutes.has(lineId)
? "bg-primary border-primary"
: "bg-background border-border"
)}
{state.lineIds.map((lineId) => {
const lineColor = lineColors[lineId];
const isSelected = selectedRoutes.has(lineId);
return (
<TouchableOpacity
key={lineId}
onPress={() => handleRouteSelect(lineId)}
activeOpacity={0.7}
style={styles.filterButton}
>
<Text
<View
className={cn(
"text-sm font-medium",
selectedRoutes.has(lineId) ? "text-white" : "text-foreground"
"px-3 py-2 rounded-full",
!lineColor && "border",
!lineColor && (isSelected ? "bg-primary border-primary" : "bg-background border-border")
)}
numberOfLines={1}
style={lineColor ? {
backgroundColor: isSelected ? lineColor : "transparent",
borderWidth: 1,
borderColor: lineColor,
} : undefined}
>
{lineNames[lineId] ? <><Text style={{ fontWeight: "bold" }}>{lineNames[lineId]}</Text>({lineId})</> : lineId}
</Text>
</View>
</TouchableOpacity>
))}
<Text
className={cn(
"text-sm font-medium",
!lineColor && (isSelected ? "text-white" : "text-foreground")
)}
style={lineColor ? { color: isSelected ? "#FFFFFF" : lineColor } : undefined}
numberOfLines={1}
>
{lineNames[lineId] ? <><Text style={{ fontWeight: "bold" }}>{lineNames[lineId]}</Text>({lineId})</> : lineId}
</Text>
</View>
</TouchableOpacity>
);
})}
</ScrollView>
</View>
)}
Expand Down Expand Up @@ -456,12 +465,29 @@ export default function MapScreen() {
>
{trajectories.map((trajectory) => (
<Fragment key={trajectory.deviceId}>
{Polyline && trajectory.coordinates.length > 1 && (
<Polyline
coordinates={trajectory.coordinates}
strokeColor={getDeviceColor(trajectory.deviceId, state.deviceIds)}
strokeWidth={4}
/>
{Polyline && trajectory.segments.map((segment, i) =>
segment.coordinates.length > 1 && (
<Polyline
key={`${trajectory.deviceId}-${i}`}
coordinates={segment.coordinates}
strokeColor={(segment.lineId && lineColors[segment.lineId]) || MAP_TRAJECTORY_COLORS[0]}
strokeWidth={4}
/>
)
)}
{Marker && trajectory.coordinates.length > 0 && (
<Marker
coordinate={trajectory.coordinates[0]}
anchor={{ x: 0.5, y: 0.5 }}
stopPropagation
>
<View
style={[
styles.startMarker,
{ borderColor: (trajectory.segments[0]?.lineId && lineColors[trajectory.segments[0].lineId]) || MAP_TRAJECTORY_COLORS[0] },
]}
/>
</Marker>
)}
{Marker && trajectory.latestPosition && (() => {
const stateConf = trajectory.latestState
Expand All @@ -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)}
Expand Down Expand Up @@ -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,
Expand Down
14 changes: 1 addition & 13 deletions constants/map-colors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
26 changes: 26 additions & 0 deletions hooks/use-device-trajectory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -62,6 +87,7 @@ export function useDeviceTrajectory(
trajectories.push({
deviceId,
coordinates,
segments,
latestPosition,
latestState,
latestSpeed: latestUpdate?.coords.speed ?? null,
Expand Down
37 changes: 32 additions & 5 deletions hooks/use-line-names.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,18 @@ let gqlUrl = Constants.expoConfig?.extra?.trainlcdGqlUrl || "";

// モジュールレベルのキャッシュ(アプリ全体で共有)
let cache: Record<string, string> = {};
let colorCache: Record<string, string> = {};
const fetchedIds = new Set<string>();
const listeners = new Set<() => void>();

function getSnapshot(): Record<string, string> {
return cache;
}

function getColorSnapshot(): Record<string, string> {
return colorCache;
}

function subscribe(listener: () => void): () => void {
listeners.add(listener);
return () => listeners.delete(listener);
Expand All @@ -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();
}
})
Expand All @@ -67,6 +78,16 @@ export function useLineNames(lineIds: string[]): Record<string, string> {
return lineNames;
}

export function useLineColors(lineIds: string[]): Record<string, string> {
const lineColors = useSyncExternalStore(subscribe, getColorSnapshot, getColorSnapshot);

useEffect(() => {
fetchLineNames(lineIds);
}, [lineIds]);

return lineColors;
}

export function formatLineName(
lineId: string,
lineNames: Record<string, string>
Expand All @@ -78,6 +99,7 @@ export function formatLineName(
/** テスト用: キャッシュとfetchedIdsをリセット */
export function _resetCache() {
cache = {};
colorCache = {};
fetchedIds.clear();
listeners.clear();
}
Expand All @@ -87,6 +109,11 @@ export function _getCache(): Record<string, string> {
return cache;
}

/** テスト用: 現在のカラーキャッシュを返す */
export function _getColorCache(): Record<string, string> {
return colorCache;
}

/** テスト用: GQL URLを上書き */
export function _setGqlUrl(url: string) {
gqlUrl = url;
Expand Down
Loading