540 lines
16 KiB
TypeScript
540 lines
16 KiB
TypeScript
import React, { FC, useMemo } from "react";
|
||
import {
|
||
View,
|
||
Text,
|
||
TouchableOpacity,
|
||
StyleSheet,
|
||
ScrollView,
|
||
Image,
|
||
} from "react-native";
|
||
import { MaterialCommunityIcons, Ionicons } from "@expo/vector-icons";
|
||
import { SheetManager } from "react-native-actions-sheet";
|
||
import type { NavigateFunction } from "@/types";
|
||
import type { OperationLogs } from "@/lib/CommonTypes";
|
||
import type { UnyohubData } from "@/types/unyohub";
|
||
|
||
const HUB_LOGO_PNG = require("@/assets/icons/hub_logo.png");
|
||
|
||
type Props = {
|
||
trainNum: string;
|
||
unyohubEntries: UnyohubData[];
|
||
todayOperation: OperationLogs[];
|
||
navigate: NavigateFunction;
|
||
expoPushToken: string;
|
||
onClose: () => void;
|
||
/** true = 上り (vehicle_img) / false = 下り (vehicle_img_right) */
|
||
direction: boolean;
|
||
};
|
||
|
||
/** 情報ソース別カードを並べて表示するパネル */
|
||
export const TrainSourcesPanel: FC<Props> = ({
|
||
trainNum,
|
||
unyohubEntries,
|
||
todayOperation,
|
||
navigate,
|
||
expoPushToken,
|
||
onClose,
|
||
direction,
|
||
}) => {
|
||
// 連結番号(train_ids のカンマ後数値)で昇順ソート
|
||
const sortedTodayOperation = useMemo(() => {
|
||
const extractOrder = (trainId: string): number => {
|
||
const parts = trainId.split(",");
|
||
if (parts.length > 1) {
|
||
const n = parseInt(parts[1].trim(), 10);
|
||
return isNaN(n) ? Infinity : n;
|
||
}
|
||
return Infinity;
|
||
};
|
||
const findId = (op: OperationLogs): string | null => {
|
||
for (const id of [...(op.train_ids ?? []), ...(op.related_train_ids ?? [])]) {
|
||
if (id.split(",")[0] === trainNum) return id;
|
||
}
|
||
return null;
|
||
};
|
||
return [...todayOperation].sort((a, b) => {
|
||
const aId = findId(a);
|
||
const bId = findId(b);
|
||
if (!aId || !bId) return aId ? -1 : bId ? 1 : 0;
|
||
return extractOrder(aId) - extractOrder(bId);
|
||
});
|
||
}, [todayOperation, trainNum]);
|
||
|
||
return (
|
||
<View style={styles.container}>
|
||
{/* ヘッダー */}
|
||
<View style={styles.panelHeader}>
|
||
<Text style={styles.panelHeaderText}>運用情報ソース</Text>
|
||
<TouchableOpacity onPress={onClose} style={styles.closeButton}>
|
||
<Ionicons name="close" size={20} color="#0099CC" />
|
||
</TouchableOpacity>
|
||
</View>
|
||
|
||
<ScrollView
|
||
style={styles.scroll}
|
||
contentContainerStyle={styles.scrollContent}
|
||
showsVerticalScrollIndicator={false}
|
||
>
|
||
{/* ──────────────────────────────────────── */}
|
||
{/* JR四国データベース(常に表示) */}
|
||
{/* ──────────────────────────────────────── */}
|
||
<SourceCard
|
||
iconName="database"
|
||
iconColor="#0099CC"
|
||
tag="公式"
|
||
tagColor="#0099CC"
|
||
title="JR四国データベース"
|
||
description="コミュニティが管理する列車データ。編成・運用の詳細を確認・編集できます。"
|
||
available
|
||
onPress={() => {
|
||
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`;
|
||
navigate("generalWebView", { uri, useExitButton: false });
|
||
SheetManager.hide("EachTrainInfo");
|
||
onClose();
|
||
}}
|
||
/>
|
||
|
||
{/* ──────────────────────────────────────── */}
|
||
{/* 鉄道運用Hub */}
|
||
{/* ──────────────────────────────────────── */}
|
||
{unyohubEntries.length > 0 ? (
|
||
<>
|
||
<SectionLabel label="鉄道運用Hub" imagePng={HUB_LOGO_PNG} />
|
||
{unyohubEntries.map((entry, i) => (
|
||
<UnyohubCard key={`unyo-${i}`} entry={entry} trainNum={trainNum} navigate={navigate} onClose={onClose} />
|
||
))}
|
||
</>
|
||
) : (
|
||
<SourceCard
|
||
iconImagePng={HUB_LOGO_PNG}
|
||
iconColor="#333"
|
||
tag="運用Hub"
|
||
tagColor="#555"
|
||
title="鉄道運用Hub"
|
||
description="この列車の運用データはありません。"
|
||
available={false}
|
||
/>
|
||
)}
|
||
|
||
{/* ──────────────────────────────────────── */}
|
||
{/* 今日の運用情報 */}
|
||
{/* ──────────────────────────────────────── */}
|
||
{sortedTodayOperation.length > 0 ? (
|
||
<>
|
||
<SectionLabel label="今日の運用情報" icon="history" />
|
||
<DirectionBanner direction={direction} />
|
||
{sortedTodayOperation.map((op, i) => (
|
||
<OperationCard
|
||
key={`op-${i}`}
|
||
op={op}
|
||
index={i}
|
||
direction={direction}
|
||
navigate={navigate}
|
||
onClose={onClose}
|
||
/>
|
||
))}
|
||
</>
|
||
) : (
|
||
<SourceCard
|
||
iconName="history"
|
||
iconColor="#aaa"
|
||
tag="記録"
|
||
tagColor="#aaa"
|
||
title="今日の運用情報"
|
||
description="本日の運用記録はありません。"
|
||
available={false}
|
||
/>
|
||
)}
|
||
</ScrollView>
|
||
</View>
|
||
);
|
||
};
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* サブコンポーネント */
|
||
/* ------------------------------------------------------------------ */
|
||
|
||
/** セクションラベル */
|
||
const SectionLabel: FC<{ label: string; icon?: string; imagePng?: any }> = ({ label, icon, imagePng }) => (
|
||
<View style={styles.sectionLabel}>
|
||
{imagePng ? (
|
||
<Image source={imagePng} style={{ width: 14, height: 14 }} />
|
||
) : (
|
||
<MaterialCommunityIcons name={(icon ?? "information") as any} size={14} color="#888" />
|
||
)}
|
||
<Text style={styles.sectionLabelText}>{label}</Text>
|
||
</View>
|
||
);
|
||
|
||
/** 汎用ソースカード */
|
||
const SourceCard: FC<{
|
||
iconName?: string;
|
||
iconImagePng?: any;
|
||
iconColor: string;
|
||
tag: string;
|
||
tagColor: string;
|
||
title: string;
|
||
description: string;
|
||
available: boolean;
|
||
onPress?: () => void;
|
||
}> = ({ iconName, iconImagePng, iconColor, tag, tagColor, title, description, available, onPress }) => (
|
||
<TouchableOpacity
|
||
style={[styles.card, !available && styles.cardDisabled]}
|
||
onPress={available ? onPress : undefined}
|
||
activeOpacity={available ? 0.7 : 1}
|
||
>
|
||
<View style={[styles.cardIconWrap, { backgroundColor: iconColor + "22" }]}>
|
||
{iconImagePng ? (
|
||
<Image source={iconImagePng} style={{ width: 24, height: 24 }} />
|
||
) : (
|
||
<MaterialCommunityIcons name={(iconName ?? "information") as any} size={22} color={iconColor} />
|
||
)}
|
||
</View>
|
||
<View style={styles.cardBody}>
|
||
<View style={styles.cardTitleRow}>
|
||
<Text style={[styles.cardTitle, !available && styles.cardTitleDisabled]}>{title}</Text>
|
||
<View style={[styles.tag, { backgroundColor: tagColor + "33" }]}>
|
||
<Text style={[styles.tagText, { color: tagColor }]}>{tag}</Text>
|
||
</View>
|
||
</View>
|
||
<Text style={styles.cardDesc}>{description}</Text>
|
||
</View>
|
||
{available && (
|
||
<MaterialCommunityIcons name="chevron-right" size={18} color="#ccc" />
|
||
)}
|
||
</TouchableOpacity>
|
||
);
|
||
|
||
/** 鉄道運用Hubカード(1編成分) */
|
||
const UnyohubCard: FC<{
|
||
entry: UnyohubData;
|
||
trainNum: string;
|
||
navigate: NavigateFunction;
|
||
onClose: () => void;
|
||
}> = ({ entry, trainNum, navigate, onClose }) => {
|
||
const matchedTrain = entry.trains?.find((t) => t.train_number === trainNum);
|
||
const positionText = matchedTrain
|
||
? `位置: ${matchedTrain.position_forward}〜${matchedTrain.position_rear}両目`
|
||
: null;
|
||
const carInfo = `${entry.car_count}両 (${entry.min_car_count}〜${entry.max_car_count}両)`;
|
||
const timeInfo =
|
||
entry.starting_time && entry.ending_time
|
||
? `${entry.starting_time} → ${entry.ending_time}`
|
||
: null;
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
style={styles.card}
|
||
activeOpacity={0.7}
|
||
onPress={() => {
|
||
// 編成番号+列番でページを開く(将来的にunyohub公式ページへの対応も想定)
|
||
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?formation=${encodeURIComponent(entry.formations)}&source=unyohub`;
|
||
navigate("generalWebView", { uri, useExitButton: true });
|
||
SheetManager.hide("EachTrainInfo");
|
||
onClose();
|
||
}}
|
||
>
|
||
{/* 編成色インジケーター */}
|
||
<View
|
||
style={[
|
||
styles.cardIconWrap,
|
||
{ backgroundColor: "#33333318" },
|
||
]}
|
||
>
|
||
<Image source={HUB_LOGO_PNG} style={{ width: 24, height: 24 }} />
|
||
</View>
|
||
<View style={styles.cardBody}>
|
||
<View style={styles.cardTitleRow}>
|
||
<Text style={styles.cardTitle}>{entry.formations}</Text>
|
||
<View style={[styles.tag, { backgroundColor: "#FF980033" }]}>
|
||
<Text style={[styles.tagText, { color: "#FF9800" }]}>運用Hub</Text>
|
||
</View>
|
||
{entry.from_beginner && (
|
||
<View style={[styles.tag, { backgroundColor: "#9C27B033" }]}>
|
||
<Text style={[styles.tagText, { color: "#9C27B0" }]}>初心者</Text>
|
||
</View>
|
||
)}
|
||
</View>
|
||
|
||
{/* 詳細行 */}
|
||
<View style={styles.unyoDetailRow}>
|
||
{positionText && (
|
||
<DetailChip icon="seat" text={positionText} />
|
||
)}
|
||
<DetailChip icon="train-car" text={carInfo} />
|
||
{timeInfo && <DetailChip icon="clock-outline" text={timeInfo} />}
|
||
{entry.starting_location && (
|
||
<DetailChip icon="map-marker" text={entry.starting_location} />
|
||
)}
|
||
</View>
|
||
|
||
{entry.comment && (
|
||
<Text style={styles.unyoComment}>{entry.comment}</Text>
|
||
)}
|
||
|
||
<View style={styles.postsRow}>
|
||
<MaterialCommunityIcons name="account-group" size={12} color="#888" />
|
||
<Text style={styles.postsText}>{entry.posts_count}件の投稿</Text>
|
||
</View>
|
||
</View>
|
||
<MaterialCommunityIcons name="chevron-right" size={18} color="#ccc" />
|
||
</TouchableOpacity>
|
||
);
|
||
};
|
||
|
||
/** 進行方向バナー */
|
||
const DirectionBanner: FC<{ direction: boolean }> = ({ direction }) => (
|
||
<View style={styles.directionBanner}>
|
||
{direction ? (
|
||
// 上り:矢印は左向き(← 先頭)
|
||
<>
|
||
<MaterialCommunityIcons name="arrow-left" size={14} color="#0099CC" />
|
||
<Text style={styles.directionText}>進行方向(上り)</Text>
|
||
</>
|
||
) : (
|
||
// 下り:矢印は右向き(先頭 →)
|
||
<>
|
||
<Text style={styles.directionText}>進行方向(下り)</Text>
|
||
<MaterialCommunityIcons name="arrow-right" size={14} color="#0099CC" />
|
||
</>
|
||
)}
|
||
</View>
|
||
);
|
||
|
||
/** 今日の運用情報カード */
|
||
const OperationCard: FC<{
|
||
op: OperationLogs;
|
||
index: number;
|
||
direction: boolean;
|
||
navigate: NavigateFunction;
|
||
onClose: () => void;
|
||
}> = ({ op, index, direction, navigate, onClose }) => {
|
||
// 進行方向に応じて車両画像を選択(trainIconStatus.tsx と同ロジック)
|
||
const thumbUri = direction
|
||
? (op.vehicle_img || op.vehicle_img_right)
|
||
: (op.vehicle_img_right || op.vehicle_img);
|
||
const hasUrl = !!op.vehicle_info_url;
|
||
const trainIds = [...(op.train_ids || []), ...(op.related_train_ids || [])];
|
||
|
||
return (
|
||
<TouchableOpacity
|
||
style={[styles.card, !hasUrl && styles.cardNoArrow]}
|
||
activeOpacity={hasUrl ? 0.7 : 1}
|
||
onPress={
|
||
hasUrl
|
||
? () => {
|
||
navigate("howto", { info: op.vehicle_info_url, goTo: "menu" });
|
||
SheetManager.hide("EachTrainInfo");
|
||
onClose();
|
||
}
|
||
: undefined
|
||
}
|
||
>
|
||
{/* 車両サムネイル or アイコン(進行方向考慮) */}
|
||
{thumbUri ? (
|
||
<Image
|
||
source={{ uri: thumbUri }}
|
||
style={styles.vehicleThumb}
|
||
resizeMode="contain"
|
||
/>
|
||
) : (
|
||
<View style={[styles.cardIconWrap, { backgroundColor: "#4CAF5022" }]}>
|
||
<MaterialCommunityIcons name="train" size={22} color="#4CAF50" />
|
||
</View>
|
||
)}
|
||
|
||
<View style={styles.cardBody}>
|
||
<View style={styles.cardTitleRow}>
|
||
<Text style={styles.cardTitle}>運用記録 #{index + 1}</Text>
|
||
<View style={[styles.tag, { backgroundColor: "#4CAF5033" }]}>
|
||
<Text style={[styles.tagText, { color: "#4CAF50" }]}>記録</Text>
|
||
</View>
|
||
</View>
|
||
{trainIds.length > 0 && (
|
||
<Text style={styles.cardDesc} numberOfLines={1}>
|
||
列番: {trainIds.slice(0, 4).join(" / ")}
|
||
{trainIds.length > 4 ? " ..." : ""}
|
||
</Text>
|
||
)}
|
||
{op.date && (
|
||
<Text style={styles.cardDesc}>日付: {op.date}</Text>
|
||
)}
|
||
</View>
|
||
{hasUrl && (
|
||
<MaterialCommunityIcons name="chevron-right" size={18} color="#ccc" />
|
||
)}
|
||
</TouchableOpacity>
|
||
);
|
||
};
|
||
|
||
/** 小さい詳細チップ */
|
||
const DetailChip: FC<{ icon: string; text: string }> = ({ icon, text }) => (
|
||
<View style={styles.chip}>
|
||
<MaterialCommunityIcons name={icon as any} size={11} color="#666" />
|
||
<Text style={styles.chipText}>{text}</Text>
|
||
</View>
|
||
);
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* スタイル */
|
||
/* ------------------------------------------------------------------ */
|
||
const styles = StyleSheet.create({
|
||
container: {
|
||
backgroundColor: "#fff",
|
||
borderTopWidth: 1,
|
||
borderTopColor: "#e0e0e0",
|
||
},
|
||
panelHeader: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
paddingHorizontal: 14,
|
||
paddingVertical: 10,
|
||
borderBottomWidth: 1,
|
||
borderBottomColor: "#f0f0f0",
|
||
},
|
||
panelHeaderText: {
|
||
flex: 1,
|
||
fontSize: 14,
|
||
fontWeight: "bold",
|
||
color: "#333",
|
||
},
|
||
closeButton: {
|
||
padding: 4,
|
||
},
|
||
scroll: {
|
||
maxHeight: 360,
|
||
},
|
||
scrollContent: {
|
||
paddingHorizontal: 12,
|
||
paddingVertical: 8,
|
||
paddingBottom: 16,
|
||
},
|
||
sectionLabel: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
marginTop: 10,
|
||
marginBottom: 4,
|
||
gap: 4,
|
||
},
|
||
sectionLabelText: {
|
||
fontSize: 12,
|
||
color: "#888",
|
||
fontWeight: "600",
|
||
letterSpacing: 0.5,
|
||
},
|
||
card: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
backgroundColor: "#f9f9f9",
|
||
borderRadius: 10,
|
||
marginBottom: 8,
|
||
padding: 10,
|
||
borderWidth: 1,
|
||
borderColor: "#eeeeee",
|
||
gap: 10,
|
||
},
|
||
cardDisabled: {
|
||
opacity: 0.5,
|
||
},
|
||
cardNoArrow: {
|
||
// no change needed
|
||
},
|
||
cardIconWrap: {
|
||
width: 40,
|
||
height: 40,
|
||
borderRadius: 10,
|
||
alignItems: "center",
|
||
justifyContent: "center",
|
||
flexShrink: 0,
|
||
},
|
||
vehicleThumb: {
|
||
width: 50,
|
||
height: 40,
|
||
borderRadius: 6,
|
||
flexShrink: 0,
|
||
backgroundColor: "#eee",
|
||
},
|
||
cardBody: {
|
||
flex: 1,
|
||
gap: 3,
|
||
},
|
||
cardTitleRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 6,
|
||
flexWrap: "wrap",
|
||
},
|
||
cardTitle: {
|
||
fontSize: 14,
|
||
fontWeight: "bold",
|
||
color: "#222",
|
||
},
|
||
cardTitleDisabled: {
|
||
color: "#aaa",
|
||
},
|
||
cardDesc: {
|
||
fontSize: 12,
|
||
color: "#666",
|
||
},
|
||
tag: {
|
||
borderRadius: 4,
|
||
paddingHorizontal: 5,
|
||
paddingVertical: 1,
|
||
},
|
||
tagText: {
|
||
fontSize: 10,
|
||
fontWeight: "bold",
|
||
},
|
||
unyoDetailRow: {
|
||
flexDirection: "row",
|
||
flexWrap: "wrap",
|
||
gap: 4,
|
||
marginTop: 2,
|
||
},
|
||
unyoComment: {
|
||
fontSize: 11,
|
||
color: "#888",
|
||
fontStyle: "italic",
|
||
marginTop: 2,
|
||
},
|
||
postsRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 3,
|
||
marginTop: 2,
|
||
},
|
||
postsText: {
|
||
fontSize: 11,
|
||
color: "#888",
|
||
},
|
||
chip: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
backgroundColor: "#f0f0f0",
|
||
borderRadius: 4,
|
||
paddingHorizontal: 5,
|
||
paddingVertical: 2,
|
||
gap: 3,
|
||
},
|
||
chipText: {
|
||
fontSize: 11,
|
||
color: "#555",
|
||
},
|
||
directionBanner: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
gap: 4,
|
||
backgroundColor: "#E3F4FB",
|
||
borderRadius: 6,
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 4,
|
||
marginBottom: 6,
|
||
alignSelf: "flex-start",
|
||
},
|
||
directionText: {
|
||
fontSize: 11,
|
||
fontWeight: "600",
|
||
color: "#0099CC",
|
||
},
|
||
});
|