1170 lines
33 KiB
TypeScript
1170 lines
33 KiB
TypeScript
import React, { FC, useState, useEffect, useRef } from "react";
|
||
import {
|
||
View,
|
||
Text,
|
||
TouchableOpacity,
|
||
StyleSheet,
|
||
ScrollView,
|
||
Platform,
|
||
Image,
|
||
Animated,
|
||
} from "react-native";
|
||
import ActionSheet, { SheetManager } from "react-native-actions-sheet";
|
||
import { MaterialCommunityIcons } from "@expo/vector-icons";
|
||
import { getTrainType } from "@/lib/getTrainType";
|
||
import type { NavigateFunction } from "@/types";
|
||
import type { OperationLogs, CustomTrainData } from "@/lib/CommonTypes";
|
||
import type { UnyohubData, ElesiteData } from "@/types/unyohub";
|
||
import { useUnyohub } from "@/stateBox/useUnyohub";
|
||
import { useElesite } from "@/stateBox/useElesite";
|
||
|
||
export type TrainDataSourcesPayload = {
|
||
trainNum: string;
|
||
unyohubEntries: UnyohubData[];
|
||
elesiteEntries: ElesiteData[];
|
||
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");
|
||
const ELESITE_LOGO_PNG = require("@/assets/icons/elesite_logo.png");
|
||
|
||
/** ISO 8601 日時文字列を "HH:MM" 形式にフォーマット */
|
||
const formatHHMM = (iso: string): string => {
|
||
try {
|
||
const d = new Date(iso);
|
||
const h = d.getHours().toString().padStart(2, "0");
|
||
const m = d.getMinutes().toString().padStart(2, "0");
|
||
return `${h}:${m}`;
|
||
} catch {
|
||
return "";
|
||
}
|
||
};
|
||
|
||
export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
|
||
payload,
|
||
}) => {
|
||
// ── フックはすべて条件分岐より前に呼ぶ(Rules of Hooks)──
|
||
const { useUnyohub: unyohubEnabled } = useUnyohub();
|
||
const { useElesite: elesiteEnabled } = useElesite();
|
||
|
||
if (!payload) return null;
|
||
|
||
const {
|
||
trainNum,
|
||
unyohubEntries,
|
||
elesiteEntries = [],
|
||
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 elesiteCount = elesiteEntries.length;
|
||
const hasTrainInfo = priority > 200;
|
||
|
||
// 運用情報: train_ids / related_train_ids の位置番号でソートして unit_ids を収集
|
||
// "4565M,2" のようなカンマ区切り位置番号を解析
|
||
// 上り(resolvedDirection=true)=ASC, 下り(false)=DESC
|
||
const positionOf = (op: (typeof todayOperation)[number]): number => {
|
||
const all = [...(op.train_ids ?? []), ...(op.related_train_ids ?? [])];
|
||
const match = all.find((id) => id.split(",")[0] === trainNum);
|
||
const pos = match ? parseInt(match.split(",")[1] ?? "0", 10) : 0;
|
||
return isNaN(pos) ? 0 : pos;
|
||
};
|
||
const sortedOperation = [...todayOperation].sort((a, b) => {
|
||
const diff = positionOf(a) - positionOf(b);
|
||
return resolvedDirection ? diff : -diff;
|
||
});
|
||
const allUnitIds = [
|
||
...new Set(sortedOperation.flatMap((op) => op.unit_ids ?? [])),
|
||
];
|
||
const fallbackIds = sortedOperation
|
||
.flatMap((op) => op.train_ids ?? [])
|
||
.slice(0, 4);
|
||
const unitIdsSub =
|
||
allUnitIds.length > 0
|
||
? null
|
||
: opCount > 0
|
||
? fallbackIds.join("・") || "運用記録あり"
|
||
: "本日の運用記録なし";
|
||
|
||
// 最新運用ログの更新時刻(IDが最大のエントリのdate)
|
||
const latestOperationDate =
|
||
opCount > 0
|
||
? [...todayOperation].sort((a, b) => b.id - a.id)[0]?.date ?? null
|
||
: null;
|
||
|
||
const operationDetail = (
|
||
<View style={styles.operationDetailBlock}>
|
||
{opCount > 0 &&
|
||
(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>
|
||
))}
|
||
{opCount > 0 && <DirectionBanner direction={resolvedDirection} />}
|
||
<CyclingRefDirectionBanner
|
||
rows={[
|
||
{
|
||
leftLabel: "高松/岡山",
|
||
lineLabel: "予讃線",
|
||
rightLabel: "松山/宇和島",
|
||
},
|
||
{ leftLabel: "高松", lineLabel: "高徳線", rightLabel: "徳島" },
|
||
{
|
||
leftLabel: "高松/岡山",
|
||
lineLabel: "土讃線",
|
||
rightLabel: "高知/窪川",
|
||
},
|
||
{ leftLabel: "高松", lineLabel: "瀬戸大橋線", rightLabel: "岡山" },
|
||
{
|
||
leftLabel: "阿波池田/鳴門",
|
||
lineLabel: "徳島/鳴門/牟岐線",
|
||
rightLabel: "牟岐/阿波海南",
|
||
},
|
||
]}
|
||
color="#0099CC"
|
||
/>
|
||
</View>
|
||
);
|
||
|
||
// 鉄道運用Hub: 車番(formations)が空でないエントリのみ抽出して判定
|
||
const hasNonEmptyFormations = unyohubEntries.some(
|
||
(e) => !!e.formations && e.formations.trim() !== "",
|
||
);
|
||
const nonEmptyFormationEntries = unyohubEntries.filter(
|
||
(e) => !!e.formations && e.formations.trim() !== "",
|
||
);
|
||
|
||
// 先頭エントリで direction を取得し、表示順を決定:
|
||
// outbound → position_forward 昇順 (pos=1 が宇和島/南端側)
|
||
// inbound → position_forward 降順 (pos=MAX が宇和島/南端側)
|
||
const matchedDirection = nonEmptyFormationEntries[0]?.trains?.find(
|
||
(t) => t.train_number === trainNum,
|
||
)?.direction;
|
||
const hubSortDescending = matchedDirection === "inbound";
|
||
|
||
const formationNames =
|
||
[...nonEmptyFormationEntries]
|
||
.sort((a, b) => {
|
||
const posA =
|
||
a.trains?.find((t) => t.train_number === trainNum)
|
||
?.position_forward ?? 0;
|
||
const posB =
|
||
b.trains?.find((t) => t.train_number === trainNum)
|
||
?.position_forward ?? 0;
|
||
return hubSortDescending ? posB - posA : posA - posB;
|
||
})
|
||
.slice(0, 4)
|
||
.map((e) => e.formations)
|
||
.join("・") +
|
||
(nonEmptyFormationEntries.length > 4
|
||
? ` 他${nonEmptyFormationEntries.length - 4}件`
|
||
: "");
|
||
|
||
const formationDetail = (
|
||
<View style={styles.operationDetailBlock}>
|
||
{hasNonEmptyFormations && (
|
||
<Text style={styles.unitIdText}>{formationNames}</Text>
|
||
)}
|
||
<RefDirectionBanner
|
||
rows={[{ leftLabel: "宇和島/宿毛/阿波海南" }]}
|
||
color="#888"
|
||
/>
|
||
</View>
|
||
);
|
||
|
||
// えれサイト最終投稿時刻(last_reported_at が最新のエントリ)
|
||
const elesiteLastReportedAt =
|
||
elesiteEntries
|
||
.map((e) => e.report_info?.last_reported_at)
|
||
.filter((d): d is string => !!d)
|
||
.sort()
|
||
.at(-1) ?? null;
|
||
|
||
// えれサイト: units が1件以上あるエントリのみ「データあり」と判定
|
||
const elesiteHasNonEmptyFormations = elesiteEntries.some(
|
||
(e) => (e.formation_config?.units?.length ?? 0) > 0,
|
||
);
|
||
const elesiteNonEmptyFormationEntries = elesiteEntries
|
||
.filter((e) => (e.formation_config?.units?.length ?? 0) > 0)
|
||
.sort((a, b) => {
|
||
// high松(left_station)側のユニットを先に表示
|
||
// (heading_to === "left") === is_leading が true → high松(left)端のユニット
|
||
const aNav = a.trains?.find((t) => t.train_number === trainNum)?.nav;
|
||
const bNav = b.trains?.find((t) => t.train_number === trainNum)?.nav;
|
||
const aIsLeft =
|
||
(aNav?.heading_to === "left") === (aNav?.is_leading === true);
|
||
const bIsLeft =
|
||
(bNav?.heading_to === "left") === (bNav?.is_leading === true);
|
||
if (aIsLeft === bIsLeft) return 0;
|
||
return aIsLeft ? -1 : 1;
|
||
});
|
||
// えれサイト: 編成名テキスト(formation_config.units 優先)
|
||
const elesiteFormationNames =
|
||
elesiteNonEmptyFormationEntries
|
||
.slice(0, 4)
|
||
.map((e) => {
|
||
const units = e.formation_config?.units;
|
||
return units?.length
|
||
? units.map((u) => u.formation).join("+")
|
||
: e.formations;
|
||
})
|
||
.join("・") +
|
||
(elesiteNonEmptyFormationEntries.length > 4
|
||
? ` 他${elesiteNonEmptyFormationEntries.length - 4}件`
|
||
: "");
|
||
|
||
// 列車情報 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;
|
||
|
||
const elesiteFormationDetail = (
|
||
<View style={styles.operationDetailBlock}>
|
||
{elesiteHasNonEmptyFormations && (
|
||
<Text style={styles.unitIdText}>{elesiteFormationNames}</Text>
|
||
)}
|
||
{elesiteCount > 0
|
||
? (() => {
|
||
const fc = (elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0])
|
||
?.formation_config;
|
||
return fc?.left_station && fc?.right_station ? (
|
||
<RefDirectionBanner
|
||
rows={[
|
||
{ leftLabel: fc.left_station, rightLabel: fc.right_station },
|
||
]}
|
||
color="#44bb44"
|
||
/>
|
||
) : null;
|
||
})()
|
||
: undefined}
|
||
</View>
|
||
);
|
||
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: latestOperationDate
|
||
? `更新: ${formatHHMM(latestOperationDate)}`
|
||
: 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 ─────────────────────────── */}
|
||
{unyohubEnabled && (
|
||
<SourceCard
|
||
imagePng={HUB_LOGO_PNG}
|
||
color="#333"
|
||
title="鉄道運用Hub"
|
||
label="外部コミュニティデータ"
|
||
sub={
|
||
hasNonEmptyFormations
|
||
? ""
|
||
: unyoCount > 0
|
||
? "数日の運用報告なし"
|
||
: "この列車の運用データはありません"
|
||
}
|
||
badge={hasNonEmptyFormations ? unyoCount : null}
|
||
badgeColor="#333"
|
||
detail={formationDetail}
|
||
disabled={unyoCount === 0}
|
||
onPress={() =>
|
||
openWebView(
|
||
`https://jr-shikoku-data-system.pages.dev/unyohub-connection-train-data/${trainNum}`,
|
||
true,
|
||
)
|
||
}
|
||
/>
|
||
)}
|
||
|
||
{/* ─── えれサイト ─────────────────────────── */}
|
||
{elesiteEnabled && (
|
||
<SourceCard
|
||
imagePng={ELESITE_LOGO_PNG}
|
||
color="#44bb44"
|
||
title="えれサイト"
|
||
label="外部コミュニティデータ"
|
||
sub={
|
||
elesiteCount === 0
|
||
? "この列車の運用データはありません"
|
||
: !elesiteHasNonEmptyFormations
|
||
? "本日の運用報告なし"
|
||
: elesiteLastReportedAt
|
||
? `最終投稿: ${formatHHMM(elesiteLastReportedAt)}`
|
||
: ""
|
||
}
|
||
badge={elesiteHasNonEmptyFormations ? elesiteCount : null}
|
||
badgeColor="#44bb44"
|
||
detail={elesiteFormationDetail}
|
||
disabled={elesiteCount === 0}
|
||
onPress={() => {
|
||
const matchedEntry =
|
||
elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0];
|
||
const matchedTrain = matchedEntry?.trains?.find(
|
||
(t) => t.train_number === trainNum,
|
||
);
|
||
const url =
|
||
matchedTrain?.timetable_url || "https://www.elesite-next.com/";
|
||
openWebView(url, 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>
|
||
);
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* RefDirectionBanner: 基準方向ラベル */
|
||
/* ------------------------------------------------------------------ */
|
||
type RefDirRow = {
|
||
leftLabel?: string;
|
||
lineLabel?: string;
|
||
rightLabel?: string;
|
||
};
|
||
|
||
const RefDirectionBanner: FC<{ rows: RefDirRow[]; color?: string }> = ({
|
||
rows,
|
||
color = "#888",
|
||
}) => (
|
||
<View style={styles.refDirContainer}>
|
||
{rows.map((row, i) => (
|
||
<View key={i} style={styles.refDirRow}>
|
||
{!!row.leftLabel && (
|
||
<MaterialCommunityIcons name="arrow-left" size={10} color={color} />
|
||
)}
|
||
{!!row.leftLabel && (
|
||
<Text style={[styles.refDirText, { color }]}>{row.leftLabel}</Text>
|
||
)}
|
||
{!!row.lineLabel && (
|
||
<View style={[styles.refDirLineTag, { borderColor: color + "55" }]}>
|
||
<Text style={[styles.refDirLineText, { color }]}>
|
||
{row.lineLabel}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
{!!row.rightLabel && (
|
||
<>
|
||
<Text style={[styles.refDirText, { color }]}>{row.rightLabel}</Text>
|
||
<MaterialCommunityIcons
|
||
name="arrow-right"
|
||
size={10}
|
||
color={color}
|
||
/>
|
||
</>
|
||
)}
|
||
</View>
|
||
))}
|
||
</View>
|
||
);
|
||
|
||
/* ------------------------------------------------------------------ */
|
||
/* CyclingRefDirectionBanner: 1秒ごとにフェードして路線を切り替え */
|
||
/* ------------------------------------------------------------------ */
|
||
const CyclingRefDirectionBanner: FC<{ rows: RefDirRow[]; color?: string }> = ({
|
||
rows,
|
||
color = "#888",
|
||
}) => {
|
||
const [index, setIndex] = useState(0);
|
||
const opacity = useRef(new Animated.Value(1)).current;
|
||
|
||
useEffect(() => {
|
||
if (rows.length <= 1) return;
|
||
const timer = setInterval(() => {
|
||
Animated.timing(opacity, {
|
||
toValue: 0,
|
||
duration: 250,
|
||
useNativeDriver: true,
|
||
}).start(() => {
|
||
setIndex((prev) => (prev + 1) % rows.length);
|
||
Animated.timing(opacity, {
|
||
toValue: 1,
|
||
duration: 250,
|
||
useNativeDriver: true,
|
||
}).start();
|
||
});
|
||
}, 3000);
|
||
return () => clearInterval(timer);
|
||
}, [rows.length]);
|
||
|
||
const row = rows[index];
|
||
return (
|
||
<Animated.View style={[styles.refDirRow, { opacity }]}>
|
||
{!!row.leftLabel && (
|
||
<MaterialCommunityIcons name="arrow-left" size={10} color={color} />
|
||
)}
|
||
{!!row.leftLabel && (
|
||
<Text style={[styles.refDirText, { color }]}>{row.leftLabel}</Text>
|
||
)}
|
||
{!!row.lineLabel && (
|
||
<View style={[styles.refDirLineTag, { borderColor: color + "55" }]}>
|
||
<Text style={[styles.refDirLineText, { color }]}>
|
||
{row.lineLabel}
|
||
</Text>
|
||
</View>
|
||
)}
|
||
{!!row.rightLabel && (
|
||
<>
|
||
<Text style={[styles.refDirText, { color }]}>{row.rightLabel}</Text>
|
||
<MaterialCommunityIcons name="arrow-right" size={10} color={color} />
|
||
</>
|
||
)}
|
||
</Animated.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;
|
||
const { color: typeColor, fontAvailable } = getTrainType({
|
||
type: data.type,
|
||
whiteMode: false,
|
||
});
|
||
const resolvedTypeColor = typeColor === "white" ? "#333333ff" : typeColor;
|
||
// 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, { backgroundColor: resolvedTypeColor }]}
|
||
>
|
||
<Text
|
||
style={[
|
||
styles.typeTagText,
|
||
{
|
||
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
|
||
fontWeight: !fontAvailable ? "bold" : undefined,
|
||
paddingTop: fontAvailable ? 2 : 0,
|
||
},
|
||
]}
|
||
>
|
||
{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;
|
||
disabled?: boolean;
|
||
onPress?: () => void;
|
||
detail?: React.ReactNode;
|
||
};
|
||
|
||
const SourceCard: FC<SourceCardProps> = ({
|
||
icon,
|
||
imagePng,
|
||
color,
|
||
title,
|
||
label,
|
||
sub,
|
||
badge,
|
||
badgeColor,
|
||
disabled,
|
||
onPress,
|
||
detail,
|
||
}) => (
|
||
<TouchableOpacity
|
||
style={[styles.card, disabled && { opacity: 0.45 }]}
|
||
activeOpacity={disabled ? 1 : 0.7}
|
||
disabled={disabled}
|
||
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: {
|
||
borderRadius: 4,
|
||
paddingHorizontal: 8,
|
||
paddingVertical: 2,
|
||
},
|
||
typeTagText: {
|
||
color: "#fff",
|
||
fontSize: 13,
|
||
},
|
||
trainNameText: {
|
||
fontSize: 16,
|
||
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: "#0077aa",
|
||
borderRadius: 5,
|
||
paddingHorizontal: 10,
|
||
paddingVertical: 5,
|
||
alignSelf: "flex-start",
|
||
},
|
||
ledText: {
|
||
fontFamily: "JNR-font",
|
||
fontSize: 20,
|
||
color: "#ffffff",
|
||
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",
|
||
},
|
||
refDirContainer: {
|
||
gap: 3,
|
||
marginTop: 5,
|
||
},
|
||
refDirRow: {
|
||
flexDirection: "row",
|
||
alignItems: "center",
|
||
flexWrap: "wrap",
|
||
gap: 3,
|
||
},
|
||
refDirText: {
|
||
fontSize: 10,
|
||
fontWeight: "600",
|
||
},
|
||
refDirLineTag: {
|
||
borderWidth: 1,
|
||
borderRadius: 4,
|
||
paddingHorizontal: 4,
|
||
paddingVertical: 1,
|
||
},
|
||
refDirLineText: {
|
||
fontSize: 10,
|
||
},
|
||
});
|