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