Files
jrshikoku/components/ActionSheetComponents/TrainDataSources.tsx

1170 lines
33 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, 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,
},
});