1679 lines
52 KiB
TypeScript
1679 lines
52 KiB
TypeScript
import React, { FC, useState, useEffect, useMemo, 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 {
|
|
buildElesiteLineGroups,
|
|
type ElesiteLineGroup,
|
|
} from "@/lib/elesiteTrainOrder";
|
|
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 (
|
|
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 2 }}>
|
|
{parts.map((part, i) => (
|
|
<React.Fragment key={i}>
|
|
{i > 0 && (
|
|
<Text style={{ fontSize: 11, color, fontWeight: "bold" }}>+</Text>
|
|
)}
|
|
<View
|
|
style={{
|
|
borderWidth: 1,
|
|
borderColor: color + "66",
|
|
borderRadius: 4,
|
|
paddingHorizontal: 5,
|
|
paddingVertical: 1,
|
|
backgroundColor: color + "12",
|
|
}}
|
|
>
|
|
<Text style={{ fontSize: 12, fontWeight: "700", color }} numberOfLines={1}>
|
|
{part}
|
|
</Text>
|
|
</View>
|
|
</React.Fragment>
|
|
))}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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 (
|
|
<Animated.View style={{ opacity, flexDirection: "row", alignItems: "center", gap: 4 }}>
|
|
{item.datetime ? (
|
|
<Text style={{ fontSize: 11, color: colors.textSecondary }} numberOfLines={1}>
|
|
{`最終投稿: ${formatDateHHMM(item.datetime)}`}
|
|
</Text>
|
|
) : (
|
|
<Text style={{ fontSize: 11, color: colors.textSecondary }} numberOfLines={1}>
|
|
{`運用情報 ${index + 1}/${items.length}`}
|
|
</Text>
|
|
)}
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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 (
|
|
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 4 }}>
|
|
{items.map((text, i) => {
|
|
const isActive = i === activeIndex;
|
|
const parts = text.split("+").map((s) => s.trim()).filter(Boolean);
|
|
const inner = (
|
|
<View style={{ flexDirection: "row", alignItems: "center", flexWrap: "wrap", gap: 2 }}>
|
|
{parts.map((part, j) => (
|
|
<React.Fragment key={j}>
|
|
{j > 0 && (
|
|
<Text style={{ fontSize: 11, fontWeight: "bold", color: isActive ? color : color + "55" }}>+</Text>
|
|
)}
|
|
<Text
|
|
style={{
|
|
fontSize: 12,
|
|
fontWeight: isActive ? "700" : "500",
|
|
color: isActive ? color : color + "55",
|
|
paddingHorizontal: 4,
|
|
paddingVertical: 1,
|
|
}}
|
|
numberOfLines={1}
|
|
>
|
|
{part}
|
|
</Text>
|
|
</React.Fragment>
|
|
))}
|
|
</View>
|
|
);
|
|
return (
|
|
<React.Fragment key={i}>
|
|
{i > 0 && (
|
|
<Text style={{ fontSize: 11, color: color + "55" }}>・</Text>
|
|
)}
|
|
{isActive ? (
|
|
<Animated.View
|
|
style={{
|
|
borderWidth: borderAnim,
|
|
borderColor: color,
|
|
borderRadius: 6,
|
|
paddingHorizontal: 4,
|
|
paddingVertical: 1,
|
|
backgroundColor: color + "12",
|
|
}}
|
|
>
|
|
{inner}
|
|
</Animated.View>
|
|
) : (
|
|
inner
|
|
)}
|
|
</React.Fragment>
|
|
);
|
|
})}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
const ElesiteLineGroupCycler: FC<{
|
|
groups: ElesiteLineGroup[];
|
|
color: string;
|
|
onActiveGroupChange?: (group: ElesiteLineGroup | null) => void;
|
|
}> = ({ groups, color, onActiveGroupChange }) => {
|
|
const { colors } = useThemeColors();
|
|
const [index, setIndex] = useState(0);
|
|
const opacity = useRef(new Animated.Value(1)).current;
|
|
|
|
useEffect(() => {
|
|
if (groups.length === 0) {
|
|
onActiveGroupChange?.(null);
|
|
return;
|
|
}
|
|
|
|
if (index >= groups.length) {
|
|
setIndex(0);
|
|
return;
|
|
}
|
|
|
|
onActiveGroupChange?.(groups[index]);
|
|
}, [groups, index, onActiveGroupChange]);
|
|
|
|
useEffect(() => {
|
|
if (groups.length <= 1) return;
|
|
|
|
const timer = setInterval(() => {
|
|
Animated.timing(opacity, {
|
|
toValue: 0,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}).start(() => {
|
|
setIndex((prev) => (prev + 1) % groups.length);
|
|
Animated.timing(opacity, {
|
|
toValue: 1,
|
|
duration: 250,
|
|
useNativeDriver: true,
|
|
}).start();
|
|
});
|
|
}, 3000);
|
|
|
|
return () => clearInterval(timer);
|
|
}, [groups.length]);
|
|
|
|
const group = groups[index];
|
|
if (!group) return null;
|
|
|
|
return (
|
|
<Animated.View style={{ opacity }}>
|
|
{group.formationText ? (
|
|
<View style={styles.elesiteFormationWrap}>
|
|
<FormationChips text={group.formationText} color={color} />
|
|
</View>
|
|
) : (
|
|
<Text style={[styles.subText, { color: colors.textSecondary }]}>
|
|
本日の運用報告なし
|
|
</Text>
|
|
)}
|
|
{(group.leftStation || group.rightStation || group.lineLabel) && (
|
|
<RefDirectionBanner
|
|
rows={[
|
|
{
|
|
leftLabel: group.leftStation ?? undefined,
|
|
lineLabel: group.lineLabel ?? undefined,
|
|
rightLabel: group.rightStation ?? undefined,
|
|
},
|
|
]}
|
|
color={color}
|
|
/>
|
|
)}
|
|
</Animated.View>
|
|
);
|
|
};
|
|
|
|
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<string>();
|
|
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 elesiteLineGroups = useMemo(
|
|
() => buildElesiteLineGroups(elesiteEntries, trainNum),
|
|
[elesiteEntries, trainNum],
|
|
);
|
|
const elesiteDisplayGroups = useMemo(
|
|
() =>
|
|
elesiteLineGroups.some((group) => group.hasFormations)
|
|
? elesiteLineGroups.filter((group) => group.hasFormations)
|
|
: elesiteLineGroups,
|
|
[elesiteLineGroups],
|
|
);
|
|
const [activeElesiteGroupKey, setActiveElesiteGroupKey] = useState<string | null>(null);
|
|
const hasTrainInfo = priority > 200;
|
|
|
|
useEffect(() => {
|
|
if (elesiteDisplayGroups.length === 0) {
|
|
setActiveElesiteGroupKey(null);
|
|
return;
|
|
}
|
|
|
|
if (
|
|
!activeElesiteGroupKey ||
|
|
!elesiteDisplayGroups.some((group) => group.key === activeElesiteGroupKey)
|
|
) {
|
|
setActiveElesiteGroupKey(elesiteDisplayGroups[0].key);
|
|
}
|
|
}, [activeElesiteGroupKey, elesiteDisplayGroups]);
|
|
|
|
// 運用情報: 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() !== "",
|
|
);
|
|
|
|
// 最終投稿日時(エントリ中の最新値)
|
|
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 = (
|
|
<View style={styles.operationDetailBlock}>
|
|
{hasNonEmptyFormations && (
|
|
<View style={{ opacity: isUnyohubStale ? 0.4 : 1 }}>
|
|
<ActiveFormationChipsCycler
|
|
items={sortedFormationDisplay.map((e) => e.formations || "")}
|
|
color={colors.textAccent}
|
|
/>
|
|
{nonEmptyFormationEntries.length > 4 && (
|
|
<Text style={{ fontSize: 11, color: colors.textTertiary, marginTop: 2 }}>
|
|
他{nonEmptyFormationEntries.length - 4}件
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)}
|
|
{unyohubGroupNames !== "" && (
|
|
<Text style={[styles.subText, { color: colors.textSecondary }]}>{unyohubGroupNames}</Text>
|
|
)}
|
|
{(hubHasIsQuotation || hubHasFromBeginner || hubHasCommentExists || hubHasHiddenByDefault) && (
|
|
<View style={styles.metaChipRow}>
|
|
{hubHasIsQuotation && (
|
|
<View style={styles.metaChip}>
|
|
<MaterialCommunityIcons name="content-copy" size={11} color="#888" />
|
|
<Text style={styles.metaChipText}>引用データ</Text>
|
|
</View>
|
|
)}
|
|
{hubHasFromBeginner && (
|
|
<View style={[styles.metaChip, styles.metaChipOrange]}>
|
|
<MaterialCommunityIcons name="account-school" size={11} color="#f57c00" />
|
|
<Text style={[styles.metaChipText, { color: "#f57c00" }]}>初心者投稿</Text>
|
|
</View>
|
|
)}
|
|
{hubHasCommentExists && (
|
|
<View style={styles.metaChip}>
|
|
<MaterialCommunityIcons name="comment-text-outline" size={11} color="#0077aa" />
|
|
<Text style={[styles.metaChipText, { color: "#0077aa" }]}>コメントあり</Text>
|
|
</View>
|
|
)}
|
|
{hubHasHiddenByDefault && (
|
|
<View style={[styles.metaChip, styles.metaChipOrange]}>
|
|
<MaterialCommunityIcons name="eye-off-outline" size={11} color="#f57c00" />
|
|
<Text style={[styles.metaChipText, { color: "#f57c00" }]}>非表示設定</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
<RefDirectionBanner
|
|
rows={[{ leftLabel: "宇和島/宿毛/阿波海南" }]}
|
|
color="#888"
|
|
/>
|
|
</View>
|
|
);
|
|
|
|
const elesiteHasNonEmptyFormations = elesiteDisplayGroups.some(
|
|
(group) => group.hasFormations,
|
|
);
|
|
const activeElesiteGroup =
|
|
elesiteDisplayGroups.find((group) => group.key === activeElesiteGroupKey) ??
|
|
elesiteDisplayGroups[0] ??
|
|
null;
|
|
const activeElesiteGroupIndex = activeElesiteGroup
|
|
? elesiteDisplayGroups.findIndex((group) => group.key === activeElesiteGroup.key)
|
|
: 0;
|
|
const elesiteBadgeCount = elesiteHasNonEmptyFormations
|
|
? elesiteDisplayGroups.length
|
|
: null;
|
|
const activeElesiteGroupLabel =
|
|
activeElesiteGroup?.lineLabel ||
|
|
[activeElesiteGroup?.leftStation, activeElesiteGroup?.rightStation]
|
|
.filter(Boolean)
|
|
.join("〜") ||
|
|
null;
|
|
|
|
// 列車情報 subテキスト
|
|
const trainInfoSub = customTrainData?.vehicle_formation
|
|
? customTrainData.vehicle_formation
|
|
: hasTrainInfo
|
|
? "臨時情報あり / コミュニティ樓所データを確認・編集"
|
|
: "編成データ・コミュニティ独自データを確認・編集";
|
|
|
|
const trainDetail = customTrainData ? (
|
|
<TrainInfoDetail
|
|
data={customTrainData}
|
|
typeName={typeName}
|
|
trainName={trainName}
|
|
departureStation={departureStation}
|
|
destinationStation={destinationStation}
|
|
/>
|
|
) : null;
|
|
|
|
const elesiteFormationDetail = (
|
|
<View style={styles.operationDetailBlock}>
|
|
{elesiteDisplayGroups.length > 0 && (
|
|
<ElesiteLineGroupCycler
|
|
groups={elesiteDisplayGroups}
|
|
color="#44bb44"
|
|
onActiveGroupChange={(group) =>
|
|
setActiveElesiteGroupKey(group?.key ?? null)
|
|
}
|
|
/>
|
|
)}
|
|
</View>
|
|
);
|
|
return (
|
|
<ActionSheet
|
|
gestureEnabled
|
|
CustomHeaderComponent={<></>}
|
|
isModal={Platform.OS === "ios" && !Platform.isPad}
|
|
containerStyle={{ backgroundColor: colors.sheetBackground, ...(maxHeight != null ? { maxHeight } : {}) }}
|
|
>
|
|
<View pointerEvents={isCapturing ? "none" : "auto"}>
|
|
<ViewShot ref={viewShot} options={{ format: "jpg" }}>
|
|
{/* ヘッダー */}
|
|
<View style={[styles.header, { backgroundColor: colors.sheetBackground }]}>
|
|
<View style={[styles.handleBar, { backgroundColor: colors.border }]} />
|
|
<View style={styles.headerRow}>
|
|
<Text style={[styles.headerTitle, { color: colors.textPrimary }]}>運用情報ソース</Text>
|
|
{customTrainData?.train_number_override ? (
|
|
<CyclingHeaderTrainNum
|
|
original={trainNum}
|
|
override={customTrainData.train_number_override}
|
|
color={colors.textTertiary}
|
|
/>
|
|
) : (
|
|
<Text style={[styles.headerSub, { color: colors.textTertiary }]}>{trainNum}</Text>
|
|
)}
|
|
</View>
|
|
</View>
|
|
|
|
<ScrollView
|
|
nestedScrollEnabled
|
|
style={[styles.scroll, { backgroundColor: colors.sheetBackground, maxHeight: isCapturing ? undefined : windowHeight * 0.65 }]}
|
|
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={colors.textSecondary}
|
|
title="鉄道運用Hub"
|
|
label="外部コミュニティデータ"
|
|
sub={
|
|
hasNonEmptyFormations
|
|
? unyohubSubItems.length > 1
|
|
? <FadingSubCycler items={unyohubSubItems} color={colors.textSecondary} />
|
|
: 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 && (
|
|
<SourceCard
|
|
imagePng={ELESITE_LOGO_PNG}
|
|
color="#44bb44"
|
|
title="えれサイト"
|
|
label="外部コミュニティデータ"
|
|
sub={
|
|
elesiteCount === 0
|
|
? "この列車の運用データはありません"
|
|
: !elesiteHasNonEmptyFormations
|
|
? activeElesiteGroupLabel || "本日の運用報告なし"
|
|
: elesiteDisplayGroups.length > 1
|
|
? activeElesiteGroupLabel || ""
|
|
: activeElesiteGroup?.lastReportedAt
|
|
? `最終投稿: ${formatHHMM(activeElesiteGroup.lastReportedAt)}`
|
|
: activeElesiteGroupLabel || ""
|
|
}
|
|
badge={elesiteBadgeCount}
|
|
badgeColor="#44bb44"
|
|
detail={elesiteFormationDetail}
|
|
disabled={elesiteCount === 0}
|
|
onPress={() => {
|
|
const url =
|
|
activeElesiteGroup?.timetableUrl || "https://www.elesite-next.com/";
|
|
SheetManager.hide("TrainDataSources");
|
|
Linking.openURL(url);
|
|
}}
|
|
/>
|
|
)}
|
|
</ScrollView>
|
|
</ViewShot>
|
|
</View>
|
|
|
|
<TouchableOpacity
|
|
style={[styles.shareButton, { backgroundColor: colors.sheetBackground, borderColor: colors.border }]}
|
|
onPress={onCapture}
|
|
>
|
|
<MaterialCommunityIcons name="share-variant" size={22} color={colors.textSecondary} />
|
|
<Text style={[styles.shareButtonText, { color: colors.textSecondary }]}>スクリーンショットを共有</Text>
|
|
</TouchableOpacity>
|
|
<View style={styles.footer} />
|
|
</ActionSheet>
|
|
);
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* DirectionBanner: 進行方向表示 */
|
|
/* ------------------------------------------------------------------ */
|
|
const DirectionBanner: FC<{ direction: boolean }> = ({ direction }) => {
|
|
const { colors, fixed } = useThemeColors();
|
|
return (
|
|
<View style={[styles.directionBanner, { backgroundColor: colors.backgroundSecondary }]}>
|
|
{direction ? (
|
|
<>
|
|
<MaterialCommunityIcons name="arrow-left" size={13} color={fixed.primary} />
|
|
<Text style={[styles.directionText, { color: fixed.primary }]}>進行方向</Text>
|
|
</>
|
|
) : (
|
|
<>
|
|
<Text style={[styles.directionText, { color: fixed.primary }]}>進行方向</Text>
|
|
<MaterialCommunityIcons name="arrow-right" size={13} color={fixed.primary} />
|
|
</>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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 (
|
|
<Animated.View style={[styles.headerTrainNumWrap, { opacity }]}>
|
|
<Text style={[styles.headerSub, { color }]}>
|
|
{showOverride ? override : original}
|
|
</Text>
|
|
<Text style={[styles.headerTrainNumLabel, { color }]}>
|
|
{showOverride ? "上書" : "通常"}
|
|
</Text>
|
|
</Animated.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 }) => {
|
|
const { colors } = useThemeColors();
|
|
return (
|
|
<TouchableOpacity
|
|
style={[styles.card, styles.combinedCard, { backgroundColor: colors.surface, borderColor: colors.borderCard }]}
|
|
activeOpacity={0.7}
|
|
onPress={onPress}
|
|
>
|
|
{/* 共通ヘッダー */}
|
|
<View style={styles.combinedHeader}>
|
|
<View style={[styles.colorBar, { backgroundColor: color }]} />
|
|
<Text style={[styles.labelText, styles.combinedLabel, { color: colors.textTertiary }]}>{label}</Text>
|
|
</View>
|
|
{/* 各行 */}
|
|
{rows.map((row, i) => (
|
|
<React.Fragment key={row.title}>
|
|
{i > 0 && <View style={[styles.divider, { marginLeft: 14, backgroundColor: colors.borderCard }]} />}
|
|
<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, { color: colors.textPrimary }]}>{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, { color: colors.textSecondary }]} 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={colors.textDisabled}
|
|
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 { colors } = useThemeColors();
|
|
const {
|
|
infogram,
|
|
train_info,
|
|
vehicle_formation,
|
|
via_data,
|
|
start_date,
|
|
end_date,
|
|
uwasa,
|
|
optional_text,
|
|
} = data;
|
|
const { fontFamily } = 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 (
|
|
<View style={styles.trainDetail}>
|
|
{/* 種別・列車名・始発→行先 */}
|
|
{(typeName || trainName || departureStation || destDisplay) && (
|
|
<View style={[styles.trainTitleBlock, { borderBottomColor: colors.borderSecondary }]}>
|
|
<View style={styles.trainHeaderRow}>
|
|
{!!typeName && (
|
|
<View
|
|
style={[styles.typeTag, { backgroundColor: resolvedTypeColor }]}
|
|
>
|
|
<Text
|
|
style={[
|
|
styles.typeTagText,
|
|
{
|
|
fontFamily,
|
|
fontWeight: fontFamily ? undefined : "bold",
|
|
paddingTop: fontFamily ? 2 : 0,
|
|
},
|
|
]}
|
|
>
|
|
{typeName}
|
|
{fontFamily === "JR-WEST-PLUS" ? (
|
|
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
|
|
) : null}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
{!!trainName && (
|
|
<Text style={[styles.trainNameText, { color: colors.textPrimary }]} numberOfLines={1}>
|
|
{trainName}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
{!!(departureStation || destDisplay) && (
|
|
<View style={styles.routeRow}>
|
|
{!!departureStation && (
|
|
<Text style={[styles.routeStation, { color: colors.textSecondary }]}>{departureStation}</Text>
|
|
)}
|
|
{!!(departureStation && destDisplay) && (
|
|
<MaterialCommunityIcons
|
|
name="arrow-right"
|
|
size={13}
|
|
color={colors.textQuaternary}
|
|
/>
|
|
)}
|
|
{!!destDisplay && (
|
|
<Text style={[styles.routeStation, styles.routeDest, { color: colors.textPrimary }]}>
|
|
{destDisplay}
|
|
</Text>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
{/* LED インフォグラム */}
|
|
{!!infogram && (
|
|
<View style={styles.ledSection}>
|
|
<Text style={[styles.detailSectionLabel, { color: colors.textTertiary }]}>インフォグラム</Text>
|
|
<View style={styles.ledDisplay}>
|
|
<Text style={styles.ledText}>{infogram}</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
|
|
{/* 列車情報テキスト */}
|
|
{!!train_info && (
|
|
<View style={styles.trainInfoSection}>
|
|
<Text style={[styles.detailSectionLabel, { color: colors.textTertiary }]}>列車情報</Text>
|
|
<Text style={[styles.trainInfoText, { color: colors.textSecondary }]}>{train_info}</Text>
|
|
</View>
|
|
)}
|
|
|
|
{/* メタ情報チップ行 */}
|
|
{!!(vehicle_formation || via_data || start_date || end_date) && (
|
|
<View style={styles.metaChipRow}>
|
|
{!!vehicle_formation && (
|
|
<View style={[styles.metaChip, { backgroundColor: colors.backgroundTertiary }]}>
|
|
<MaterialCommunityIcons name="train-car" size={11} color={colors.textTertiary} />
|
|
<Text style={[styles.metaChipText, { color: colors.textSecondary }]}>{vehicle_formation}</Text>
|
|
</View>
|
|
)}
|
|
{!!via_data && (
|
|
<View style={[styles.metaChip, { backgroundColor: colors.backgroundTertiary }]}>
|
|
<MaterialCommunityIcons
|
|
name="map-marker-path"
|
|
size={11}
|
|
color={colors.textTertiary}
|
|
/>
|
|
<Text style={[styles.metaChipText, { color: colors.textSecondary }]}>経由: {via_data}</Text>
|
|
</View>
|
|
)}
|
|
{!!(start_date || end_date) && (
|
|
<View style={[styles.metaChip, styles.metaChipOrange, { backgroundColor: colors.backgroundTertiary }]}>
|
|
<MaterialCommunityIcons
|
|
name="calendar-range"
|
|
size={11}
|
|
color={colors.textWarning}
|
|
/>
|
|
<Text style={[styles.metaChipText, { color: colors.textWarning }]}>
|
|
{start_date ?? ""}〜{end_date ?? ""}
|
|
</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
|
|
{/* うわさ / optional_text */}
|
|
{(!!uwasa || !!optional_text) && (
|
|
<View style={styles.noteSection}>
|
|
{!!uwasa && (
|
|
<View
|
|
style={[
|
|
styles.noteRow,
|
|
styles.rumorRow,
|
|
{
|
|
backgroundColor: "rgba(245, 158, 11, 0.1)",
|
|
borderColor: colors.textWarning,
|
|
},
|
|
]}
|
|
>
|
|
<MaterialCommunityIcons
|
|
name="alert-circle-outline"
|
|
size={14}
|
|
color={colors.textWarning}
|
|
style={styles.rumorIcon}
|
|
/>
|
|
<View style={styles.noteTextWrap}>
|
|
<Text style={[styles.rumorLabel, { color: colors.textWarning }]}>噂情報</Text>
|
|
<Text style={[styles.noteText, { color: colors.textSecondary }]}>{uwasa}</Text>
|
|
</View>
|
|
</View>
|
|
)}
|
|
{!!optional_text && (
|
|
<View style={styles.noteRow}>
|
|
<MaterialCommunityIcons
|
|
name="information-outline"
|
|
size={12}
|
|
color={colors.iconSecondary}
|
|
/>
|
|
<Text style={[styles.noteText, { color: colors.textSecondary }]}>{optional_text}</Text>
|
|
</View>
|
|
)}
|
|
</View>
|
|
)}
|
|
</View>
|
|
);
|
|
};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* 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<SourceCardProps> = ({
|
|
icon,
|
|
imagePng,
|
|
color,
|
|
title,
|
|
label,
|
|
sub,
|
|
badge,
|
|
badgeColor,
|
|
disabled,
|
|
onPress,
|
|
detail,
|
|
}) => {
|
|
const { colors } = useThemeColors();
|
|
return (
|
|
<TouchableOpacity
|
|
style={[styles.card, { backgroundColor: colors.surface, borderColor: colors.borderCard }, 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, { color: colors.textPrimary }]}>{title}</Text>
|
|
<Text style={[styles.labelText, { color: colors.textQuaternary }]}>{label}</Text>
|
|
</View>
|
|
{sub && (
|
|
typeof sub === "string" ? (
|
|
<Text style={[styles.subText, { color: colors.textSecondary }]} numberOfLines={1}>
|
|
{sub}
|
|
</Text>
|
|
) : (
|
|
<View style={styles.subNodeWrap}>{sub}</View>
|
|
)
|
|
)}
|
|
{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={colors.textDisabled} />
|
|
</View>
|
|
</TouchableOpacity>
|
|
)};
|
|
|
|
/* ------------------------------------------------------------------ */
|
|
/* スタイル */
|
|
/* ------------------------------------------------------------------ */
|
|
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,
|
|
},
|
|
routePagerText: {
|
|
fontSize: 11,
|
|
fontWeight: "600",
|
|
marginBottom: 4,
|
|
},
|
|
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,
|
|
},
|
|
elesiteFormationWrap: {
|
|
marginBottom: 4,
|
|
},
|
|
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,
|
|
},
|
|
});
|