import React, { FC, useState, useEffect, useRef } from "react";
import {
View,
Text,
TouchableOpacity,
StyleSheet,
Platform,
Linking,
Image,
Animated,
useWindowDimensions,
} from "react-native";
import ActionSheet, { SheetManager, ScrollView } from "react-native-actions-sheet";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { getTrainType } from "@/lib/getTrainType";
import { useSheetMaxHeight } from "./useSheetMaxHeight";
import { TRAIN_TYPE_CONFIG } from "@/lib/webview/trainTypeConfig";
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";
import { useThemeColors } from "@/lib/theme";
import ViewShot from "react-native-view-shot";
import * as Sharing from "expo-sharing";
export type TrainDataSourcesPayload = {
trainNum: string;
/** UnyoHub検索用列番(上書き列番があればそちら) */
unyohubTrainNum?: 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/relationLogo/unyohub_logo.webp");
const ELESITE_LOGO_PNG = require("@/assets/relationLogo/elesite_logo.jpg");
/** 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 "";
}
};
/**
* "YYYY-MM-DD HH:MM:SS" 形式の文字列を "M/D HH:MM" 形式にフォーマット
* ISO 8601 文字列にも対応
*/
const formatDateHHMM = (datetime: string): string => {
try {
// "YYYY-MM-DD HH:MM:SS" → space を T に置換して安全にパース
const d = new Date(datetime.replace(" ", "T"));
const mo = d.getMonth() + 1;
const day = d.getDate();
const h = d.getHours().toString().padStart(2, "0");
const m = d.getMinutes().toString().padStart(2, "0");
return `${mo}/${day} ${h}:${m}`;
} catch {
return "";
}
};
/* ------------------------------------------------------------------ */
/* FormationChips: "+"区切りの編成名をチップ形式で表示 */
/* ------------------------------------------------------------------ */
const FormationChips: FC<{ text: string; color: string }> = ({ text, color }) => {
const parts = text.split("+").map((s) => s.trim()).filter(Boolean);
if (parts.length === 0) return null;
return (
{parts.map((part, i) => (
{i > 0 && (
+
)}
{part}
))}
);
};
/* ------------------------------------------------------------------ */
/* FadingSubCycler */
/* ------------------------------------------------------------------ */
type FadingSubItem = { label: string; datetime: string | null };
const FadingSubCycler: FC<{ items: FadingSubItem[]; color: string }> = ({ items, color }) => {
const { colors } = useThemeColors();
const [index, setIndex] = useState(0);
const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
if (items.length <= 1) return;
const cycle = () => {
Animated.timing(opacity, { toValue: 0, duration: 300, useNativeDriver: true }).start(() => {
setIndex((i) => (i + 1) % items.length);
Animated.timing(opacity, { toValue: 1, duration: 300, useNativeDriver: true }).start();
});
};
const id = setInterval(cycle, 3000);
return () => clearInterval(id);
}, [items.length]);
const item = items[index];
return (
{item.datetime ? (
{`最終投稿: ${formatDateHHMM(item.datetime)}`}
) : (
{`運用情報 ${index + 1}/${items.length}`}
)}
);
};
/* ------------------------------------------------------------------ */
/* ActiveFormationChipsCycler: 全チップ常時表示、アクティブのみ枠アニメ */
/* ------------------------------------------------------------------ */
const ActiveFormationChipsCycler: FC<{ items: string[]; color: string }> = ({ items, color }) => {
const [activeIndex, setActiveIndex] = useState(0);
const borderAnim = useRef(new Animated.Value(items.length <= 1 ? 1.5 : 0)).current;
useEffect(() => {
if (items.length <= 1) return;
Animated.timing(borderAnim, { toValue: 1.5, duration: 200, useNativeDriver: false }).start();
const id = setInterval(() => {
Animated.timing(borderAnim, { toValue: 0, duration: 200, useNativeDriver: false }).start(() => {
setActiveIndex((i) => (i + 1) % items.length);
Animated.timing(borderAnim, { toValue: 1.5, duration: 200, useNativeDriver: false }).start();
});
}, 3000);
return () => clearInterval(id);
}, [items.length]);
return (
{items.map((text, i) => {
const isActive = i === activeIndex;
const parts = text.split("+").map((s) => s.trim()).filter(Boolean);
const inner = (
{parts.map((part, j) => (
{j > 0 && (
+
)}
{part}
))}
);
return (
{i > 0 && (
・
)}
{isActive ? (
{inner}
) : (
inner
)}
);
})}
);
};
export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
payload,
}) => {
// ── フックはすべて条件分岐より前に呼ぶ(Rules of Hooks)──
const { useUnyohub: unyohubEnabled } = useUnyohub();
const { useElesite: elesiteEnabled } = useElesite();
const { colors, fixed } = useThemeColors();
const { height: windowHeight } = useWindowDimensions();
const viewShot = useRef(null);
const [isCapturing, setIsCapturing] = useState(false);
const maxHeight = useSheetMaxHeight();
if (!payload) return null;
const onCapture = async () => {
setIsCapturing(true);
await new Promise(resolve => setTimeout(resolve, 150));
try {
const url = await (viewShot.current as any).capture();
const ok = await Sharing.isAvailableAsync();
if (ok) {
await Sharing.shareAsync("file://" + url, { mimeType: "image/jpeg", dialogTitle: "Share this image" });
}
} finally {
setIsCapturing(false);
}
};
const {
trainNum,
unyohubTrainNum: unyohubTrainNumProp,
unyohubEntries,
elesiteEntries = [],
todayOperation,
navigate,
expoPushToken,
priority,
direction: directionProp,
customTrainData,
typeName,
trainName,
departureStation,
destinationStation,
} = payload;
// __メモ書き サフィックスを除去して列番部分だけを返す
const stripMemoSuffix = (value: string | null | undefined): string => {
if (!value) return "";
return value.split("__")[0].trim();
};
const isFreightRetsuban = trainNum.includes("レ");
const hubTrainNum = (unyohubTrainNumProp || trainNum).replace(/レ/g, "");
const freightUnyohubCandidates = (() => {
const digits = hubTrainNum.replace(/[^\d]/g, "");
const candidates = new Set();
if (!digits) return candidates;
candidates.add(digits);
if (/^\d{2}$/.test(digits)) {
candidates.add(`30${digits}`);
candidates.add(`90${digits}`);
} else if (/^(30|90)\d{2}$/.test(digits)) {
candidates.add(digits.slice(-2));
}
return candidates;
})();
const matchesHubTrainNum = (candidate?: string | null): boolean => {
if (!candidate) return false;
const normalized = stripMemoSuffix(candidate).replace(/レ/g, "");
if (normalized === hubTrainNum) return true;
if (!isFreightRetsuban) return false;
return freightUnyohubCandidates.has(normalized);
};
// APIデータ内の元の列番(__メモ付き)を取得して運用Hub連携 URL に使う
const originalHubTrainNum = (() => {
for (const entry of unyohubEntries) {
const match = entry.trains?.find(
(t) => stripMemoSuffix(t.train_number).replace(/レ/g, "") === hubTrainNum,
);
if (match?.train_number) return match.train_number;
}
return hubTrainNum;
})();
// 進行方向の確定:
// 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("TrainDataSources");
SheetManager.hide("EachTrainInfo");
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 = (
{opCount > 0 &&
(allUnitIds.length > 0 ? (
{allUnitIds.slice(0, 8).join("・")}
{allUnitIds.length > 8 ? `他${allUnitIds.length - 8}件` : ""}
) : (
{fallbackIds.join("・") || "運用記録あり"}
))}
{opCount > 0 && }
);
// 鉄道運用Hub: 車番(formations) が空でないエントリのみ表示対象にする
const hasNonEmptyFormations = unyohubEntries.some(
(e) => !!e.formations && e.formations.trim() !== "",
);
const nonEmptyFormationEntries = unyohubEntries.filter(
(e) => !!e.formations && e.formations.trim() !== "",
);
// 最終投稿日時(エントリ中の最新値)
const unyohubLastPostedDatetime =
unyohubEntries
.map((e) => e.last_posted_datetime)
.filter((d): d is string => !!d)
.sort()
.at(-1) ?? null;
// フェードサイクル用アイテム(編成あるエントリのみ)
const unyohubSubItems: FadingSubItem[] = nonEmptyFormationEntries.map((e) => ({
label: e.formations?.trim() || e.operation_id || "",
datetime: e.last_posted_datetime ?? null,
}));
// 投稿日時が今日でない場合はカードを薄く表示("YYYY-MM-DD HH:MM:SS" 形式)
const todayDateStr = new Date().toLocaleDateString("sv"); // "YYYY-MM-DD"
const isUnyohubStale =
unyohubLastPostedDatetime == null ||
!unyohubLastPostedDatetime.startsWith(todayDateStr);
// 運用グループ名(重複除去して最大3件)
const unyohubGroupNames = [
...new Set(
nonEmptyFormationEntries
.map((e) => e.operation_group_name)
.filter((n) => !!n && n.trim() !== ""),
),
]
.slice(0, 3)
.join(" / ");
// 新フィールドのチップ表示フラグ
const hubHasIsQuotation = unyohubEntries.some((e) => e.is_quotation === true);
const hubHasFromBeginner = unyohubEntries.some((e) => e.from_beginner === true);
const hubHasCommentExists = unyohubEntries.some((e) => e.comment_exists === true);
const hubHasHiddenByDefault = unyohubEntries.some((e) => e.hidden_by_default === true);
// 先頭エントリで direction を取得し、表示順を決定:
// outbound → position_forward 昇順 (pos=1 が宇和島/南端側)
// inbound → position_forward 降順 (pos=MAX が宇和島/南端側)
const matchedDirection = nonEmptyFormationEntries[0]?.trains?.find(
(t) => matchesHubTrainNum(t.train_number),
)?.direction;
const hubSortDescending = matchedDirection === "inbound";
const sortedFormationDisplay = [...nonEmptyFormationEntries]
.sort((a, b) => {
const posA =
a.trains?.find((t) => matchesHubTrainNum(t.train_number))
?.position_forward ?? 0;
const posB =
b.trains?.find((t) => matchesHubTrainNum(t.train_number))
?.position_forward ?? 0;
return hubSortDescending ? posB - posA : posA - posB;
})
.slice(0, 4);
const formationDetail = (
{hasNonEmptyFormations && (
e.formations || "")}
color={colors.textAccent}
/>
{nonEmptyFormationEntries.length > 4 && (
他{nonEmptyFormationEntries.length - 4}件
)}
)}
{unyohubGroupNames !== "" && (
{unyohubGroupNames}
)}
{(hubHasIsQuotation || hubHasFromBeginner || hubHasCommentExists || hubHasHiddenByDefault) && (
{hubHasIsQuotation && (
引用データ
)}
{hubHasFromBeginner && (
初心者投稿
)}
{hubHasCommentExists && (
コメントあり
)}
{hubHasHiddenByDefault && (
非表示設定
)}
)}
);
// えれサイト最終投稿時刻(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.trim() === trainNum)?.nav;
const bNav = b.trains?.find((t) => t.train_number.trim() === 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 ? (
) : null;
const elesiteFormationDetail = (
{elesiteHasNonEmptyFormations && (
{elesiteFormationNames}
)}
{elesiteCount > 0
? (() => {
const fc = (elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0])
?.formation_config;
return fc?.left_station && fc?.right_station ? (
) : null;
})()
: undefined}
);
return (
>}
isModal={Platform.OS === "ios" && !Platform.isPad}
containerStyle={{ backgroundColor: colors.sheetBackground, ...(maxHeight != null ? { maxHeight } : {}) }}
>
{/* ヘッダー */}
運用情報ソース
{customTrainData?.train_number_override ? (
) : (
{trainNum}
)}
{/* ─── jr-shikoku-data-system (列車情報 + 運用情報) ─── */}
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 && (
1
?
: unyohubLastPostedDatetime
? `最終投稿: ${formatDateHHMM(unyohubLastPostedDatetime)}`
: ""
: unyoCount > 0
? "数日の運用報告なし"
: "この列車の運用データはありません"
}
badge={hasNonEmptyFormations ? unyoCount : null}
badgeColor={colors.textSecondary}
detail={formationDetail}
disabled={unyoCount === 0}
onPress={() =>
openWebView(
`https://jr-shikoku-data-system.pages.dev/unyohub-connection-train-data/${originalHubTrainNum}`,
true,
)
}
/>
)}
{/* ─── えれサイト ─────────────────────────── */}
{elesiteEnabled && (
{
const matchedEntry =
elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0];
const matchedTrain = matchedEntry?.trains?.find(
(t) => t.train_number.trim() === trainNum,
);
const url =
matchedTrain?.timetable_url || "https://www.elesite-next.com/";
SheetManager.hide("TrainDataSources");
Linking.openURL(url);
}}
/>
)}
スクリーンショットを共有
);
};
/* ------------------------------------------------------------------ */
/* DirectionBanner: 進行方向表示 */
/* ------------------------------------------------------------------ */
const DirectionBanner: FC<{ direction: boolean }> = ({ direction }) => {
const { colors, fixed } = useThemeColors();
return (
{direction ? (
<>
進行方向
>
) : (
<>
進行方向
>
)}
);
};
/* ------------------------------------------------------------------ */
/* CyclingHeaderTrainNum: 上書き列番と通常列番をフェードで切り替え */
/* ------------------------------------------------------------------ */
const CyclingHeaderTrainNum: FC<{
original: string;
override: string;
color: string;
}> = ({ original, override, color }) => {
const [showOverride, setShowOverride] = useState(true);
const opacity = useRef(new Animated.Value(1)).current;
useEffect(() => {
const timer = setInterval(() => {
Animated.timing(opacity, {
toValue: 0,
duration: 250,
useNativeDriver: true,
}).start(() => {
setShowOverride((prev) => !prev);
Animated.timing(opacity, {
toValue: 1,
duration: 250,
useNativeDriver: true,
}).start();
});
}, 3000);
return () => clearInterval(timer);
}, []);
return (
{showOverride ? override : original}
{showOverride ? "上書" : "通常"}
);
};
/* ------------------------------------------------------------------ */
/* RefDirectionBanner: 基準方向ラベル */
/* ------------------------------------------------------------------ */
type RefDirRow = {
leftLabel?: string;
lineLabel?: string;
rightLabel?: string;
};
const RefDirectionBanner: FC<{ rows: RefDirRow[]; color?: string }> = ({
rows,
color = "#888",
}) => (
{rows.map((row, i) => (
{!!row.leftLabel && (
)}
{!!row.leftLabel && (
{row.leftLabel}
)}
{!!row.lineLabel && (
{row.lineLabel}
)}
{!!row.rightLabel && (
<>
{row.rightLabel}
>
)}
))}
);
/* ------------------------------------------------------------------ */
/* 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 (
{!!row.leftLabel && (
)}
{!!row.leftLabel && (
{row.leftLabel}
)}
{!!row.lineLabel && (
{row.lineLabel}
)}
{!!row.rightLabel && (
<>
{row.rightLabel}
>
)}
);
};
/* ------------------------------------------------------------------ */
/* 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 }) => {
const { colors } = useThemeColors();
return (
{/* 共通ヘッダー */}
{label}
{/* 各行 */}
{rows.map((row, i) => (
{i > 0 && }
{/* 左カラーバー分のスペーサー */}
{row.title}
{row.badge !== null && (
{row.badge}
)}
{row.sub !== null && (
{row.sub}
)}
{row.detail && {row.detail}}
{i === rows.length - 1 && (
)}
))}
)};
/* ------------------------------------------------------------------ */
/* TrainInfoDetail: 列車情報の構造化表示 */
/* ------------------------------------------------------------------ */
const TrainInfoDetail: FC<{
data: CustomTrainData;
typeName?: string;
trainName?: string;
departureStation?: string;
destinationStation?: string;
}> = ({ data, typeName, trainName, departureStation, destinationStation }) => {
const { colors } = useThemeColors();
const {
infogram,
train_info,
vehicle_formation,
via_data,
start_date,
end_date,
uwasa,
optional_text,
} = data;
const { fontAvailable } = getTrainType({
type: data.type,
whiteMode: false,
});
// 位置情報表示と同じ種別色を使用(視認性向上)
const trainTypeCfg = TRAIN_TYPE_CONFIG[data.type];
const resolvedTypeColor = trainTypeCfg ? trainTypeCfg.typeColor : "#333333ff";
// 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 (
{/* 種別・列車名・始発→行先 */}
{(typeName || trainName || departureStation || destDisplay) && (
{!!typeName && (
{typeName}
)}
{!!trainName && (
{trainName}
)}
{!!(departureStation || destDisplay) && (
{!!departureStation && (
{departureStation}
)}
{!!(departureStation && destDisplay) && (
)}
{!!destDisplay && (
{destDisplay}
)}
)}
)}
{/* LED インフォグラム */}
{!!infogram && (
インフォグラム
{infogram}
)}
{/* 列車情報テキスト */}
{!!train_info && (
列車情報
{train_info}
)}
{/* メタ情報チップ行 */}
{!!(vehicle_formation || via_data || start_date || end_date) && (
{!!vehicle_formation && (
{vehicle_formation}
)}
{!!via_data && (
経由: {via_data}
)}
{!!(start_date || end_date) && (
{start_date ?? ""}〜{end_date ?? ""}
)}
)}
{/* うわさ / optional_text */}
{(!!uwasa || !!optional_text) && (
{!!uwasa && (
噂情報
{uwasa}
)}
{!!optional_text && (
{optional_text}
)}
)}
);
};
/* ------------------------------------------------------------------ */
/* SourceCard */
/* ------------------------------------------------------------------ */
type SourceCardProps = {
icon?: string;
imagePng?: any;
color: string;
title: string;
label: string;
sub?: string | React.ReactNode;
badge: number | string | null;
badgeColor: string;
disabled?: boolean;
onPress?: () => void;
detail?: React.ReactNode;
};
const SourceCard: FC = ({
icon,
imagePng,
color,
title,
label,
sub,
badge,
badgeColor,
disabled,
onPress,
detail,
}) => {
const { colors } = useThemeColors();
return (
{/* 左カラーバー */}
{/* アイコン */}
{imagePng ? (
) : (
)}
{/* テキスト */}
{title}
{label}
{sub && (
typeof sub === "string" ? (
{sub}
) : (
{sub}
)
)}
{detail && {detail}}
{/* バッジ + 矢印 */}
{badge !== null ? (
{badge}
) : null}
)};
/* ------------------------------------------------------------------ */
/* スタイル */
/* ------------------------------------------------------------------ */
const styles = StyleSheet.create({
header: {
paddingTop: 8,
paddingBottom: 12,
paddingHorizontal: 16,
},
handleBar: {
width: 40,
height: 5,
borderRadius: 3,
alignSelf: "center",
marginBottom: 12,
},
headerRow: {
flexDirection: "row",
alignItems: "baseline",
gap: 8,
},
headerTitle: {
fontSize: 17,
fontWeight: "bold",
},
headerSub: {
fontSize: 14,
},
headerTrainNumWrap: {
flexDirection: "row",
alignItems: "baseline",
gap: 4,
},
headerTrainNumLabel: {
fontSize: 10,
fontWeight: "600",
},
trainHeaderRow: {
flexDirection: "row",
alignItems: "center",
flexWrap: "wrap",
gap: 6,
},
trainTitleBlock: {
gap: 4,
paddingBottom: 4,
borderBottomWidth: StyleSheet.hairlineWidth,
marginBottom: 4,
},
typeTag: {
borderRadius: 4,
paddingHorizontal: 8,
paddingVertical: 2,
},
typeTagText: {
color: "#fff",
fontSize: 13,
},
trainNameText: {
fontSize: 16,
flexShrink: 1,
},
routeRow: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
routeStation: {
fontSize: 13,
},
routeDest: {
fontWeight: "bold",
},
scroll: {},
scrollContent: {
paddingHorizontal: 14,
gap: 10,
paddingBottom: 4,
},
card: {
flexDirection: "row",
alignItems: "center",
borderRadius: 12,
borderWidth: 1,
overflow: "hidden",
minHeight: 70,
},
combinedCard: {
flexDirection: "column",
alignItems: "stretch",
},
combinedHeader: {
flexDirection: "row",
alignItems: "center",
paddingTop: 8,
paddingBottom: 2,
gap: 8,
},
combinedLabel: {
marginLeft: 4,
},
combinedRow: {
flexDirection: "row",
alignItems: "flex-start",
minHeight: 58,
paddingVertical: 6,
},
divider: {
height: StyleSheet.hairlineWidth,
},
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",
},
cardTitleDisabled: {
},
labelText: {
fontSize: 10,
fontWeight: "500",
},
subText: {
fontSize: 12,
},
subNodeWrap: {
marginTop: 2,
},
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,
},
shareButton: {
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
marginHorizontal: 14,
marginTop: 8,
paddingVertical: 10,
borderRadius: 10,
borderWidth: 1,
},
shareButtonText: {
fontSize: 14,
fontWeight: "600",
},
/* ── TrainInfoDetail ──────────────── */
detailWrap: {
marginTop: 4,
marginBottom: 4,
},
trainDetail: {
gap: 8,
},
ledSection: {
gap: 4,
},
detailSectionLabel: {
fontSize: 10,
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,
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,
},
noteSection: {
gap: 6,
marginTop: 4,
paddingRight: 10,
},
noteRow: {
flexDirection: "row",
alignItems: "flex-start",
gap: 4,
},
noteTextWrap: {
flex: 1,
gap: 2,
},
rumorRow: {
borderWidth: 1,
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
gap: 6,
marginVertical: 2,
},
rumorIcon: {
marginTop: 1,
},
rumorLabel: {
fontSize: 10,
fontWeight: "700",
letterSpacing: 0.6,
},
noteText: {
fontSize: 11,
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,
},
});