Files
jrshikoku/components/ActionSheetComponents/TrainDataSources.tsx

740 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { FC } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
ScrollView,
Platform,
Image,
} from "react-native";
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import type { NavigateFunction } from "@/types";
import type { OperationLogs, CustomTrainData } from "@/lib/CommonTypes";
import type { UnyohubData } from "@/types/unyohub";
export type TrainDataSourcesPayload = {
trainNum: string;
unyohubEntries: UnyohubData[];
todayOperation: OperationLogs[];
navigate: NavigateFunction;
expoPushToken: string;
/** customTrainDataDetector から取得した priority 値 */
priority: number;
/** 進行方向: true = 上り (vehicle_img) / false = 下り (vehicle_img_right)
* 未指定の場合は列番の奇数偶数でフォールバック */
direction?: boolean;
/** customTrainDataDetector の全データ */
customTrainData?: CustomTrainData;
/** 種別名 (e.g. "特急") */
typeName?: string;
/** 列車名・行先 (e.g. "モーニングEXP高松 高松行") */
trainName?: string;
/** 始発駅名 */
departureStation?: string;
/** 終着駅名(ダイヤから) */
destinationStation?: string;
};
const HUB_LOGO_PNG = require("@/assets/icons/hub_logo.png");
export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
payload,
}) => {
if (!payload) return null;
const { trainNum, unyohubEntries, todayOperation, navigate, expoPushToken, priority, direction: directionProp, customTrainData, typeName, trainName, departureStation, destinationStation } =
payload;
// 進行方向の確定:
// 1. payload.direction が明示されていればそれを使う
// 2. customTrainData.directions が設定されていればそれを使う
// 3. どちらもなければ列番の偶数 = 上り / 奇数 = 下り でフォールバック
const resolvedDirection: boolean = (() => {
if (directionProp !== undefined) return directionProp;
if (customTrainData?.directions !== undefined) return !!customTrainData.directions;
return parseInt(trainNum.replace(/[^\d]/g, ""), 10) % 2 === 0;
})();
const close = () => SheetManager.hide("TrainDataSources");
const openWebView = (uri: string, useExitButton: boolean) => {
SheetManager.hide("EachTrainInfo");
SheetManager.hide("TrainDataSources");
navigate("generalWebView", { uri, useExitButton });
};
/* ── 各ソースの状態 ─────────────────────────────── */
const opCount = todayOperation.length;
const unyoCount = unyohubEntries.length;
const hasTrainInfo = priority > 200;
// 運用情報: 全エントリの unit_ids をフラットに収集・重複除去
const allUnitIds = [
...new Set(todayOperation.flatMap((op) => op.unit_ids ?? [])),
];
const fallbackIds = todayOperation.flatMap((op) => op.train_ids ?? []).slice(0, 4);
const unitIdsSub = allUnitIds.length > 0
? null
: opCount > 0
? fallbackIds.join("・") || "運用記録あり"
: "本日の運用記録なし";
const operationDetail = opCount > 0 ? (
<View style={styles.operationDetailBlock}>
{allUnitIds.length > 0 ? (
<Text style={styles.unitIdText}>
{allUnitIds.slice(0, 8).join("・")}{allUnitIds.length > 8 ? `${allUnitIds.length - 8}` : ""}
</Text>
) : (
<Text style={styles.subText}>{fallbackIds.join("・") || "運用記録あり"}</Text>
)}
<DirectionBanner direction={resolvedDirection} />
</View>
) : null;
// 鉄道運用Hub: 編成名リスト
const formationNames =
unyohubEntries
.slice(0, 4)
.map((e) => e.formations)
.join("・") + (unyoCount > 4 ? `${unyoCount - 4}` : "");
const formationDetail = unyoCount > 0 ? (
<Text style={styles.unitIdText}>
{formationNames}
</Text>
) : null;
// 列車情報 subテキスト
const trainInfoSub = customTrainData?.vehicle_formation
? customTrainData.vehicle_formation
: hasTrainInfo
? "臨時情報あり / コミュニティ樓所データを確認・編集"
: "編成データ・コミュニティ独自データを確認・編集";
const trainDetail = customTrainData ? (
<TrainInfoDetail
data={customTrainData}
typeName={typeName}
trainName={trainName}
departureStation={departureStation}
destinationStation={destinationStation}
/>
) : null;
return (
<ActionSheet
gestureEnabled
CustomHeaderComponent={<></>}
isModal={Platform.OS === "ios"}
>
{/* ヘッダー */}
<View style={styles.header}>
<View style={styles.handleBar} />
<View style={styles.headerRow}>
<Text style={styles.headerTitle}></Text>
<Text style={styles.headerSub}>{trainNum}</Text>
</View>
</View>
<ScrollView
style={styles.scroll}
contentContainerStyle={styles.scrollContent}
>
{/* ─── jr-shikoku-data-system (列車情報 + 運用情報) ─── */}
<CombinedCard
rows={[
{
icon: "database-search",
title: "列車情報",
sub: null,
badge: hasTrainInfo ? "!" : null,
detail: trainDetail,
},
{
icon: "calendar-clock",
title: "運用情報",
sub: unitIdsSub,
badge: opCount > 0 ? opCount : null,
detail: operationDetail,
},
]}
color="#0099CC"
label="jr-shikoku-data-system"
onPress={() =>
openWebView(
`https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`,
false
)
}
/>
{/* ─── 鉄道運用Hub ─────────────────────────── */}
<SourceCard
imagePng={HUB_LOGO_PNG}
color="#333"
title="鉄道運用Hub"
label="外部コミュニティデータ"
sub={unyoCount > 0 ? "" : "運用データなし / 新規投稿もここから"}
badge={unyoCount > 0 ? unyoCount : null}
badgeColor="#333"
detail={formationDetail}
onPress={() =>
openWebView(
`https://jr-shikoku-data-system.pages.dev/unyohub-connection-train-data/${trainNum}`,
true
)
}
/>
</ScrollView>
<View style={styles.footer} />
</ActionSheet>
);
};
/* ------------------------------------------------------------------ */
/* DirectionBanner: 進行方向表示 */
/* ------------------------------------------------------------------ */
const DirectionBanner: FC<{ direction: boolean }> = ({ direction }) => (
<View style={styles.directionBanner}>
{direction ? (
<>
<MaterialCommunityIcons name="arrow-left" size={13} color="#0099CC" />
<Text style={styles.directionText}></Text>
</>
) : (
<>
<Text style={styles.directionText}></Text>
<MaterialCommunityIcons name="arrow-right" size={13} color="#0099CC" />
</>
)}
</View>
);
/* ------------------------------------------------------------------ */
/* CombinedCard: 複数行を 1 枠にまとめるカード */
/* ------------------------------------------------------------------ */
type CombinedRow = {
icon: string;
title: string;
sub: string | null;
badge: number | string | null;
detail?: React.ReactNode;
};
const CombinedCard: FC<{
rows: CombinedRow[];
color: string;
label: string;
onPress: () => void;
}> = ({ rows, color, label, onPress }) => (
<TouchableOpacity style={[styles.card, styles.combinedCard]} activeOpacity={0.7} onPress={onPress}>
{/* 共通ヘッダー */}
<View style={styles.combinedHeader}>
<View style={[styles.colorBar, { backgroundColor: color }]} />
<Text style={[styles.labelText, styles.combinedLabel]}>{label}</Text>
</View>
{/* 各行 */}
{rows.map((row, i) => (
<React.Fragment key={row.title}>
{i > 0 && <View style={[styles.divider, { marginLeft: 14 }]} />}
<View style={styles.combinedRow}>
{/* 左カラーバー分のスペーサー */}
<View style={{ width: 4 }} />
<View style={[styles.iconWrap, { backgroundColor: color + "18", marginTop: 6 }]}>
<MaterialCommunityIcons name={row.icon as any} size={22} color={color} />
</View>
<View style={styles.textWrap}>
<View style={styles.titleRow}>
<Text style={styles.cardTitle}>{row.title}</Text>
{row.badge !== null && (
<View style={[styles.badge, { backgroundColor: color }]}>
<Text style={styles.badgeText}>{row.badge}</Text>
</View>
)}
</View>
{row.sub !== null && (
<Text style={styles.subText} numberOfLines={1}>{row.sub}</Text>
)}
{row.detail && (
<View style={styles.detailWrap}>{row.detail}</View>
)}
</View>
{i === rows.length - 1 && (
<MaterialCommunityIcons name="chevron-right" size={20} color="#ccc" style={{ marginRight: 10, marginTop: 16 }} />
)}
</View>
</React.Fragment>
))}
</TouchableOpacity>
);
/* ------------------------------------------------------------------ */
/* TrainInfoDetail: 列車情報の構造化表示 */
/* ------------------------------------------------------------------ */
const TrainInfoDetail: FC<{
data: CustomTrainData;
typeName?: string;
trainName?: string;
departureStation?: string;
destinationStation?: string;
}> = ({ data, typeName, trainName, departureStation, destinationStation }) => {
const { infogram, train_info, vehicle_formation, via_data, start_date, end_date, uwasa, optional_text } = data;
// to_data がなければダイヤの終着駅をフォールバック
const destDisplay = data.to_data || destinationStation || "";
const hasAny = typeName || trainName || departureStation || destDisplay || infogram || train_info || vehicle_formation || via_data || start_date || end_date || uwasa || optional_text;
if (!hasAny) return null;
return (
<View style={styles.trainDetail}>
{/* 種別・列車名・始発→行先 */}
{(typeName || trainName || departureStation || destDisplay) && (
<View style={styles.trainTitleBlock}>
<View style={styles.trainHeaderRow}>
{!!typeName && (
<View style={styles.typeTag}>
<Text style={styles.typeTagText}>{typeName}</Text>
</View>
)}
{!!trainName && (
<Text style={styles.trainNameText} numberOfLines={1}>{trainName}</Text>
)}
</View>
{!!(departureStation || destDisplay) && (
<View style={styles.routeRow}>
{!!departureStation && (
<Text style={styles.routeStation}>{departureStation}</Text>
)}
{!!(departureStation && destDisplay) && (
<MaterialCommunityIcons name="arrow-right" size={13} color="#aaa" />
)}
{!!destDisplay && (
<Text style={[styles.routeStation, styles.routeDest]}>{destDisplay}</Text>
)}
</View>
)}
</View>
)}
{/* LED インフォグラム */}
{!!infogram && (
<View style={styles.ledSection}>
<Text style={styles.detailSectionLabel}></Text>
<View style={styles.ledDisplay}>
<Text style={styles.ledText}>{infogram}</Text>
</View>
</View>
)}
{/* 列車情報テキスト */}
{!!train_info && (
<View style={styles.trainInfoSection}>
<Text style={styles.detailSectionLabel}></Text>
<Text style={styles.trainInfoText}>{train_info}</Text>
</View>
)}
{/* メタ情報チップ行 */}
{!!(vehicle_formation || via_data || start_date || end_date) && (
<View style={styles.metaChipRow}>
{!!vehicle_formation && (
<View style={styles.metaChip}>
<MaterialCommunityIcons name="train-car" size={11} color="#444" />
<Text style={styles.metaChipText}>{vehicle_formation}</Text>
</View>
)}
{!!via_data && (
<View style={styles.metaChip}>
<MaterialCommunityIcons name="map-marker-path" size={11} color="#444" />
<Text style={styles.metaChipText}>: {via_data}</Text>
</View>
)}
{!!(start_date || end_date) && (
<View style={[styles.metaChip, styles.metaChipOrange]}>
<MaterialCommunityIcons name="calendar-range" size={11} color="#bf360c" />
<Text style={[styles.metaChipText, { color: "#bf360c" }]}>
{start_date ?? ""}{end_date ?? ""}
</Text>
</View>
)}
</View>
)}
{/* うわさ / optional_text */}
{(!!uwasa || !!optional_text) && (
<View style={styles.noteSection}>
{!!uwasa && (
<View style={styles.noteRow}>
<MaterialCommunityIcons name="message-text-outline" size={12} color="#888" />
<Text style={styles.noteText}>{uwasa}</Text>
</View>
)}
{!!optional_text && (
<View style={styles.noteRow}>
<MaterialCommunityIcons name="information-outline" size={12} color="#888" />
<Text style={styles.noteText}>{optional_text}</Text>
</View>
)}
</View>
)}
</View>
);
};
/* ------------------------------------------------------------------ */
/* SourceCard */
/* ------------------------------------------------------------------ */
type SourceCardProps = {
icon?: string;
imagePng?: any;
color: string;
title: string;
label: string;
sub?: string;
badge: number | string | null;
badgeColor: string;
onPress?: () => void;
detail?: React.ReactNode;
};
const SourceCard: FC<SourceCardProps> = ({
icon,
imagePng,
color,
title,
label,
sub,
badge,
badgeColor,
onPress,
detail,
}) => (
<TouchableOpacity
style={styles.card}
activeOpacity={0.7}
onPress={onPress}
>
{/* 左カラーバー */}
<View style={[styles.colorBar, { backgroundColor: color }]} />
{/* アイコン */}
<View style={[styles.iconWrap, { backgroundColor: color + "18" }]}>
{imagePng ? (
<Image source={imagePng} style={{ width: 28, height: 28 }} />
) : (
<MaterialCommunityIcons
name={(icon ?? "information") as any}
size={24}
color={color}
/>
)}
</View>
{/* テキスト */}
<View style={styles.textWrap}>
<View style={styles.titleRow}>
<Text style={styles.cardTitle}>{title}</Text>
<Text style={styles.labelText}>{label}</Text>
</View>
{sub && <Text style={styles.subText} numberOfLines={1}>{sub}</Text>}
{detail && <View style={styles.detailWrap}>{detail}</View>}
</View>
{/* バッジ + 矢印 */}
<View style={styles.rightArea}>
{badge !== null ? (
<View style={[styles.badge, { backgroundColor: badgeColor }]}>
<Text style={styles.badgeText}>{badge}</Text>
</View>
) : null}
<MaterialCommunityIcons name="chevron-right" size={20} color="#ccc" />
</View>
</TouchableOpacity>
);
/* ------------------------------------------------------------------ */
/* スタイル */
/* ------------------------------------------------------------------ */
const styles = StyleSheet.create({
header: {
paddingTop: 8,
paddingBottom: 12,
paddingHorizontal: 16,
},
handleBar: {
width: 40,
height: 5,
borderRadius: 3,
backgroundColor: "#ddd",
alignSelf: "center",
marginBottom: 12,
},
headerRow: {
flexDirection: "row",
alignItems: "baseline",
gap: 8,
},
headerTitle: {
fontSize: 17,
fontWeight: "bold",
color: "#111",
},
headerSub: {
fontSize: 14,
color: "#888",
},
trainHeaderRow: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: 6,
},
trainTitleBlock: {
gap: 4,
paddingBottom: 4,
borderBottomWidth: StyleSheet.hairlineWidth,
borderBottomColor: "#e8e8e8",
marginBottom: 4,
},
typeTag: {
backgroundColor: "#0099CC",
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
},
typeTagText: {
color: "#fff",
fontSize: 13,
fontWeight: "bold",
},
trainNameText: {
fontSize: 16,
fontWeight: "bold",
color: "#111",
flexShrink: 1,
},
routeRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
routeStation: {
fontSize: 13,
color: "#555",
},
routeDest: {
fontWeight: "bold",
color: "#111",
},
scroll: {},
scrollContent: {
paddingHorizontal: 14,
gap: 10,
paddingBottom: 4,
},
card: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#fff",
borderRadius: 12,
borderWidth: 1,
borderColor: "#ebebeb",
overflow: "hidden",
minHeight: 70,
},
combinedCard: {
flexDirection: "column",
alignItems: "stretch",
},
combinedHeader: {
flexDirection: "row",
alignItems: "center",
paddingTop: 8,
paddingBottom: 2,
gap: 8,
},
combinedLabel: {
color: "#888",
marginLeft: 4,
},
combinedRow: {
flexDirection: "row",
alignItems: "flex-start",
minHeight: 58,
paddingVertical: 6,
},
divider: {
height: StyleSheet.hairlineWidth,
backgroundColor: "#ebebeb",
},
colorBar: {
width: 4,
alignSelf: "stretch",
},
iconWrap: {
width: 48,
height: 48,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
marginHorizontal: 10,
flexShrink: 0,
},
textWrap: {
flex: 1,
paddingVertical: 6,
gap: 3,
},
titleRow: {
flexDirection: "row",
alignItems: "center",
gap: 6,
},
cardTitle: {
fontSize: 15,
fontWeight: "bold",
color: "#111",
},
cardTitleDisabled: {
color: "#aaa",
},
labelText: {
fontSize: 10,
color: "#aaa",
fontWeight: "500",
},
subText: {
fontSize: 12,
color: "#555",
},
subTextDisabled: {
color: "#bbb",
},
rightArea: {
flexDirection: "row",
alignItems: "center",
paddingRight: 10,
gap: 4,
},
badge: {
minWidth: 20,
height: 20,
borderRadius: 10,
alignItems: "center",
justifyContent: "center",
paddingHorizontal: 5,
},
badgeText: {
color: "#fff",
fontSize: 11,
fontWeight: "bold",
},
operationDetailBlock: {
//marginTop: 4,
gap: 6,
},
unitIdText: {
fontSize: 13,
fontWeight: "bold",
color: "#0077aa",
letterSpacing: 0.5,
},
footer: {
height: 20,
},
/* ── TrainInfoDetail ──────────────── */
detailWrap: {
marginTop: 4,
marginBottom: 4,
},
trainDetail: {
gap: 8,
},
ledSection: {
gap: 4,
},
detailSectionLabel: {
fontSize: 10,
color: "#999",
fontWeight: "600",
letterSpacing: 0.5,
},
ledDisplay: {
backgroundColor: "#1a1a1a",
borderRadius: 5,
paddingHorizontal: 10,
paddingVertical: 5,
alignSelf: "flex-start",
},
ledText: {
fontFamily: "JNR-font",
fontSize: 20,
color: "#ff8800",
letterSpacing: 2,
},
trainInfoSection: {
gap: 3,
},
trainInfoText: {
fontSize: 12,
color: "#333",
lineHeight: 18,
},
metaChipRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: 5,
},
metaChip: {
flexDirection: "row",
alignItems: "center",
backgroundColor: "#f0f0f0",
borderRadius: 5,
paddingHorizontal: 7,
paddingVertical: 3,
gap: 4,
},
metaChipOrange: {
backgroundColor: "#fff3e0",
},
metaChipText: {
fontSize: 11,
color: "#444",
},
noteSection: {
gap: 4,
borderLeftWidth: 2,
borderLeftColor: "#e0e0e0",
paddingLeft: 8,
},
noteRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: 4,
},
noteText: {
fontSize: 11,
color: "#666",
lineHeight: 16,
flex: 1,
},
directionBanner: {
flexDirection: "row",
alignItems: "center",
gap: 4,
backgroundColor: "#E3F4FB",
borderRadius: 5,
paddingHorizontal: 7,
paddingVertical: 3,
marginTop: 4,
alignSelf: "flex-start",
},
directionText: {
fontSize: 11,
fontWeight: "600",
color: "#0099CC",
},
});