Compare commits

..

160 Commits

Author SHA1 Message Date
harukin-expo-dev-env 907ddf9dae Merge commit '929c444474a121b15a8f995351387646fdb35e88' into develop 2026-07-15 08:04:16 +00:00
harukin-expo-dev-env 929c444474 fix: 通過駅のフィルタリングロジックを改善し、列車データの取得処理を最適化 2026-07-15 08:04:05 +00:00
harukin-expo-dev-env b6800d6566 fix: 列車情報の時間フィルタリングロジックを改善し、再生中の基準時刻を追加 2026-07-14 10:49:31 +00:00
harukin-expo-dev-env b0a0d62344 fix: 列車情報の表示ロジックを改善し、条件付きHTML生成を追加 2026-07-14 09:11:23 +00:00
harukin-expo-dev-env bfd90b50e4 feat: 列車タイプのラッピング処理を追加し、表示ロジックを改善 2026-07-14 05:57:05 +00:00
harukin-expo-dev-env 553735ac1d feat: JR-WEST-PLUSフォントの調整と表示領域の正規化を実施 2026-07-14 04:26:38 +00:00
harukin-expo-dev-env 5f1641ff90 feat: JR-WEST-PLUSフォントを追加し、フォント設定を最適化 2026-07-14 02:51:41 +00:00
harukin-expo-dev-env 351854f128 fix: 修正された列車データの取得と表示ロジックを改善し、未登録列車の処理を追加 2026-07-14 00:46:35 +00:00
harukin-expo-dev-env e6f0391484 feat: Implement X投稿向け運行情報画像セット generation in ndView.tsx
- Added functionality to generate a set of images for posting to X (formerly Twitter) with multiple images.
- Introduced a new button for creating X投稿向け画像, which generates up to 4 PNG images.
- The first image serves as a cover with a route map and summary, while subsequent images contain detailed operation information.
- Ensured images maintain a fixed size of 1080 x 1440 px and follow specific naming conventions.
- Implemented error handling for image generation failures and ensured compatibility with existing sharing features.
- Updated UI elements and styles for the new functionality, including button states and layout adjustments.
- Added necessary TypeScript type checks and validation for the new features.
2026-07-12 19:01:51 +00:00
harukin-expo-dev-env b1cfad4528 Merge commit '76b13a70a9e8f791bc8551107b7609581e92e8d5' into develop 2026-07-12 15:40:52 +00:00
harukin-expo-dev-env 76b13a70a9 型定義を改善し、originalStationListの使用を最適化 2026-07-12 15:35:00 +00:00
harukin-expo-dev-env eec2fd2bb7 Refactor MenuPage component and update dependencies
- Removed unused navigation logic from MenuPage.tsx.
- Added postinstall script for patch-package in package.json.
- Introduced a new patch for @react-navigation/bottom-tabs to fix zIndex and opacity issues on Android.
- Updated yarn.lock with new dependencies and versions, including patch-package and others.
2026-07-12 10:59:40 +00:00
harukin-expo-dev-env 8c2930c814 ネイティブスクリーンの無効化を条件化し、スクリーンの非アクティブ状態を保持する設定を追加 2026-07-11 04:27:16 +00:00
harukin-expo-dev-env c7fdf6f0b1 Merge commit '9746a4d04168112371c44a7a1afd72cd8dd04e4f' into develop 2026-07-11 00:14:03 +00:00
harukin-expo-dev-env 9746a4d041 iOSおよびAndroidのビルド番号とバージョンコードを更新 2026-07-10 11:33:25 +00:00
harukin-expo-dev-env 740c825122 Merge commit 'f6b25ce5d30f7b5f85cb95c7d2e62432eb6aa1ca' into patch/6.x 2026-07-10 11:30:13 +00:00
harukin-expo-dev-env f6b25ce5d3 Merge commit '390ab3ac6932cbb0a25d179a0e8d81c061d8f268' into develop 2026-07-10 11:28:33 +00:00
harukin-expo-dev-env 390ab3ac69 履歴管理機能を追加し、ビューポートメタタグの管理を強化 2026-07-10 11:28:15 +00:00
harukin-expo-dev-env 2eb926890c Adjust train type badge rendering 2026-07-09 10:15:49 +00:00
harukin-expo-dev-env bf2e68a3a5 各種機能の改善と最適化、ナビゲーションの明示的なターゲット設定を追加 2026-07-08 14:39:15 +00:00
harukin-expo-dev-env dd4892f6b7 各種データの取得フラグ機能を実装、動作の最適化 2026-07-08 01:39:29 +00:00
harukin-expo-dev-env 61548ea3f9 Tune data fetch observability after first run 2026-07-07 04:10:24 +00:00
harukin-expo-dev-env bf0c4d90d9 Add data fetch observability 2026-07-07 02:47:13 +00:00
harukin-expo-dev-env 47d6f043b4 Work around Android user location map crash 2026-07-06 17:44:15 +00:00
harukin-expo-dev-env aacae06743 Initialize root tab state on startup 2026-07-06 08:42:28 +00:00
harukin-expo-dev-env dab60c47fa Tighten hidden tab preload timing 2026-07-06 08:31:08 +00:00
harukin-expo-dev-env 39fe154b33 Add hidden webview startup preload 2026-07-06 08:25:25 +00:00
harukin-expo-dev-env a8582c9fd6 Run hidden prewarm without interaction gate 2026-07-06 07:50:39 +00:00
harukin-expo-dev-env 3133535606 Prewarm operation tab from top menu startup 2026-07-06 07:01:06 +00:00
harukin-expo-dev-env a5e1b7d297 Restore Android carousel parallax mode 2026-07-06 06:20:13 +00:00
harukin-expo-dev-env 021a9b0101 Restore Android carousel and add runtime flags 2026-07-06 05:58:03 +00:00
harukin-expo-dev-env 6cea1ed4e8 Disable iOS screen optimizations for webviews 2026-07-04 16:33:33 +00:00
harukin-expo-dev-env 17f4db58bc Stabilize root tab screen mounting 2026-07-04 16:26:59 +00:00
harukin-expo-dev-env 9740a7639b Stabilize positions webview retention on iOS 2026-07-04 16:22:35 +00:00
harukin-expo-dev-env 191a536f5c Guard iOS webview remount on tab blur 2026-07-04 15:57:04 +00:00
harukin-expo-dev-env 07514bf92d Preserve operation webview and quiet noisy defaults 2026-07-04 15:48:37 +00:00
harukin-expo-dev-env 997d910d1c Stabilize background WebView retention 2026-07-04 15:45:01 +00:00
harukin-expo-dev-env 9ea9d6764b Adjust WebView remount behavior for background tabs 2026-07-03 10:24:41 +00:00
harukin-expo-dev-env cf3995fcbe factor code structure for improved readability and maintainability 2026-07-02 02:37:34 +00:00
harukin-expo-dev-env 983c453322 Merge commit 'c69585b257d234fbc640839c851ed3d57cfebd63' into develop 2026-07-01 12:10:45 +00:00
harukin-expo-dev-env c69585b257 インポート機能の作成、録画システムのポジション最適化 2026-06-29 16:11:42 +00:00
harukin-expo-dev-env c6d4fca881 Merge commit '9c6719050935e76e3618ece27b9b838b5efe05e8' into develop 2026-06-29 14:43:20 +00:00
harukin-expo-dev-env 9c67190509 Update signage view configuration 2026-06-29 14:42:44 +00:00
harukin-expo-dev-env 956b660b4f Add recording JSON import and export 2026-06-29 14:39:30 +00:00
harukin-expo-dev-env f056fe593a Sentryロギングシステムを追加し、アプリのエラートラッキングを強化 2026-06-29 06:08:29 +00:00
harukin-expo-dev-env ca8d3225d1 Merge commit '0ebc6bc153d9893c7d067dd31805d2b87e3a3e9b' into develop 2026-06-27 04:37:27 +00:00
harukin-expo-dev-env 0ebc6bc153 Merge commit 'fbd342d640a13972e92a7ed35c061d42ca9f5a75' into patch/6.x 2026-06-27 04:35:48 +00:00
harukin-expo-dev-env fbd342d640 要素のサイズ変更 2026-06-27 04:33:54 +00:00
harukin-expo-dev-env b6e6d2e809 Merge commit '9eff185351a96859f9fb06c08b073c7b3e5cefa3' into develop 2026-06-25 17:46:42 +00:00
harukin-expo-dev-env 9eff185351 7.0.7 release 2026-06-25 17:36:48 +00:00
harukin-expo-dev-env 10e9aece2b Merge commit 'a1b914485134d0c1d1226a45ca43cd35505eeeef' into patch/6.x 2026-06-25 17:35:52 +00:00
harukin-expo-dev-env a1b9144851 Merge commit '16a62aa3bad5279d05dab49160a31388e4f9aef5' into develop 2026-06-25 17:16:17 +00:00
harukin-expo-dev-env 16a62aa3ba 小見出しも並べられるように変更 2026-06-25 16:57:20 +00:00
harukin-expo-dev-env f8395d7270 Hubアイコンを権限餅専用に 2026-06-25 16:20:04 +00:00
harukin-expo-dev-env 5a0fb9f43c 団体臨時から臨時を削除 2026-06-25 16:01:31 +00:00
harukin-expo-dev-env 9d188198a9 Merge commit 'cb1d2665d811ff6cc2a75806654c1e9f823201e7' into develop 2026-06-25 14:27:38 +00:00
harukin-expo-dev-env cb1d2665d8 切り替えスイッチを設置 2026-06-25 14:27:24 +00:00
harukin-expo-dev-env 3bbdd9d0a6 レイアウト機能調整 2026-06-25 13:26:51 +00:00
harukin-expo-dev-env 159f91e55e Merge commit 'daf261b4f842a5fa050cde0643d86654ae45150a' into feature/train-info-edit 2026-06-23 17:12:37 +00:00
harukin-expo-dev-env daf261b4f8 Merge commit '50fa4de9196adbb86313acadbe403d9a64af585a' into develop 2026-06-23 17:12:31 +00:00
harukin-expo-dev-env 50fa4de919 アイコンステータスの表示改善 2026-06-23 17:11:28 +00:00
harukin-expo-dev-env ab2061b2bd unyohubアイコン対応 2026-06-23 16:40:44 +00:00
harukin-expo-dev-env 4dd0775640 feat: add userscript generator for JR Shikoku operation info
- Implemented a script to generate a Tampermonkey userscript from ndView.tsx.
- Extracted and modified the operation page script body to include necessary postMessage functions.
- Added metadata for the userscript including name, namespace, version, and match URL.
- Ensured compatibility with Tampermonkey's GM_download for image capture.
2026-06-20 06:07:39 +00:00
harukin-expo-dev-env 4f81431ccd feat: 列車情報のキャプチャ機能を追加し、スタイルを改善 2026-06-10 05:04:09 +00:00
harukin-expo-dev-env 6371787b47 Merge commit '0ced766608b43cd9244817a0732246ca8b9c5f38' into develop 2026-06-06 08:35:26 +00:00
harukin-expo-dev-env 0ced766608 fix: バージョンコードを7.0.6に更新 2026-06-05 12:48:19 +00:00
harukin-expo-dev-env 26b61cdbc9 Merge commit '0e6763d9545f03b1331fc60e050dfa346afc4153' into patch/6.x 2026-06-05 12:35:07 +00:00
harukin-expo-dev-env 0e6763d954 feat: えれサイトの列車情報をグループ化し、表示を改善 2026-06-05 12:31:54 +00:00
harukin-expo-dev-env 7ba9a750ea feat: 録画の削除機能を追加し、スワイプ操作での削除を実装 2026-06-01 12:11:00 +00:00
harukin-expo-dev-env f9b2786809 Merge commit '95f498e89418c9bb8c551987f0b3f1b693c05c1d' into develop 2026-05-20 15:29:09 +00:00
harukin-expo-dev-env 95f498e894 fix: マリンライナー編成順序の修正(elesite連携)
マリンライナー(3xxxM)の岡山〜高松間の駅シーケンスを
elesiteTrainOrder.ts と webViewInjectjavascript.ts に追加。
originData には JR 西日本区間の駅がないため、
MARINE_STATION_SEQUENCE としてインラインで定義した。

これにより heading_to:"unknown" のマリン系統でも
正しく進行方向を推論できるようになり、
連結順序が逆順になる問題を解消した。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-20 13:48:47 +00:00
harukin-expo-dev-env a88f7bac09 Merge commit '23ef404f7b8f728749024b74e1b8742d5b75d529' into develop 2026-05-20 06:18:08 +00:00
harukin-expo-dev-env 23ef404f7b fix: update version code to 7.0.5 in SettingTopPage component 2026-05-20 06:17:56 +00:00
harukin-expo-dev-env 709cbda835 feat: add button for paper timetable in FixedContentBottom component 2026-05-20 06:02:50 +00:00
harukin-expo-dev-env f2bb72e40d Merge commit '6650a8c9513d426701abeef65d35bae0f8d79bb3' into patch/6.x 2026-05-19 03:07:03 +00:00
harukin-expo-dev-env 6650a8c951 feat: add experimental diagram URL support and update related components 2026-05-19 03:06:45 +00:00
harukin-expo-dev-env d76daf8791 Merge commit '8d7005170c0a7b89587871aef84ebb55df473c04' into patch/6.x 2026-05-15 17:22:28 +00:00
harukin-expo-dev-env 8d7005170c Refactor Elesite sorting logic and introduce new utility functions
- Moved the Elesite sorting logic to a new module `elesiteTrainOrder.ts`.
- Created utility functions to infer heading direction and sort Elesite entries by train number.
- Updated `injectJavascriptData` to utilize the new sorting logic and handle station sequences.
- Refactored `useElesite` to use the new sorting utility for improved readability and maintainability.
2026-05-15 17:21:18 +00:00
harukin-expo-dev-env 37e147e3be Merge commit '26aa71866ae1c949f0c31c7899ef68c30d111a61' into develop 2026-05-05 10:43:58 +00:00
harukin-expo-dev-env 26aa71866a feat: add Koboke Observatory to tourist spots data 2026-05-05 09:10:34 +00:00
harukin-expo-dev-env 2274934731 fix: update version code to 7.0.4 2026-05-05 08:12:25 +00:00
harukin-expo-dev-env 6c674a3f38 Merge commit 'd72acabe9de6b17c5e2206bea9f69ddd2ae3aea1' into patch/6.x 2026-05-05 08:10:22 +00:00
harukin-expo-dev-env d72acabe9d Merge commit '3e0e4876bc64e73100b6c825d6218201577a6f9a' into develop 2026-05-05 08:09:57 +00:00
harukin-expo-dev-env 3e0e4876bc feat: purple reload button when mock API is active
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-05 08:06:55 +00:00
harukin-expo-dev-env 3ab0b332ec Merge commit '5e6ff42fe6d6e30b30ae96e13fcd598197ca5fd8' into develop 2026-05-05 08:04:56 +00:00
harukin-expo-dev-env 5e6ff42fe6 Add script to compile web inject JavaScript with minification and obfuscation options
- Introduced `compile-web-script.ts` for generating JavaScript files for userscripts.
- Supports options for minification and obfuscation.
- Generates base JS, minified JS, and obfuscated userscript.
- Configurable via command line arguments and environment variables.
2026-05-05 08:04:47 +00:00
harukin-expo-dev-env 4916edd498 fix: use /train-positions/current endpoint for mock polling
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 18:25:47 +00:00
harukin-expo-dev-env 0c582765db feat: poll mock API server for live train positions
- positionMasters: add fetchMockTrainPositions() targeting
  /train-positions/latest on the mock API server
- useTrainMenu: when mockApiFeatureEnabled and not recording/playing,
  poll /train-positions/latest every 15s and update mockTrainPositions
  (same interval as live mode); on mode switch updates immediately
- useCurrentTrain:
  - playing: use local recording data (no network call)
  - mock ON idle: fetch from mock API server (15s poll driven by useTrainMenu)
  - mock OFF: fetch from real backend as before
- Recording still forces mock OFF so real API data is captured

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 18:01:12 +00:00
harukin-expo-dev-env ff6a8c9518 feat: integrate position-masters API into mock system
- Add lib/mockApi/positionMasters.ts
  - fetchPositionMasters() from jr-shikoku-backend-mock-api-v1.haruk.in
  - buildPosLookup() / lookupPos() helpers
  - serializePosLookupForJs() for baking into injected scripts
- webviewXhrInterceptor: accept positionMasters in MockApiConfig,
  bake _POS_LOOKUP into injected JS, enrich Pos from PosNum on serve
- useTrainMenu: fetch position masters on mock-enable (and at startup
  if already enabled); expose positionMasters + lookupPosText()
- useCurrentTrain: fill Pos via lookupPosText when mapping mock data
- mockApi/index.ts: re-export positionMasters types and helpers

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-03 17:46:13 +00:00
harukin-expo-dev-env c425c0917b Merge commit '7b7ac73169255ae569ab38756f14a139dd255b9c' into develop 2026-05-02 09:15:31 +00:00
harukin-expo-dev-env 7b7ac73169 feat: add detailed changelog for version update to 7.0.3, highlighting new features and improvements 2026-05-02 08:59:55 +00:00
harukin-expo-dev-env 8594e79c25 Merge commit '6ce2cad4e5428dd30c066053176c59784cbe6fc0' into develop 2026-05-02 03:01:15 +00:00
harukin-expo-dev-env 6ce2cad4e5 fix: update version code to 7.0.3 2026-05-02 02:59:29 +00:00
harukin-expo-dev-env 75795f31e9 Merge commit '35542339943b4da6c181d5b6cc586d720752ab13' into patch/6.x 2026-05-02 02:53:10 +00:00
harukin-expo-dev-env 3554233994 fix: update header colors in injected JavaScript for better visibility 2026-05-02 02:52:22 +00:00
harukin-expo-dev-env d71cc373c9 fix: sync WebView train display when playback frame changes
The XHR interceptor baked _MOCK_TRAIN at page load time, so seeking or
advancing a playback frame only changed React state but had no effect
on the already-loaded WebView page.

- webviewXhrInterceptor.ts:
  - Expose window.__jrsMockUpdateTrain(newData) from inside the IIFE so
    the _MOCK_TRAIN variable can be updated after page load
  - Add generateMockUpdateScript(trainPositions) helper: calls
    __jrsMockUpdateTrain with fresh data + GetDateTime, then calls
    window.setReload() to trigger the page's own redraw cycle

- WebView.tsx:
  - Import generateMockUpdateScript
  - Add useEffect watching mockTrainPositions (skips first mount since
    beforeContentLoaded already has correct data)
  - When mockApiFeatureEnabled && mockTrainPositions changes, inject the
    update script via webview.current.injectJavaScript()

Result: seeking, prev/next frame, and auto-advance during playback now
immediately update the train position display in the WebView.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 01:06:34 +00:00
harukin-expo-dev-env 3587f72434 feat: add RecordingStatusBar with elapsed timer and keep-awake
- RecordingStatusBar.tsx: shown during recorderState === 'recording'
  - absolute positioned red bar at top of map screen (zIndex 2000)
  - blinking REC dot (700ms interval) + REC label + MM:SS elapsed timer
  - snapshot count display (right side)
  - pointerEvents="none" so it doesn't block map interaction
  - activateKeepAwakeAsync while recording, deactivated on stop/unmount
    (same pattern as FixedPositionBox, tag = 'recording-status-bar')
- Apps.tsx: render <RecordingStatusBar /> alongside PlaybackTimeline

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 00:59:03 +00:00
harukin-expo-dev-env 4f4d3cad0a fix: crash on playback start + support multiple recordings
Crash fix (React Hooks violation):
- PlaybackTimeline.tsx: move ALL hooks (useRef for PanResponder, useCallback)
  to before the early return; use seekRef/totalRef to share values into
  PanResponder handlers without stale closure issues

Multiple recordings support:
- trainRecorder.ts: redesign to id-based multi-recording system
  - RecordingMeta type for lightweight list (no snapshots)
  - TrainRecording now includes id field
  - saveRecording/loadRecordingById/deleteRecordingById/loadRecordingList
  - migrateOldRecording() migrates old single MOCK_RECORDING key on first launch
- constants/storage.ts: add MOCK_RECORDINGS_INDEX + MOCK_RECORDING_DATA_PREFIX keys
- useTrainMenu: savedRecording → recordingList (RecordingMeta[]) + activeRecording (TrainRecording|null)
  - startPlayback(id) loads full recording on demand
  - deleteRecording(id) deletes by id and refreshes list
  - stopPlayback clears activeRecording
- DataSourceSettings: recording list UI
  - shows all recordings with date/time, snapshot count, duration
  - ▶ play and 削除 buttons per row
  - recording/playing status indicator

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 00:52:30 +00:00
harukin-expo-dev-env 8144e8a48a feat: add playback timeline UI with pause/resume/seek controls
- useTrainMenu: add playbackPaused state, pausePlayback/resumePlayback/seekToSnapshot
  - playback loop now respects playbackPaused (no timer when paused)
  - seekToSnapshot pauses playback and immediately applies snapshot data
  - expose playbackIndex, playbackPaused + new functions in context value
- PlaybackTimeline.tsx: new component shown when recorderState === 'playing'
  - absolute positioned bar at top of map screen (zIndex 2000)
  - prev/play-pause/next frame buttons + skip-to-start/end
  - time display (HH:mm:ss) + snapshot counter (n/total) + total duration
  - PanResponder-based scrubber track with filled progress bar and draggable thumb
- Apps.tsx: render <PlaybackTimeline /> alongside FixedPositionBox
- Remove MockApiToggle.tsx (no longer used since settings-only toggle)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 00:42:56 +00:00
harukin-expo-dev-env 37c08ad257 feat: add train position record & playback feature
Implements snapshot-based recording and playback of live train
position data for mock API debugging (admin-only).

Recording:
- startRecording() disables mock mode and begins capturing live
  train snapshots every ~15s (via getCurrentTrain polling)
- Each snapshot stores { t: elapsed_ms, trains: TrainEntry[] }
- stopRecording() saves completed recording to AsyncStorage

Playback:
- startPlayback() enables mock mode and loops through snapshots
  at their original recorded timing using useEffect+setTimeout
- stopPlayback() returns to idle

Storage:
- Single slot in AsyncStorage (MOCK_RECORDING key)
- Loaded on app start in useTrainMenu
- deleteRecording() removes it

New files:
- lib/mockApi/trainRecorder.ts — TrainRecording/TrainSnapshot types
  + save/load/delete AsyncStorage helpers

Modified files:
- constants/storage.ts — add MOCK_RECORDING key
- stateBox/useTrainMenu.tsx — recorder state + all controls
- stateBox/useCurrentTrain.tsx — call addTrainSnapshot after live fetch
- components/Settings/DataSourceSettings.tsx — record/play UI with
  status dot, snapshot count, and action buttons

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-02 00:31:55 +00:00
harukin-expo-dev-env b5eb830734 fix: update useWebViewRemount to include backgroundThresholdMs option for better app state handling 2026-05-01 23:25:50 +00:00
harukin-expo-dev-env 92f4b37861 feat: apply mock data to currentTrain (app-side train positions)
When MOCK_API_FEATURE_ENABLED is on, getCurrentTrain() now returns
mock data instead of fetching from the n8n webhook or fallback API.

- App.tsx: move TrainMenuProvider before CurrentTrainProvider so
  useTrainMenu() is available inside CurrentTrainProvider
- useCurrentTrain: import useTrainMenu + MOCK_TRAIN_POSITIONS;
  getCurrentTrain() short-circuits to mock data when mockApiFeatureEnabled;
  added useEffect on mockApiFeatureEnabled to reload immediately on toggle

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 12:40:30 +00:00
harukin-expo-dev-env a35956848a refactor: remove map-screen MOCK switch; settings toggle controls mock directly
The separate runtime MOCK switch on the map screen is removed.
Now the admin-only settings toggle (MOCK_API_FEATURE_ENABLED) is the
single control point — turning it on activates mock mode immediately,
turning it off deactivates it.

- useTrainMenu: remove mockApiEnabled/setMockApiEnabled state;
  mockApiConfig now derives from mockApiFeatureEnabled alone
- WebView: use mockApiFeatureEnabled for key prop (triggers reload)
- Apps.tsx: remove MockApiToggle import and JSX usage

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 12:35:36 +00:00
harukin-expo-dev-env a809d287a2 fix: eliminate UX flash by calling setStrings() immediately in observer
The 100ms pure-debounce approach caused a visible layout flash on every
render (normal mode too) because Tokyo UX was not applied for 100ms.

Fix: call setStrings() immediately in the MutationObserver callback to
prevent any flash, AND keep a 250ms delayed follow-up call to catch
async train re-renders from mock XHR setTimeout(0) responses.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 11:59:52 +00:00
harukin-expo-dev-env 7566910821 fix: re-apply Tokyo UX after async train re-renders
Root cause: setStrings() was called synchronously in setReload(),
but the mock XHR interceptor responds via setTimeout(0), so the
site re-rendered train elements AFTER setStrings() ran, stripping
Tokyo UX styling.

Two-pronged fix:
1. setReload() now calls setStrings() again after 200ms to
   re-apply Tokyo UX once the async re-render completes.
2. textInsert MutationObserver now watches with subtree:true
   (childList only, not attributes - no infinite loop risk)
   with 100ms debouncing, so ANY train element re-render
   (including those triggered by mock XHR data) will trigger
   a re-application of Tokyo UX.
   - currentLines postMessage now only fires when direct
     children of #disp change (line selection changes only)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 11:51:13 +00:00
harukin-expo-dev-env 2cf6b679c5 fix: Tokyo UX stripped when mock is active
Two bugs caused the Tokyo UX to be stripped/broken when mock mode was ON:

1. **Double callback firing**: the interceptor's `setTimeout` called both
   `onreadystatechange.call(self)` AND `dispatchEvent(new Event('readystatechange'))`.
   Since `dispatchEvent` already fires `onXxx` property handlers via the DOM event
   model, the page's callback fired twice, causing a second DOM re-render that could
   overwrite Tokyo UX modifications. Fixed by relying on `dispatchEvent` only (with
   `ProgressEvent` for load events), with a direct-call fallback only for environments
   that lack `dispatchEvent`.

2. **No double-injection guard**: if `injectedJavaScriptBeforeContentLoaded` caused
   the interceptor to run more than once (e.g., `onPageStarted` fires multiple times
   on Android), `_origOpen` would capture the already-patched version, leading to
   unexpected behaviour. Fixed by adding `if (window.__jrsMockActive) return;` at the
   top of the interceptor IIFE.

3. **Belt-and-suspenders injection**: the interceptor is now also prepended to
   `injectedJavaScript` (Tokyo UX script) via the restored `mockApiConfig` parameter
   on `injectJavascriptData`. The `__jrsMockActive` guard ensures it's a no-op when
   `injectedJavaScriptBeforeContentLoaded` already ran it, but guarantees the
   interceptor is active on platforms where IJBCL is unreliable — before
   `MoveDisplayStation` triggers the first train-data poll via `onLoadEnd`.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 11:40:11 +00:00
harukin-expo-dev-env 1dcc25dec0 chore: update train.json with live disrupted data (2026-05-01 19:42)
94列車、62列車遅延の運行乱れデータをキャプチャ。
モックAPIのテストデータとして有用な状態。

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-05-01 10:43:19 +00:00
harukin-expo-dev-env 24f0c82b54 fix: call _origOpen even when intercepting to allow setRequestHeader
インターセプト対象のXHRでも_origOpen()を呼ぶよう修正。
これによりXHRがOPENED状態に入り、ページ側のsetRequestHeader()呼び出しが
エラーなく実行される。実際のリクエストはsend()でブロックすることで
モックデータのみ返す動作は維持される。

Playwright実機テスト結果:
- arg1=train&arg2=train: 2回インターセプト成功、実ネットワーク0件
- setRequestHeaderエラー: 解消
- 路線選択後のstation dataロード: 正常動作

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 22:49:22 +00:00
harukin-expo-dev-env 8321a47cbb fix: move XHR interceptor to injectedJavaScriptBeforeContentLoaded
インターセプターをページスクリプトより前に実行されるよう修正。
injectedJavaScript(ページ読込後)からinjectedJavaScriptBeforeContentLoaded
(ページスクリプト実行前)へ移動することで、モックON時のページフリーズを解消。

- lib/webViewInjectjavascript.ts: injectJavascriptDataからインターセプターを分離、
  generateBeforeContentLoadedScript()を新規エクスポート
- stateBox/useTrainMenu.tsx: injectJavascriptBeforeContentLoadedを別途計算しコンテキストに追加
- components/Apps/WebView.tsx: injectedJavaScriptBeforeContentLoadedプロップを追加

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 16:50:55 +00:00
harukin-expo-dev-env 59821a40d2 fix: rewrite XHR interceptor using prototype patching
Replaces the MockXHR wrapper class with direct prototype patching.

Root cause of broken page:
1. CRITICAL: callbacks called without .call(self) → 'this' inside
   onload/onreadystatechange was the global object, so this.responseText
   returned undefined and the page's JSON.parse crashed silently
2. responseType='json' not handled → page received string instead of
   parsed object when using xhr.response
3. instanceof XMLHttpRequest returned false, potentially breaking page
   validation logic

New approach (prototype-patch):
- Save original XMLHttpRequest.prototype.open and .send
- Patch open() to detect mock URL and store mock body on the instance
- Patch send() to define instance-level property getters (status,
  responseText, response, readyState) and fire callbacks with correct
  'this' context via .call(self)
- responseType='json' handled: response getter returns parsed object
- instanceof XMLHttpRequest always true (prototype chain untouched)
- dispatchEvent fires load/readystatechange for addEventListener users
- Non-mock requests delegate to original open/send unchanged

All 6 test cases pass: onload, onreadystatechange, addEventListener,
responseType=json, instanceof, and non-mock pass-through

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 16:05:41 +00:00
harukin-expo-dev-env 42473189da fix: reload WebView on mock toggle and fix XHR callback timing bug
- components/Apps/WebView.tsx:
  - import mockApiEnabled from context
  - add mockApiEnabled to WebView key so toggling forces a full reload
    (injectedJavaScript only runs at page load, so reload is required)

- lib/mockApi/webviewXhrInterceptor.ts:
  - Fix callback timing bug: callbacks set before open() were stored on
    _orig (because _mockBody was null), never on self._onload, causing
    mock send() to fire no-op callbacks
  - Always store all callbacks/listeners in _callbacks/_listeners maps
  - Apply _callbacks to _orig at send() time when not mocked
  - Fix addEventListener: always buffer in _listeners, apply to _orig
    at send() time; add removeEventListener support

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 15:50:37 +00:00
harukin-expo-dev-env 71e1ad8d22 feat: add admin mock API toggle in settings and map screen switch
- constants/storage.ts: add MOCK_API_FEATURE_ENABLED key
- stateBox/useTrainMenu.tsx:
  - import MOCK_TRAIN_POSITIONS; auto-populate on mount and on toggle-off
  - add mockApiFeatureEnabled (persistent, admin-only) state + setter
  - mockApiConfig now requires both mockApiFeatureEnabled AND mockApiEnabled
- components/Settings/DataSourceSettings.tsx:
  - add mock API debug section (admin-gated, showDebugSelector)
  - Switch toggles mockApiFeatureEnabled and persists to AsyncStorage
- components/Apps/MockApiToggle.tsx: new component
  - absolute-positioned to the left of ReloadButton
  - visible only when mockApiFeatureEnabled && mapSwitch==="true"
  - MOCK label + Switch toggles mockApiEnabled runtime state
- components/Apps.tsx: render MockApiToggle alongside ReloadButton

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 15:41:24 +00:00
harukin-expo-dev-env 170fbf0a57 feat: add WebView XHR interceptor for mock train position injection
- Scrape & analyse all 19 internal APIs of train.jr-shikoku.co.jp/sp.html
  using puppeteer + Chromium remote debugging
- Save live-captured sample JSON for every API endpoint under
  lib/mockApi/mockData/ (station lists, lang, timetable, train positions, etc.)
- Add lib/mockApi/webviewXhrInterceptor.ts – generates a JS snippet that
  overrides XMLHttpRequest inside the WebView before page scripts run;
  intercepts /g?arg1=train&arg2=train (and optionally all static APIs)
  returning mock data instead of the real server response
- Add lib/mockApi/index.ts – convenience re-exports + MOCK_TRAIN_POSITIONS
  constant pre-populated with captured sample data
- Extend InjectJavascriptOptions with optional mockApiConfig field
- injectJavascriptData() prepends the interceptor JS when mockApiConfig is set
- TrainMenuContext gains mockApiEnabled / setMockApiEnabled and
  mockTrainPositions / setMockTrainPositions state; consumers can enable
  mock mode and inject custom TrainEntry[] without reloading the app

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-30 15:30:06 +00:00
harukin-expo-dev-env 899c655d8c fix: add platformNum property to train data structures for improved display 2026-04-30 15:18:28 +00:00
harukin-expo-dev-env 4c1a315f5d fix: add loading and error handling in GeneralWebView component with reload functionality 2026-04-30 14:27:15 +00:00
harukin-expo-dev-env a8c785bf7f fix: expose setReload function to the global window object for accessibility 2026-04-30 14:01:55 +00:00
harukin-expo-dev-env a9668e6d51 fix: trim train numbers in sorting and filtering logic for accurate matching 2026-04-30 09:49:44 +00:00
harukin-expo-dev-env 3a4e083b9d feat: add FormationChips, FadingSubCycler, and ActiveFormationChipsCycler components for enhanced train data visualization 2026-04-30 08:59:46 +00:00
harukin-expo-dev-env 45cc68ae56 fix: normalize train numbers by stripping suffixes in TrainDataSources and useUnyohub
Co-authored-by: Copilot <copilot@github.com>
2026-04-29 15:51:15 +00:00
harukin-expo-dev-env d2e2f1d05c Merge commit '43f8095912f564ab02c3ddfbc0e218df5778cee6' into develop 2026-04-29 11:15:58 +00:00
harukin-expo-dev-env 43f8095912 feat: add changelog for version update from 064d81d4 to a959cf39, highlighting improvements in elesite integration, navigation stability, keyboard handling, and overall user experience 2026-04-29 11:15:52 +00:00
harukin-expo-dev-env 14e8e3422c Merge commit 'fac89f6f2ab74695e4f52e6a043e43f7f5c7f411' into patch/6.x 2026-04-26 08:49:28 +00:00
harukin-expo-dev-env fac89f6f2a fix: simplify elesite permission handling and update version code to 7.0.2 2026-04-26 08:43:04 +00:00
harukin-expo-dev-env 331b9d64d2 Merge commit '94eb84b6de6c0ad86f90542c80efe262595e8a57' into develop 2026-04-26 07:56:56 +00:00
harukin-expo-dev-env 94eb84b6de fix: enhance ListViewItem cycling animation and update train pair mapping in BusAndTrainDataProvider 2026-04-25 07:34:10 +00:00
harukin-expo-dev-env 9fb591d6c2 Merge commit '3ecb301e8276ca20ce9af849e5d808850653a0ab' into fix/April-Mid-Patch 2026-04-19 11:09:20 +00:00
harukin-expo-dev-env 3ecb301e82 fix: update WebView navigation and adjust interval timing in CurrentTrainProvider 2026-04-19 11:09:13 +00:00
harukin-expo-dev-env 16e06573a1 Merge commit '5a1430d84960b4e35b562c06c07ed13d3be09c79' into experiment/web-url-experimental 2026-04-14 13:55:43 +00:00
harukin-expo-dev-env 5a1430d849 fix(HeaderText): update todayOperation prop to filter out completed operations 2026-04-14 13:55:37 +00:00
harukin-expo-dev-env 0fa3ba6029 Merge commit 'ff908414116aeb75e5d735ca184c21c1a7d17ad3' into experiment/web-url-experimental 2026-04-13 23:54:29 +00:00
harukin-expo-dev-env ff90841411 Add new logo image for Elesite to relationLogo assets 2026-04-13 07:47:25 +00:00
harukin-expo-dev-env 2d14758e83 Merge commit 'a09ba456993d9f0854aa48beb8805a6fff4c0f96' into experiment/web-url-experimental 2026-04-12 11:18:09 +00:00
harukin-expo-dev-env a09ba45699 fix(ExGridView): remove zoom scale properties from Animated.ScrollView 2026-04-12 10:02:00 +00:00
harukin-expo-dev-env b3cf1c8ca3 Merge commit '76a617cde632ebca36da29a3a0f402541cc77191' into experiment/web-url-experimental 2026-04-12 08:19:06 +00:00
harukin-expo-dev-env 76a617cde6 fix(TrainDataView): update onLongPress condition to check currentTrainData instead of onLine 2026-04-12 08:18:59 +00:00
harukin-expo-dev-env 07399f4b4e fix(HeaderText): update todayOperation to use allTodayOperation for accurate state filtering
fix(TrainIconStatus): add cache option to fetch request for improved data handling
2026-04-12 08:18:33 +00:00
harukin-expo-dev-env 1b2ba087d5 feat: 投稿システム接続先のデバッグ機能を追加し、環境設定を管理できるようにした 2026-04-11 04:13:45 +00:00
harukin-expo-dev-env 374901c9fa fix: タブバーのアニメーションとキーボード非表示設定を削除
fix: JRSTraInfoコンポーネントの初期データ読み込み処理をuseEffectで追加
2026-04-10 09:36:09 +00:00
harukin-expo-dev-env 36be7801f6 fix(SearchUnitBox): use animatedOffset with Animated.View for smooth keyboard avoidance
- Replace measuredOffset (plain number) with animatedOffset (Animated.Value)
  so the search bar smoothly follows the keyboard instead of jumping abruptly
- Wrap position:absolute container in Animated.View to accept Animated.Value as bottom
- Remove LayoutAnimation.configureNext calls that conflicted with Animated.timing
  from useKeyboardAvoid, causing layout animation races on Android
- Drop unused keyboardHeight guard (keyboardHeight > 0 ? measuredBottom : 0);
  animatedOffset starts at 0 and is driven by the hook's timing, so no jump

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-10 02:30:46 +00:00
harukin-expo-dev-env b87c6f8f71 docs: キーボードアニメーション調整に関するドキュメントを追加 2026-04-09 10:46:54 +00:00
harukin-expo-dev-env 4017f82b10 fix: キーボード回避をAnimated.timing/springに移行し、高速切替時の位置ずれとアニメーション不動を解消
- LayoutAnimation.configureNext → Animated.timing/spring に全面移行
  - iOS: Animated.spring でキーボードアニメーションに追従
  - Android: Animated.timing + Easing.out(cubic) で自然な減速カーブ
- measureGenRef 世代カウンタで飛行中の古い measure() コールバックを無効化
- retryTimerRef (500ms) で adjustResize の中間座標を自動訂正
- currentAnimRef で高速切替時に前アニメをキャンセル
- AllTrainDiagramView / StationDiagramView を Animated.View 化
- docs: 改修資料を追加
2026-04-09 10:41:19 +00:00
harukin-expo-dev-env 58ce5fa2b5 feat: チュートリアル機能の設計案を追加し、ユーザーの初回体験を改善 2026-04-08 05:20:52 +00:00
harukin-expo-dev-env 4809426632 docs: ActionSheetアニメーション破綻の修正記録を追加 2026-04-08 05:03:19 +00:00
harukin-expo-dev-env 8b42644548 fix: EachTrainInfo ActionSheetのスプリングアニメーション破綻を修正
iOS (isModal=true) でマリンライナー等の走行中列車を表示した際に
ActionSheet のスライドアップアニメーションが瞬間表示になる問題を修正。

【根本原因】
1. iOS onOpen の発火タイミング問題(最重要)
   - ライブラリ内で onOpen が Modal.onShow にバインドされており、
     スプリングアニメーション開始「前」に発火する
   - onOpen 後に showThrew=true になると通過駅が追加されて高さが増加し
     onSheetLayout が再発火 → スプリングがほぼ終点からリスタート

2. useEffect による非同期な高さ変化
   - useThroughStations / useStopStationIDs / useTrainDiagramData が
     useState([]) で初期化し useEffect で計算していたため
     空リスト → フルリストの高さ変化が onSheetLayout をトリガーしていた

3. useAutoScroll の InteractionManager が Reanimated アニメーションを認識しない

【修正内容】
- EachTrainInfoCore: showThrew の初期値を useState(() => !!getCurrentStationData(...))
  に変更し、走行中なら最初から true にして高さ変化を防ぐ
- useTrainDiagramData / useThroughStations / useStopStationIDs:
  純粋計算関数を抽出し useState lazy initializer で初回レンダリング時から正確な高さを確保
- EachTrainInfo: onOpen/onClose で sheetOpened state を管理し EachTrainInfoCore に渡す
- useAutoScroll: setShowThrew 引数を削除、sheetOpened フラグでスクロールをゲート
2026-04-08 05:00:58 +00:00
harukin-expo-dev-env 5914646443 stackAwareNavigate関数を導入し、遷移時のナビゲーションロジックを改善 2026-04-08 02:54:30 +00:00
harukin-expo-dev-env a54ef7ca13 ナビゲーションロジックを改善し、stackAwareNavigate関数を導入して遷移時のスタック管理を強化。プライバシーポリシーと設計メモを追加。 2026-04-08 02:54:14 +00:00
harukin-expo-dev-env 6b46c7150c AppContainerのonStateChangeロジックを改善し、アクティブなルートの状態を正確にチェックするように修正 2026-04-05 06:07:17 +00:00
harukin-expo-dev-env 9e2abc96c7 StatusBarの表示ロジックを改善し、Appsコンポーネントにフォーカス状態を追加 2026-04-05 06:07:04 +00:00
harukin-expo-dev-env ad5357ce7f 運用Hub情報の取得ロジックを改善し、貨物列車の車番処理を追加 2026-04-03 02:07:26 +00:00
harukin-expo-dev-env 045ed21cd7 噂機能のスタイル強化 2026-04-02 15:25:22 +00:00
harukin-expo-dev-env a9fef20f78 Merge commit '0764c17d43dedad48f74173baec555dd95c44b4c' into develop 2026-04-02 15:24:27 +00:00
harukin-expo-dev-env 0764c17d43 MapPinコンポーネントにおけるマーカーの表示方法を改善し、画像をViewでラップしてスタイルを適用 2026-04-01 11:13:54 +00:00
harukin-expo-dev-env b12269d35d バージョンコードを7.0から7.0.1に更新 2026-04-01 10:23:36 +00:00
harukin-expo-dev-env 057e595220 各コンポーネントにおけるLive Activity機能の一時的無効化、アイコン設定の改善、アイコンリストのセクション化、及び関連する状態管理の追加 2026-04-01 10:14:49 +00:00
harukin-expo-dev-env 2be3a5c481 アセット画像と変更履歴を追加 2026-04-01 10:08:10 +00:00
harukin-expo-dev-env a3873f55be Merge commit 'dbfbc43316527f21f956922fcc609f17b3581e1d' into patch/6.x 2026-03-31 17:42:19 +00:00
harukin-expo-dev-env dbfbc43316 iOSビルド用設定変更 2026-03-31 17:39:24 +00:00
harukin-expo-dev-env 69e61b401c Live Activity機能を一時的に無効化し、関連するコードを修正 2026-03-31 17:34:59 +00:00
harukin-expo-dev-env bed4366654 iOSビルド番号を61から62に更新 2026-03-31 15:01:59 +00:00
harukin-expo-dev-env 0be77a56ae Merge commit '131bd309842dee49ce3606cf7c371b7f223d61ee' into develop 2026-03-31 15:01:27 +00:00
169 changed files with 28697 additions and 3460 deletions
+2 -1
View File
@@ -59,4 +59,5 @@ ios/
!modules/**/ios/
*.ipa
*.apk
*.aab
*.aab
.env.local
+7
View File
@@ -0,0 +1,7 @@
{
"mcpServers": {
"Sentry": {
"url": "https://mcp.sentry.dev/mcp/xprocess-m5/jr-shikoku-unofficial-apps"
}
}
}
+132 -28
View File
@@ -5,6 +5,7 @@ import {
useRealWindowDimensions,
} from "./utils/dexDimensionOverride";
import { Linking, Platform, UIManager, View } from "react-native";
import { enableFreeze, enableScreens } from "react-native-screens";
import { GestureHandlerRootView } from "react-native-gesture-handler";
import "./utils/disableFontScaling"; // グローバルなフォントスケーリング無効化
import { AppContainer } from "./Apps";
@@ -25,22 +26,98 @@ import { buildProvidersTree } from "./lib/providerTreeProvider";
import { StationListProvider } from "./stateBox/useStationList";
import { NotificationProvider } from "./stateBox/useNotifications";
import { UserPositionProvider } from "./stateBox/useUserPosition";
import { rootNavigationRef } from "./lib/rootNavigation";
import {
markStartupExplicitTarget,
resolveStartupNavigationSource,
rootNavigationRef,
stackAwareNavigate,
} from "./lib/rootNavigation";
import { AppThemeProvider } from "./lib/theme";
import StatusbarDetect from "./StatusbarDetect";
import * as Sentry from '@sentry/react-native';
import {
startAppLifecycleCrashSentinel,
stopAppLifecycleCrashSentinel,
} from "./lib/observability/appLifecycleCrashSentinel";
Sentry.init({
dsn: 'https://1090312e4cf501f5a455d523eff2d538@o4511646874664960.ingest.us.sentry.io/4511646880432128',
// Adds more context data to events (IP address, cookies, user, etc.)
// For more information, visit: https://docs.sentry.io/platforms/react-native/data-management/data-collected/
sendDefaultPii: true,
// Enable Logs
enableLogs: true,
// Configure Session Replay
replaysSessionSampleRate: 0.1,
replaysOnErrorSampleRate: 1,
integrations: [Sentry.mobileReplayIntegration(), Sentry.feedbackIntegration()],
tracesSampleRate: __DEV__ ? 1.0 : 0.05,
beforeSend(event) {
const dataFetch = event.contexts?.data_fetch as any;
if (dataFetch?.responseHead && typeof dataFetch.responseHead === "string") {
dataFetch.responseHead = dataFetch.responseHead.slice(0, 300);
}
return event;
},
// uncomment the line below to enable Spotlight (https://spotlightjs.com)
// spotlight: __DEV__,
});
LogBox.ignoreLogs([
"ViewPropTypes will be removed",
"ColorPropType will be removed",
]);
const nativeScreensDisabled =
Platform.OS === "ios" || Platform.OS === "android";
if (nativeScreensDisabled) {
enableFreeze(false);
enableScreens(false);
}
if (Platform.OS === "android") {
if (UIManager.setLayoutAnimationEnabledExperimental) {
UIManager.setLayoutAnimationEnabledExperimental(true);
}
}
export default function App() {
export default Sentry.wrap(function App() {
useEffect(() => {
const nativeScreensMode = nativeScreensDisabled
? "screens-disabled"
: "default";
const rootTabSceneMode =
Platform.OS === "android" ? "opacity-fixed-z-v1" : "default";
Sentry.setTag("native_screens_mode", nativeScreensMode);
Sentry.setTag("root_tab_scene_mode", rootTabSceneMode);
Sentry.setContext("runtime_navigation_flags", {
platform: Platform.OS,
nativeScreensMode,
rootTabSceneMode,
});
Sentry.addBreadcrumb({
category: "runtime.flags",
level: "info",
message: "runtime navigation flags applied",
data: {
platform: Platform.OS,
nativeScreensMode,
rootTabSceneMode,
},
});
void startAppLifecycleCrashSentinel({ nativeScreensMode });
return () => {
stopAppLifecycleCrashSentinel();
};
}, []);
useEffect(() => {
UpdateAsync();
}, []);
@@ -54,12 +131,12 @@ export default function App() {
return;
}
rootNavigationRef.navigate("topMenu", {
stackAwareNavigate("topMenu", {
screen: "setting",
params: {
screen: "FelicaHistoryPage",
},
} as any);
});
};
const navigateWhenReady = (
@@ -78,42 +155,69 @@ export default function App() {
const routeFromUrl = (url: string, retryCount = 0) => {
const normalized = (url || "").toLowerCase();
if (!normalized) return;
if (!normalized) return false;
if (
normalized.includes("felicahistorypage") ||
normalized.includes("open/felica")
) {
markStartupExplicitTarget();
navigateWhenReady(() => openFelicaPage(), url, retryCount);
} else if (normalized.includes("open/traininfo")) {
return true;
}
if (normalized.includes("open/traininfo")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
rootNavigationRef.navigate("topMenu", { screen: "menu" } as any);
stackAwareNavigate("topMenu", { screen: "menu" });
setTimeout(() => {
SheetManager.show("JRSTraInfo");
}, 450);
}, url, retryCount);
} else if (normalized.includes("open/operation")) {
navigateWhenReady(() => {
rootNavigationRef.navigate("information" as any);
}, url, retryCount);
} else if (normalized.includes("open/settings")) {
navigateWhenReady(() => {
rootNavigationRef.navigate("topMenu", {
screen: "setting",
} as any);
}, url, retryCount);
} else if (normalized.includes("open/topmenu")) {
navigateWhenReady(() => {
rootNavigationRef.navigate("topMenu", {
screen: "menu",
} as any);
}, url, retryCount);
return true;
}
if (normalized.includes("open/operation")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
stackAwareNavigate("information");
}, url, retryCount);
return true;
}
if (normalized.includes("open/settings")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
stackAwareNavigate("topMenu", {
screen: "setting",
});
}, url, retryCount);
return true;
}
if (normalized.includes("open/topmenu")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
stackAwareNavigate("topMenu", {
screen: "menu",
});
}, url, retryCount);
return true;
}
if (normalized.includes("positions/apps")) {
markStartupExplicitTarget();
navigateWhenReady(() => {
stackAwareNavigate("positions");
}, url, retryCount);
return true;
}
return false;
};
Linking.getInitialURL().then((url) => {
if (url) routeFromUrl(url);
});
Linking.getInitialURL()
.then((url) => {
if (url) routeFromUrl(url);
})
.finally(() => {
resolveStartupNavigationSource();
});
const sub = Linking.addEventListener("url", ({ url }) => {
routeFromUrl(url);
@@ -131,10 +235,10 @@ export default function App() {
StationListProvider,
FavoriteStationProvider,
TrainDelayDataProvider,
TrainMenuProvider, // CurrentTrainProvider より先に置くことで useTrainMenu が使える
CurrentTrainProvider,
AreaInfoProvider,
BusAndTrainDataProvider,
TrainMenuProvider,
SheetProvider,
]);
return (
@@ -153,7 +257,7 @@ export default function App() {
</DeviceOrientationChangeProvider>
</AppThemeProvider>
);
}
});
/**
* 低密度ディスプレイ(DeX等)で全体を transform scale で拡大。
+573 -15
View File
@@ -1,7 +1,7 @@
import React from "react";
import { NavigationContainer, DarkTheme, DefaultTheme } from "@react-navigation/native";
import { NavigationContainer, DarkTheme, DefaultTheme, type EventArg } from "@react-navigation/native";
import { createBottomTabNavigator } from "@react-navigation/bottom-tabs";
import { Animated, Platform, ActivityIndicator, View, StyleSheet } from "react-native";
import { Animated, Platform, ActivityIndicator, View, StyleSheet, StatusBar, useWindowDimensions, InteractionManager } from "react-native";
import { useNavigationState } from "@react-navigation/native";
import { useFonts } from "expo-font";
import { LinearGradient } from "expo-linear-gradient";
@@ -14,9 +14,21 @@ import { useTrainMenu } from "./stateBox/useTrainMenu";
import lineColorList from "./assets/originData/lineColorList";
import { stationIDPair } from "./lib/getStationList";
import "./components/ActionSheetComponents/sheets";
import { rootNavigationRef } from "./lib/rootNavigation";
import { lastObservedRootRouteRef, positionsLifecycleRef, rootNavigationRef } from "./lib/rootNavigation";
import {
startupExplicitTargetRef,
waitForStartupNavigationResolution,
} from "./lib/rootNavigation";
import { fixedColors } from "./lib/theme/colors";
import { useThemeColors } from "./lib/theme";
import * as Sentry from "@sentry/react-native";
import WebView from "react-native-webview";
import { AS } from "./storageControl";
import { STORAGE_KEYS } from "./constants/storage";
import {
recordAppLifecycleRootNavigation,
setAppLifecycleWebViewActive,
} from "./lib/observability/appLifecycleCrashSentinel";
type RootTabParamList = {
positions: undefined;
@@ -35,10 +47,219 @@ type TabProps = {
const Tab = createBottomTabNavigator<RootTabParamList>();
const HiddenStartupPreloadWebViews = ({
shouldPreloadPositions,
shouldPreloadInformation,
}: {
shouldPreloadPositions: boolean;
shouldPreloadInformation: boolean;
}) => {
const [positionsLoaded, setPositionsLoaded] = React.useState(false);
const [informationLoaded, setInformationLoaded] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("startup_hidden_preload", {
shouldPreloadPositions,
shouldPreloadInformation,
positionsLoaded,
informationLoaded,
});
}, [informationLoaded, positionsLoaded, shouldPreloadInformation, shouldPreloadPositions]);
const hiddenStyle = React.useMemo(() => ({
position: "absolute" as const,
width: 1,
height: 1,
opacity: 0,
left: -1000,
top: -1000,
pointerEvents: "none" as const,
}), []);
const showPositions = shouldPreloadPositions && !positionsLoaded;
const showInformation = shouldPreloadInformation && !informationLoaded;
if (!showPositions && !showInformation) return null;
return (
<View pointerEvents="none" style={hiddenStyle}>
{showPositions ? (
<WebView
source={{ uri: "https://train.jr-shikoku.co.jp/" }}
originWhitelist={["https://train.jr-shikoku.co.jp"]}
javaScriptEnabled
setSupportMultipleWindows={false}
onLoadStart={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", true);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "positions hidden preload loadStart",
});
}}
onLoadEnd={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "positions hidden preload loadEnd",
});
setPositionsLoaded(true);
}}
onError={() => {
setAppLifecycleWebViewActive("startup_hidden_positions", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "error",
message: "positions hidden preload error",
});
}}
/>
) : null}
{showInformation ? (
<WebView
source={{ uri: "https://www.jr-shikoku.co.jp/info/" }}
originWhitelist={["https://www.jr-shikoku.co.jp"]}
javaScriptEnabled
setSupportMultipleWindows={false}
onLoadStart={() => {
setAppLifecycleWebViewActive("startup_hidden_operation", true);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "operation hidden preload loadStart",
});
}}
onLoadEnd={() => {
setAppLifecycleWebViewActive("startup_hidden_operation", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "info",
message: "operation hidden preload loadEnd",
});
setInformationLoaded(true);
}}
onError={() => {
setAppLifecycleWebViewActive("startup_hidden_operation", false);
Sentry.addBreadcrumb({
category: "startup.preload",
level: "error",
message: "operation hidden preload error",
});
}}
/>
) : null}
</View>
);
};
const PositionsRootGateContext = React.createContext({
shouldActivate: false,
isRootFocused: false,
});
const InformationRootGateContext = React.createContext({
shouldActivate: false,
isRootFocused: false,
});
const DeferredPositionsRoot = ({ shouldActivate, isRootFocused }: { shouldActivate: boolean; isRootFocused: boolean }) => {
const [hasActivated, setHasActivated] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("positions_root_gate", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
}, [hasActivated, isRootFocused, shouldActivate]);
React.useEffect(() => {
if (!shouldActivate || hasActivated) return;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root activation",
data: {
reason: "first_root_visit",
},
});
setHasActivated(true);
}, [hasActivated, shouldActivate]);
if (!hasActivated) {
return <View style={{ flex: 1 }} />;
}
return <Top />;
};
const PositionsTabScreen = React.memo(() => {
const { shouldActivate, isRootFocused } = React.useContext(PositionsRootGateContext);
return (
<DeferredPositionsRoot
shouldActivate={shouldActivate}
isRootFocused={isRootFocused}
/>
);
});
PositionsTabScreen.displayName = "PositionsTabScreen";
const DeferredInformationRoot = ({ shouldActivate, isRootFocused }: { shouldActivate: boolean; isRootFocused: boolean }) => {
const [hasActivated, setHasActivated] = React.useState(false);
React.useEffect(() => {
Sentry.setContext("operation_root_gate", {
focused: isRootFocused,
activated: hasActivated,
shouldActivate,
});
}, [hasActivated, isRootFocused, shouldActivate]);
React.useEffect(() => {
if (!shouldActivate || hasActivated) return;
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root activation",
data: {
reason: isRootFocused ? "first_focus" : "background_prewarm",
},
});
setHasActivated(true);
}, [hasActivated, isRootFocused, shouldActivate]);
if (!hasActivated) {
return <View style={{ flex: 1 }} />;
}
return <TNDView />;
};
const InformationTabScreen = React.memo(() => {
const { shouldActivate, isRootFocused } = React.useContext(InformationRootGateContext);
return (
<DeferredInformationRoot
shouldActivate={shouldActivate}
isRootFocused={isRootFocused}
/>
);
});
InformationTabScreen.displayName = "InformationTabScreen";
export function AppContainer() {
const { areaInfo, areaIconBadgeText, isInfo } = useAreaInfo();
const { selectedLine } = useTrainMenu();
const { width, height } = useWindowDimensions();
const [isExtraWindowOpen, setIsExtraWindowOpen] = React.useState(false);
const [startupInitialRoute, setStartupInitialRoute] = React.useState<keyof RootTabParamList | null>(null);
const [hasVisitedPositions, setHasVisitedPositions] = React.useState(false);
const [hasVisitedInformation, setHasVisitedInformation] = React.useState(false);
const [currentRootRoute, setCurrentRootRoute] = React.useState<keyof RootTabParamList | null>(null);
const [navigationReady, setNavigationReady] = React.useState(false);
const startupPrewarmAttemptedRef = React.useRef(false);
const informationStartupPrewarmAttemptedRef = React.useRef(false);
// フェードアニメーション用 (0=通常, 1=追加ウィンドウ青)
const fadeAnim = React.useRef(new Animated.Value(0)).current;
@@ -62,15 +283,53 @@ export function AppContainer() {
return `#${[r, g, b].map((v) => Math.min(255, v).toString(16).padStart(2, "0")).join("")}`;
};
const { isDark } = useThemeColors();
const lastRootNavStateRef = React.useRef("");
const lineColorDark = lineColor ? darkenHex(lineColor, 0.78) : null;
React.useEffect(() => {
let cancelled = false;
void (async () => {
await waitForStartupNavigationResolution();
if (cancelled) return;
let nextInitialRoute: keyof RootTabParamList = "topMenu";
if (!startupExplicitTargetRef.current) {
try {
const startPage = await AS.getItem(STORAGE_KEYS.START_PAGE);
if (startPage === "true") {
nextInitialRoute = "positions";
}
} catch {
// Keep the default route when the setting is unset or unreadable.
}
}
if (cancelled) return;
setStartupInitialRoute(nextInitialRoute);
setHasVisitedPositions(nextInitialRoute === "positions");
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: "startup initial root route resolved",
data: {
startupExplicitTarget: startupExplicitTargetRef.current,
initialRoute: nextInitialRoute,
},
});
})();
return () => {
cancelled = true;
};
}, []);
const linking = {
prefixes: ["jrshikoku://"],
config: {
screens: {
positions: {
screens: {
Apps: "positions/apps",
},
screens: {},
},
topMenu: {
screens: {
@@ -101,17 +360,152 @@ export function AppContainer() {
tabBarLabel: label,
headerShown: false,
gestureEnabled: true,
unmountOnBlur: false,
tabBarIcon: initIcon(icon as any, iconFamily, tabBarBadge, isInfo),
},
});
const applyRootNavigationState = React.useCallback((state: any, source: "ready" | "change") => {
const activeRoute = state?.routes?.[state?.index ?? 0];
const hasExtra = (activeRoute?.state?.index ?? 0) > 0;
const nestedState = activeRoute?.state;
const nestedRoute = nestedState?.routes?.[nestedState.index ?? 0]?.name ?? null;
const signature = JSON.stringify({
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
});
if (lastRootNavStateRef.current !== signature) {
lastRootNavStateRef.current = signature;
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: source === "ready" ? "root state initialized" : "root state change",
data: {
rootIndex: state?.index ?? 0,
rootRoute: activeRoute?.name ?? null,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
source,
},
});
}
const rootRouteName = (activeRoute?.name ?? null) as keyof RootTabParamList | null;
if (rootRouteName) {
lastObservedRootRouteRef.current = rootRouteName;
}
recordAppLifecycleRootNavigation({
rootIndex: state?.index ?? 0,
rootRoute: rootRouteName,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
source,
});
Sentry.setTag("root_tab", rootRouteName ?? "unknown");
Sentry.setContext("root_navigation", {
rootIndex: state?.index ?? 0,
rootRoute: rootRouteName,
nestedIndex: nestedState?.index ?? 0,
nestedRoute,
hasExtra,
source,
operationOrientation: width > height ? "landscape" : "portrait",
hasVisitedPositions,
hasVisitedInformation,
});
setCurrentRootRoute(rootRouteName);
setIsExtraWindowOpen(hasExtra);
}, [hasVisitedInformation, hasVisitedPositions, width, height]);
const [fontLoaded, error] = useFonts({
"JR-Nishi": require("./assets/fonts/jr-nishi.otf"),
"JR-WEST-PLUS": require("./assets/fonts/JR-WEST-PLUS.otf"),
Zou: require("./assets/fonts/DelaGothicOne-Regular.ttf"),
"JNR-font": require("./assets/fonts/JNRfont_pict.ttf"),
"DiaPro": require("./assets/fonts/DiaPro-Regular.otf"),
});
if (!fontLoaded) {
React.useEffect(() => {
if (!navigationReady || !fontLoaded) return;
if (startupPrewarmAttemptedRef.current) return;
if (hasVisitedPositions) {
startupPrewarmAttemptedRef.current = true;
return;
}
if (currentRootRoute === null || currentRootRoute === "positions") return;
startupPrewarmAttemptedRef.current = true;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions startup prewarm scheduled",
data: {
currentRootRoute,
},
});
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled || hasVisitedPositions) return;
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions startup prewarm activated",
data: {
currentRootRoute,
},
});
setHasVisitedPositions(true);
}, 500);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [currentRootRoute, fontLoaded, hasVisitedPositions, navigationReady]);
React.useEffect(() => {
if (!navigationReady || !fontLoaded) return;
if (informationStartupPrewarmAttemptedRef.current) return;
if (hasVisitedInformation) {
informationStartupPrewarmAttemptedRef.current = true;
return;
}
if (currentRootRoute === null || currentRootRoute === "information") return;
informationStartupPrewarmAttemptedRef.current = true;
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation startup prewarm scheduled",
data: {
currentRootRoute,
},
});
let cancelled = false;
const timer = setTimeout(() => {
if (cancelled || hasVisitedInformation) return;
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation startup prewarm activated",
data: {
currentRootRoute,
},
});
setHasVisitedInformation(true);
}, 500);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [currentRootRoute, fontLoaded, hasVisitedInformation, navigationReady]);
if (!fontLoaded || !startupInitialRoute) {
return (
<View style={{ flex: 1, justifyContent: "center", alignItems: "center" }}>
<ActivityIndicator size="large" />
@@ -123,14 +517,123 @@ export function AppContainer() {
ref={rootNavigationRef}
linking={linking}
theme={isDark ? DarkTheme : DefaultTheme}
onReady={() => {
Sentry.addBreadcrumb({
category: "nav.root",
level: "info",
message: "navigation ready",
});
applyRootNavigationState(rootNavigationRef.getRootState?.() ?? rootNavigationRef.getState?.(), "ready");
setNavigationReady(true);
}}
onStateChange={(state) => {
const hasExtra = state?.routes?.some((r) => (r.state?.index ?? 0) > 0) ?? false;
setIsExtraWindowOpen(hasExtra);
applyRootNavigationState(state, "change");
}}
>
{/* @ts-expect-error - Tab.Navigator type definition issue */}
<PositionsRootGateContext.Provider
value={{
shouldActivate: hasVisitedPositions,
isRootFocused: currentRootRoute === "positions",
}}
>
<InformationRootGateContext.Provider
value={{
shouldActivate: hasVisitedInformation,
isRootFocused: currentRootRoute === "information",
}}
>
<Tab.Navigator
initialRouteName="topMenu"
id="rootTabs"
detachInactiveScreens={false}
initialRouteName={startupInitialRoute}
screenListeners={({ route }) => ({
tabPress: (event: EventArg<"tabPress", true>) => {
const rootState = rootNavigationRef.getState();
const activeRootRouteName =
(rootState?.routes?.[rootState.index ?? 0]?.name as keyof RootTabParamList | undefined) ??
currentRootRoute ??
null;
const leavingUnstablePositions =
route.name !== "positions" &&
positionsLifecycleRef.current.isUnstable;
const blockingUnstableExit =
route.name !== "positions" &&
positionsLifecycleRef.current.blockTabExit;
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: blockingUnstableExit
? "root tabPress blocked while positions unstable"
: leavingUnstablePositions
? "root tabPress intercepted for unstable positions"
: "root tabPress",
data: {
route: route.name,
currentRootRoute,
activeRootRouteName,
unstablePositions: positionsLifecycleRef.current.isUnstable,
blockTabExit: positionsLifecycleRef.current.blockTabExit,
},
});
if (blockingUnstableExit) {
event.preventDefault();
positionsLifecycleRef.current.resetBeforeLeave?.();
requestAnimationFrame(() => {
setTimeout(() => {
rootNavigationRef.navigate(route.name as never);
}, 0);
});
return;
}
if (leavingUnstablePositions) {
event.preventDefault();
positionsLifecycleRef.current.resetBeforeLeave?.();
requestAnimationFrame(() => {
setTimeout(() => {
rootNavigationRef.navigate(route.name as never);
}, 0);
});
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root exit reset and continued while session unstable",
data: {
route: route.name,
},
});
}
},
focus: () => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab blur",
data: { route: route.name },
});
},
state: (event) => {
Sentry.addBreadcrumb({
category: "nav.tab",
level: "info",
message: "root tab state",
data: {
route: route.name,
stateType: event.type,
},
});
},
})}
screenOptions={({ route }) => {
const showGradient = route.name === "positions" && !!lineColor && !!lineColorDark;
const defaultBg = isDark ? "#1c1c1e" : "white";
@@ -138,8 +641,7 @@ export function AppContainer() {
const defaultInactive = isDark ? "#8e8e93" : "#8e8e93";
return {
lazy: false,
tabBarHideOnKeyboard: Platform.OS === "android",
animation: Platform.OS === "ios" ? "none" : "shift",
freezeOnBlur: false,
sceneContainerStyle: { backgroundColor: defaultBg },
tabBarActiveTintColor: (showGradient || isExtraWindowOpen) ? "white" : defaultActive,
tabBarInactiveTintColor: (showGradient || isExtraWindowOpen) ? "rgba(255,255,255,0.75)" : defaultInactive,
@@ -173,7 +675,32 @@ export function AppContainer() {
>
<Tab.Screen
{...getTabProps("positions", "走行位置", "bar-chart", "AntDesign")}
component={Top}
component={PositionsTabScreen}
listeners={{
tabPress: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root tabPress activation",
});
setHasVisitedPositions(true);
},
focus: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root focus activation",
});
setHasVisitedPositions(true);
},
blur: () => {
Sentry.addBreadcrumb({
category: "positions.root",
level: "info",
message: "positions root blur preserved",
});
},
}}
/>
<Tab.Screen
{...getTabProps("topMenu", "トップメニュー", "radio", "Ionicons")}
@@ -189,9 +716,40 @@ export function AppContainer() {
areaInfo ? areaIconBadgeText : undefined,
isInfo
)}
children={TNDView}
component={InformationTabScreen}
listeners={{
tabPress: () => {
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root tabPress activation",
});
setHasVisitedInformation(true);
},
focus: () => {
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root focus activation",
});
setHasVisitedInformation(true);
},
blur: () => {
Sentry.addBreadcrumb({
category: "operation.root",
level: "info",
message: "operation root blur preserved",
});
},
}}
/>
</Tab.Navigator>
</InformationRootGateContext.Provider>
</PositionsRootGateContext.Provider>
<HiddenStartupPreloadWebViews
shouldPreloadPositions={navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedPositions}
shouldPreloadInformation={navigationReady && fontLoaded && currentRootRoute === "topMenu" && !hasVisitedInformation}
/>
</NavigationContainer>
);
}
+484 -27
View File
@@ -1,25 +1,277 @@
import React, { CSSProperties } from "react";
import { Alert, BackHandler, View, ViewProps } from "react-native";
import React from "react";
import { Alert, ActivityIndicator, BackHandler, StyleSheet, Text, TouchableOpacity, View } from "react-native";
import * as FileSystem from "expo-file-system/legacy";
import * as Sharing from "expo-sharing";
import { WebView } from "react-native-webview";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import { BigButton } from "./components/atom/BigButton";
import { useFocusEffect, useNavigation } from "@react-navigation/native";
import { useThemeColors } from "@/lib/theme";
import { AS } from "./storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import {
DEFAULT_JR_DATA_SYSTEM_ENV,
normalizeJrDataSystemEnvironment,
rewriteJrDataSystemUrl,
} from "@/lib/jrDataSystemEnvironment";
const RECORDING_DOWNLOAD_BRIDGE_SCRIPT = `
(() => {
if (window.__JRS_RECORDING_DOWNLOAD_BRIDGE__) return true;
window.__JRS_RECORDING_DOWNLOAD_BRIDGE__ = true;
const blobUrls = new Map();
const post = (payload) => {
window.ReactNativeWebView?.postMessage(JSON.stringify(payload));
};
const isLikelyRecordingDownload = (url, anchor) => {
if (!url) return false;
const lowerUrl = String(url).toLowerCase();
const downloadName = anchor?.getAttribute?.('download') || '';
const lowerName = String(downloadName).toLowerCase();
return Boolean(downloadName)
|| lowerUrl.includes('recording')
|| lowerUrl.includes('recordings')
|| lowerUrl.includes('download')
|| lowerUrl.includes('export')
|| lowerUrl.includes('json')
|| lowerName.includes('recording')
|| lowerName.endsWith('.json');
};
const readUrlAsText = async (url) => {
if (blobUrls.has(url)) {
return blobUrls.get(url).text();
}
const response = await fetch(url, { credentials: 'include' });
if (!response.ok) throw new Error('HTTP ' + response.status);
return response.text();
};
const importFromUrl = async (url) => {
try {
const text = await readUrlAsText(url);
post({ type: 'importRecordingDownload', text, sourceUrl: url });
} catch (error) {
post({
type: 'importRecordingDownloadError',
message: error?.message || String(error),
sourceUrl: url,
});
}
};
const findAnchor = (target) => {
let node = target;
while (node && node !== document) {
if (node.tagName === 'A' && node.href) return node;
node = node.parentNode;
}
return null;
};
if (window.URL?.createObjectURL) {
const originalCreateObjectURL = window.URL.createObjectURL.bind(window.URL);
window.URL.createObjectURL = (object) => {
const url = originalCreateObjectURL(object);
if (object instanceof Blob) blobUrls.set(url, object);
return url;
};
}
document.addEventListener('click', (event) => {
const anchor = findAnchor(event.target);
if (!anchor || !isLikelyRecordingDownload(anchor.href, anchor)) return;
event.preventDefault();
event.stopPropagation();
importFromUrl(anchor.href);
}, true);
if (window.HTMLAnchorElement?.prototype?.click) {
const originalClick = window.HTMLAnchorElement.prototype.click;
window.HTMLAnchorElement.prototype.click = function patchedClick() {
if (isLikelyRecordingDownload(this.href, this)) {
importFromUrl(this.href);
return;
}
return originalClick.apply(this, arguments);
};
}
return true;
})();
true;
`;
const isLikelyRecordingDownloadUrl = (url: string) => {
const lowerUrl = url.toLowerCase();
return lowerUrl.includes('recording')
|| lowerUrl.includes('recordings')
|| lowerUrl.includes('download')
|| lowerUrl.includes('export')
|| lowerUrl.includes('json')
|| lowerUrl.endsWith('.json');
};
export default ({ route }) => {
if (!route.params) {
return null;
}
const { uri, useExitButton = true } = route.params;
const {
uri,
useExitButton = true,
importRecordingDownloads = false,
} = route.params;
const { goBack } = useNavigation();
const { fixed } = useThemeColors();
const { importRecordingsFromText } = useTrainMenu();
const webViewRef = React.useRef<WebView>(null);
const [canGoBack, setCanGoBack] = React.useState(false);
const nativeCanGoBackRef = React.useRef(false);
const historyStackRef = React.useRef<string[]>([]);
const [selectedEnvironment, setSelectedEnvironment] = React.useState(
DEFAULT_JR_DATA_SYSTEM_ENV,
);
const [resolvedUri, setResolvedUri] = React.useState("");
const [isEnvironmentReady, setIsEnvironmentReady] = React.useState(false);
const hasAlerted = React.useRef(false);
const [isLoading, setIsLoading] = React.useState(true);
const [hasError, setHasError] = React.useState(false);
const [errorMessage, setErrorMessage] = React.useState("");
// WebViewをforce remountするためのkey
const [webViewKey, setWebViewKey] = React.useState(0);
// RN-side watchdog: WebViewプロセスの死活確認用
const lastPongAt = React.useRef<number>(Date.now());
const isLoadingRef = React.useRef(true);
const hasErrorRef = React.useRef(false);
// コンテンツ消失検知用: bodyLen の最大値と連続白画面カウント
const maxBodyLenRef = React.useRef(0);
const blankCountRef = React.useRef(0);
const remount = React.useCallback(() => {
lastPongAt.current = Date.now(); // remount直後に誤検知しないようリセット
maxBodyLenRef.current = 0;
blankCountRef.current = 0;
setHasError(false);
hasErrorRef.current = false;
setIsLoading(true);
isLoadingRef.current = true;
setWebViewKey((k) => k + 1);
}, []);
React.useEffect(() => {
let isMounted = true;
const applyEnvironment = (value: unknown) => {
if (!isMounted) return;
const nextEnvironment = normalizeJrDataSystemEnvironment(value);
const rawUri = typeof uri === "string" ? uri : "";
historyStackRef.current = [];
nativeCanGoBackRef.current = false;
setCanGoBack(false);
setSelectedEnvironment(nextEnvironment);
setResolvedUri(
importRecordingDownloads
? rawUri
: rewriteJrDataSystemUrl(rawUri, nextEnvironment),
);
setIsEnvironmentReady(true);
};
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
.then(applyEnvironment)
.catch(() => applyEnvironment(DEFAULT_JR_DATA_SYSTEM_ENV));
return () => {
isMounted = false;
};
}, [uri, importRecordingDownloads]);
const handleReload = () => {
lastPongAt.current = Date.now();
setHasError(false);
hasErrorRef.current = false;
setIsLoading(true);
isLoadingRef.current = true;
setWebViewKey((k) => k + 1);
};
const syncCanGoBack = React.useCallback((nativeCanGoBack: boolean) => {
nativeCanGoBackRef.current = nativeCanGoBack;
setCanGoBack(nativeCanGoBack || historyStackRef.current.length > 1);
}, []);
const pushHistoryEntry = React.useCallback((url: string) => {
if (!url) return;
const stack = historyStackRef.current;
if (stack[stack.length - 1] === url) return;
stack.push(url);
if (stack.length > 30) {
stack.splice(0, stack.length - 30);
}
}, []);
const handlePseudoBack = React.useCallback(() => {
const stack = historyStackRef.current;
if (stack.length <= 1) return false;
stack.pop();
const previousUrl = stack[stack.length - 1];
if (!previousUrl) return false;
setResolvedUri(previousUrl);
syncCanGoBack(false);
remount();
return true;
}, [remount, syncCanGoBack]);
const handleWebViewBack = React.useCallback(() => {
if (nativeCanGoBackRef.current) {
webViewRef.current?.goBack();
return true;
}
return handlePseudoBack();
}, [handlePseudoBack]);
const handleImportRecordingText = React.useCallback(
async (content: string) => {
try {
const result = await importRecordingsFromText(content);
Alert.alert(
"録画データを読み込みました",
result.overwrittenCount > 0
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
: `${result.importedCount}件を読み込みました。`,
);
} catch (error) {
Alert.alert(
"録画データを読み込めませんでした",
(error as Error).message || "ファイル内容を確認してください。",
);
}
},
[importRecordingsFromText],
);
const handleImportRecordingUrl = React.useCallback(
async (url: string) => {
try {
const response = await fetch(url);
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
await handleImportRecordingText(await response.text());
} catch (error) {
Alert.alert(
"録画データを取得できませんでした",
(error as Error).message || "通信状態を確認してください。",
);
}
},
[handleImportRecordingText],
);
useFocusEffect(
React.useCallback(() => {
const onHardwareBack = () => {
if (canGoBack) {
webViewRef.current?.goBack();
return true;
if (handleWebViewBack()) return true;
}
goBack();
return true;
@@ -27,32 +279,237 @@ export default ({ route }) => {
const subscription = BackHandler.addEventListener("hardwareBackPress", onHardwareBack);
return () => subscription.remove();
}, [canGoBack, goBack])
}, [canGoBack, goBack, handleWebViewBack])
);
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
<WebView
source={{ uri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
ref={webViewRef}
onNavigationStateChange={(navState) => {
setCanGoBack(navState.canGoBack);
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
goBack();
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
{ text: "完了" },
]);
{isEnvironmentReady && (
<WebView
key={webViewKey}
source={{ uri: resolvedUri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
setSupportMultipleWindows={false}
ref={webViewRef}
injectedJavaScriptBeforeContentLoaded={
importRecordingDownloads ? RECORDING_DOWNLOAD_BRIDGE_SCRIPT : `true;`
}
}}
onMessage={(event) => {
const { data } = event.nativeEvent;
const { type } = JSON.parse(data);
if (type === "back") return webViewRef.current?.goBack();
if (type === "windowClose") return goBack();
}}
/>
{useExitButton && <BigButton onPress={goBack} string="閉じる" />}
onLoadStart={() => {
isLoadingRef.current = true;
setHasError(false);
hasErrorRef.current = false;
maxBodyLenRef.current = 0;
blankCountRef.current = 0;
}}
onLoadEnd={() => {
setIsLoading(false);
isLoadingRef.current = false;
lastPongAt.current = Date.now();
}}
onError={(syntheticEvent) => {
const { nativeEvent } = syntheticEvent;
setIsLoading(false);
isLoadingRef.current = false;
setHasError(true);
hasErrorRef.current = true;
setErrorMessage(nativeEvent.description || "ページを読み込めませんでした");
}}
onHttpError={(syntheticEvent) => {
const { nativeEvent } = syntheticEvent;
if (nativeEvent.statusCode >= 500) {
setIsLoading(false);
isLoadingRef.current = false;
setHasError(true);
hasErrorRef.current = true;
setErrorMessage(`サーバーエラー (${nativeEvent.statusCode})`);
}
}}
onRenderProcessGone={() => {
// クラッシュ・メモリ回収どちらも自動remount
remount();
}}
// iOS: コンテンツプロセスがメモリ圧迫で終了した場合
onContentProcessDidTerminate={() => remount()}
onShouldStartLoadWithRequest={(request) => {
if (request.isTopFrame === false) {
return true;
}
if (
importRecordingDownloads &&
isLikelyRecordingDownloadUrl(request.url) &&
request.url !== resolvedUri
) {
void handleImportRecordingUrl(request.url);
return false;
}
if (!importRecordingDownloads) {
const rewrittenUrl = rewriteJrDataSystemUrl(
request.url,
selectedEnvironment,
);
if (rewrittenUrl !== request.url) {
setResolvedUri(rewrittenUrl);
return false;
}
}
return true;
}}
onFileDownload={(event) => {
if (!importRecordingDownloads) return;
const downloadUrl = event.nativeEvent.downloadUrl;
if (downloadUrl) {
void handleImportRecordingUrl(downloadUrl);
}
}}
onNavigationStateChange={(navState) => {
if (navState.url) {
const nextUrl = importRecordingDownloads
? navState.url
: rewriteJrDataSystemUrl(navState.url, selectedEnvironment);
setResolvedUri((current) => (current === nextUrl ? current : nextUrl));
if (nextUrl !== "https://unyohub.2pd.jp/integration/succeeded.php") {
pushHistoryEntry(nextUrl);
}
}
syncCanGoBack(navState.canGoBack);
// SPA内遷移中は白画面誤検知を防ぐためblankCountをリセット
if (navState.loading) blankCountRef.current = 0;
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
webViewRef.current?.goBack();
if (!hasAlerted.current) {
hasAlerted.current = true;
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
{ text: "完了" },
]);
}
}
}}
onMessage={(event) => {
const { data } = event.nativeEvent;
let parsed: any;
try {
parsed = JSON.parse(data);
} catch {
return;
}
const { type } = parsed;
if (type === "importRecordingDownload") {
void handleImportRecordingText(String(parsed.text ?? ""));
return;
}
if (type === "importRecordingDownloadError") {
Alert.alert(
"録画データを取得できませんでした",
parsed.message || "ダウンロード内容を読み取れませんでした。",
);
return;
}
if (type === "pong") {
lastPongAt.current = Date.now();
const bodyLen: number = parsed.bodyLen ?? 0;
// innerTextベース: 最大値を更新
if (bodyLen > maxBodyLenRef.current) maxBodyLenRef.current = bodyLen;
// 一度200文字超の表示テキストがあった後に20文字未満になったら白画面と判定
// SPA遷移中の一時的な空白を避けるため3回連続(15秒)で発火
if (maxBodyLenRef.current > 200 && bodyLen < 20) {
blankCountRef.current += 1;
if (blankCountRef.current >= 3) {
blankCountRef.current = 0;
maxBodyLenRef.current = 0;
remount();
}
} else {
blankCountRef.current = 0;
}
return;
}
if (type === "printHtml") {
(async () => {
try {
const path = FileSystem.cacheDirectory + "diagram.html";
await FileSystem.writeAsStringAsync(path, parsed.html, { encoding: FileSystem.EncodingType.UTF8 });
const ok = await Sharing.isAvailableAsync();
if (ok) {
await Sharing.shareAsync(path, { mimeType: "text/html", dialogTitle: "ダイヤグラムを共有" });
}
} catch (e) {
Alert.alert("エラー", "PDF出力の準備に失敗しました。");
}
})();
return;
}
if (type === "back") return handleWebViewBack();
if (type === "windowClose") return goBack();
}}
/>
)}
{isLoading && !hasError && (
<View style={wvStyles.loadingOverlay} pointerEvents="none">
<ActivityIndicator size="large" color="#fff" />
</View>
)}
{hasError && (
<View style={wvStyles.errorOverlay}>
<MaterialCommunityIcons name="wifi-off" size={48} color="#ccc" />
<Text style={wvStyles.errorText}>{errorMessage}</Text>
<TouchableOpacity style={wvStyles.reloadButton} onPress={handleReload}>
<MaterialCommunityIcons name="reload" size={18} color="#fff" />
<Text style={wvStyles.reloadButtonText}></Text>
</TouchableOpacity>
{useExitButton && (
<TouchableOpacity style={wvStyles.backButton} onPress={goBack}>
<Text style={wvStyles.backButtonText}></Text>
</TouchableOpacity>
)}
</View>
)}
{useExitButton && !hasError && <BigButton onPress={goBack} string="閉じる" />}
</View>
);
};
const wvStyles = StyleSheet.create({
loadingOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
backgroundColor: "rgba(0,0,0,0.25)",
},
errorOverlay: {
...StyleSheet.absoluteFillObject,
alignItems: "center",
justifyContent: "center",
backgroundColor: "#1a1a2e",
gap: 16,
paddingHorizontal: 32,
},
errorText: {
color: "#aaa",
fontSize: 14,
textAlign: "center",
},
reloadButton: {
flexDirection: "row",
alignItems: "center",
gap: 8,
backgroundColor: "#0099CC",
borderRadius: 10,
paddingHorizontal: 24,
paddingVertical: 12,
},
reloadButtonText: {
color: "#fff",
fontSize: 15,
fontWeight: "bold",
},
backButton: {
paddingHorizontal: 24,
paddingVertical: 10,
},
backButtonText: {
color: "#888",
fontSize: 14,
},
});
+94 -11
View File
@@ -18,11 +18,12 @@ import Setting from "@/components/Settings/settings";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { optionData } from "@/lib/stackOption";
import { AllTrainDiagramView } from "@/components/AllTrainDiagramView";
import { useNavigation } from "@react-navigation/native";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import { news } from "@/config/newsUpdate";
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
import GeneralWebView from "@/GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator();
export function MenuPage() {
@@ -32,18 +33,38 @@ export function MenuPage() {
const tabBarHeight = useBottomTabBarHeight();
const navigation = useNavigation<any>();
const { addListener } = navigation;
const isFocused = useIsFocused();
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
useEffect(() => {
AS.getItem(STORAGE_KEYS.START_PAGE)
.then((res) => {
if (res == "true") navigation.navigate("positions");
})
.catch((e) => {
//6.0以降false
AS.setItem(STORAGE_KEYS.START_PAGE, "false");
});
useEffect(() => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu unmounted",
});
};
}, []);
useEffect(() => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: isFocused ? "top menu focused" : "top menu blurred",
data: {
width,
height,
},
});
}, [height, isFocused, width]);
useEffect(() => {
//ニュース表示
AS.getItem(STORAGE_KEYS.NEWS_STATUS)
.then((d) => {
@@ -55,10 +76,20 @@ export function MenuPage() {
if (isSetIcon == "true") SheetManager.show("TrainIconUpdate");
})
.catch((error) => logger.error("Error fetching icon setting:", error));
return undefined;
}, []);
const scrollRef = useRef(null);
const [mapMode, setMapMode] = useState(false);
useEffect(() => {
Sentry.setContext("top_menu_screen", {
focused: isFocused,
width,
height,
mapMode,
});
}, [height, isFocused, mapMode, width]);
const [mapHeight, setMapHeight] = useState(0);
const mapHeightRef = useRef(0);
const favoriteStationRef = useRef(favoriteStation);
@@ -84,7 +115,24 @@ export function MenuPage() {
setMapFullHeight(MapFullHeight);
}, [height, tabBarHeight, width]);
useEffect(() => {
const unsubscribe = addListener("tabPress", (e) => {
const unsubscribe = addListener("tabPress", (e: any) => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu tabPress handler",
data: {
focused: navigation.isFocused(),
stackIndex: stackNavRef.current?.getState()?.index ?? null,
mapMode,
},
});
if (navigation.isFocused() && stackNavRef.current) {
if (stackNavRef.current.getState()?.index > 0) {
e.preventDefault();
stackNavRef.current.goBack();
return;
}
}
scrollRef.current?.scrollTo({
y: mapHeightRef.current - verticalScale(80),
animated: true,
@@ -106,10 +154,45 @@ export function MenuPage() {
return unsubscribe;
}, [navigation]);
const stackNavRef = useRef<any>(null);
return (
<Stack.Navigator
id={null}
detachInactiveScreens={false}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {
focus: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack blur",
data: { route: route.name },
});
},
state: () => {
Sentry.addBreadcrumb({
category: "topMenu.screen",
level: "info",
message: "top menu stack state",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
};
}}
>
<Stack.Screen
name="menu"
+1 -4
View File
@@ -1,11 +1,8 @@
import React, { FC } from "react";
import { Platform, StatusBar } from "react-native";
import { useThemeColors } from "@/lib/theme";
const StatusbarDetect: FC = () => {
const { isDark } = useThemeColors();
const barStyle = isDark ? "light-content" : "dark-content";
return <StatusBar barStyle={barStyle} translucent backgroundColor="transparent" />;
return <StatusBar barStyle="light-content" translucent backgroundColor="transparent" />;
};
export default StatusbarDetect;
+193 -9
View File
@@ -1,6 +1,6 @@
import React, { useCallback, useEffect, useRef } from "react";
import React, { useCallback, useEffect, useRef, useState } from "react";
import { createStackNavigator } from "@react-navigation/stack";
import { useNavigation } from "@react-navigation/native";
import { useIsFocused, useNavigation } from "@react-navigation/native";
import { useColorScheme } from "react-native";
import Apps from "./components/Apps";
import TrainBase from "./components/trainbaseview";
@@ -13,15 +13,20 @@ import { useCurrentTrain } from "./stateBox/useCurrentTrain";
import { useTrainMenu } from "./stateBox/useTrainMenu";
import { AS } from "./storageControl";
import { news } from "./config/newsUpdate";
import { Linking, Platform } from "react-native";
import { InteractionManager, Linking, Platform, View } from "react-native";
import GeneralWebView from "./GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import { positionsLifecycleRef, positionsStackNavRef } from "./lib/rootNavigation";
import * as Sentry from "@sentry/react-native";
const Stack = createStackNavigator();
export const Top = () => {
const { webview } = useCurrentTrain();
const { navigate, addListener, isFocused } = useNavigation<any>();
const navigation = useNavigation<any>();
const { navigate, addListener } = navigation;
const isTabFocused = useIsFocused();
const isDark = useColorScheme() === "dark";
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const [hasActivatedStack, setHasActivatedStack] = useState(false);
//地図用
const { mapSwitch } = useTrainMenu();
@@ -30,36 +35,215 @@ export const Top = () => {
mapSwitchRef.current = mapSwitch;
}, [mapSwitch]);
const goToFavoriteList = () =>
const goToFavoriteList = (event?: any) => {
if (!hasActivatedStack) {
event?.preventDefault?.();
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabLongPress ignored before stack activation",
});
return;
}
navigate("positions", { screen: "favoriteList" });
};
useEffect(() => {
const unsubscribe = addListener("tabLongPress", goToFavoriteList);
return unsubscribe;
}, []);
const goToTrainMenu = useCallback(() => {
useEffect(() => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack unmounted",
});
};
}, []);
useEffect(() => {
if (hasActivatedStack) return;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack activation scheduled",
data: {
reason: isTabFocused ? "first_focus" : "background_prewarm",
},
});
let cancelled = false;
const activate = () => {
if (cancelled) return;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack activation committed",
data: {
reason: isTabFocused ? "first_focus" : "background_prewarm",
},
});
setHasActivatedStack(true);
};
if (isTabFocused) {
const task = InteractionManager.runAfterInteractions(() => {
setTimeout(activate, 0);
});
return () => {
cancelled = true;
task.cancel?.();
};
}
const timer = setTimeout(activate, 300);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [hasActivatedStack, isTabFocused]);
useEffect(() => {
Sentry.setContext("positions_stack", {
focused: isTabFocused,
activated: hasActivatedStack,
mapSwitch,
});
}, [hasActivatedStack, isTabFocused, mapSwitch]);
const stackNavRef = positionsStackNavRef;
useEffect(() => {
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
deactivateStack: null,
};
return () => {
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
deactivateStack: null,
};
};
}, []);
const goToTrainMenu = useCallback((e: any) => {
const stackNav = stackNavRef.current;
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabPress handler",
data: {
platform: Platform.OS,
focused: isTabFocused,
mapSwitch: mapSwitchRef.current,
stackIndex: stackNav?.getState()?.index ?? null,
},
});
if (Platform.OS === "web") {
Linking.openURL("https://train.jr-shikoku.co.jp/");
setTimeout(() => navigate("topMenu", { screen: "menu" }), 100);
return;
}
if (!isFocused()) navigate("positions", { screen: "Apps" });
else if (mapSwitchRef.current == "true")
if (!hasActivatedStack || !stackNav) {
e?.preventDefault?.();
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions tabPress ignored before stack activation",
data: {
focused: isTabFocused,
activated: hasActivatedStack,
hasStackNav: !!stackNav,
},
});
return;
}
if (!isTabFocused) return;
if (stackNav && stackNav.getState()?.index > 0) {
e.preventDefault();
stackNav.goBack();
return;
}
if (mapSwitchRef.current == "true")
navigate("positions", { screen: "trainMenu" });
else webview.current?.injectJavaScript(`AccordionClassEvent()`);
return;
}, [isFocused, navigate, webview]);
}, [hasActivatedStack, isTabFocused, navigate, webview]);
useEffect(() => {
const unsubscribe = addListener("tabPress", goToTrainMenu);
return unsubscribe;
}, [addListener, goToTrainMenu]);
if (!hasActivatedStack) {
return <View style={{ flex: 1, backgroundColor: bgColor }} />;
}
return (
<Stack.Navigator
id={null}
detachInactiveScreens={false}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ route, navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {
focus: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack focus",
data: { route: route.name },
});
},
blur: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack blur",
data: { route: route.name },
});
},
transitionStart: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack transitionStart",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
transitionEnd: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack transitionEnd",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
state: () => {
Sentry.addBreadcrumb({
category: "positions.stack",
level: "info",
message: "positions stack state",
data: {
route: route.name,
stackIndex: stackNav.getState()?.index ?? null,
},
});
},
};
}}
>
<Stack.Screen
name="Apps"
+27 -8
View File
@@ -8,7 +8,7 @@
"android",
"web"
],
"version": "7.0.0",
"version": "7.1.0",
"userInterfaceStyle": "automatic",
"orientation": "default",
"icon": "./assets/icons/s8600.png",
@@ -24,7 +24,7 @@
"**/*"
],
"ios": {
"buildNumber": "61",
"buildNumber": "66",
"supportsTablet": true,
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
"appleTeamId": "54CRDT797G",
@@ -33,6 +33,8 @@
},
"infoPlist": {
"NFCReaderUsageDescription": "To read FeliCa card",
"NSPhotoLibraryUsageDescription": "運行情報画像を共有または保存するために写真ライブラリへのアクセスを使用します。",
"NSPhotoLibraryAddUsageDescription": "運行情報画像を写真ライブラリに保存するために使用します。",
"com.apple.developer.nfc.readersession.felica.systemcodes": [
"0003",
"FE00"
@@ -55,7 +57,7 @@
},
"android": {
"package": "jrshikokuinfo.xprocess.hrkn",
"versionCode": 30,
"versionCode": 32,
"intentFilters": [
{
"action": "VIEW",
@@ -115,6 +117,7 @@
{
"fonts": [
"./assets/fonts/jr-nishi.otf",
"./assets/fonts/JR-WEST-PLUS.otf",
"./assets/fonts/DelaGothicOne-Regular.ttf",
"./assets/fonts/JNRfont_pict.ttf",
"./assets/fonts/DiaPro-Regular.otf"
@@ -144,7 +147,7 @@
"minWidth": "70dp",
"minHeight": "50dp",
"description": "JR四国列車遅延速報EXのウィジェットです。30分ごとに自動更新します。タッチすると強制更新します。",
"previewImage": "./assets/widgetResource/JR_shikoku_train_info.png",
"previewImage": "./assets/widgetResource/JR_shikoku_train_info.jpg",
"updatePeriodMillis": 1800000,
"resizeMode": "horizontal|vertical"
},
@@ -154,7 +157,7 @@
"minWidth": "70dp",
"minHeight": "50dp",
"description": "JR四国運行情報のウィジェットです。30分ごとに自動更新します。タッチすると強制更新します。",
"previewImage": "./assets/widgetResource/JR_shikoku_info.png",
"previewImage": "./assets/widgetResource/JR_shikoku_info.jpg",
"updatePeriodMillis": 1800000,
"resizeMode": "horizontal|vertical"
},
@@ -164,7 +167,7 @@
"minWidth": "70dp",
"minHeight": "50dp",
"description": "JR四国非公式アプリの各種リンクを表示するウィジェットです。",
"previewImage": "./assets/widgetResource/JR_shikoku_apps_shortcut.png",
"previewImage": "./assets/widgetResource/JR_shikoku_apps_shortcut.jpg",
"updatePeriodMillis": 1800000,
"resizeMode": "horizontal|vertical"
},
@@ -174,7 +177,7 @@
"minWidth": "70dp",
"minHeight": "50dp",
"description": "Felica対応ICカードの残高をホーム画面に表示するウィジェットです。タップでスキャン画面を開きます。",
"previewImage": "./assets/widgetResource/JR_shikoku_felica_balance.png",
"previewImage": "./assets/widgetResource/JR_shikoku_felica_balance.jpg",
"updatePeriodMillis": 1800000,
"resizeMode": "horizontal|vertical"
}
@@ -970,13 +973,29 @@
"expo-web-browser",
"expo-asset",
"expo-sharing",
[
"react-native-share",
{
"ios": [],
"android": [],
"enableBase64ShareAndroid": false
}
],
[
"react-native-maps",
{
"iosGoogleMapsApiKey": "AIzaSyAVGDTjBkR_0wkQiNkoo5WDLhqXCjrjk8Y",
"androidGoogleMapsApiKey": "AIzaSyAmFb-Yj033bXZWlSzNrfq_0jc1PgRrWcE"
}
],
[
"@sentry/react-native/expo",
{
"url": "https://sentry.io/",
"project": "jr-shikoku-unofficial-apps",
"organization": "xprocess-m5"
}
]
]
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 334 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 274 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 913 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 616 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 392 KiB

Binary file not shown.
+27
View File
@@ -0,0 +1,27 @@
西日本方向ロゴ拡張「JR-WEST-PLUS」
2026/07/11 by Mameh1nata
「回送」とか専用の書体の方がかっこよくね?と思い制作したフォントです
現時点で対応している種別は以下の通りです
回送 試運転 貸切 修学旅行 救援 配給 訓練 検測 競技 検定 教習
ver1.0 プライベート公開
ver1.01 修学旅行を追加
ver1.1 救援 配給 訓練の追加
ver1.2 競技 検定 教習の追加
-注意-
・このフォントは西日本方向幕ロゴの制作者とは何の関係もありません
・常識の範囲内で使うことをお勧めします(常識の範囲外でも自己責任でお願いします)
・文字サイズが多少違ったりするかもしれませんがご了承ください
・フォント初制作なのであまりにも致命的なミス以外は許してください
配布元: https://uu.getuploader.com/mameh1nata_dustbox/download/1
アプリ同梱版の調整:
・JR-Nishi と字面を揃えるため、各グリフの表示領域を 1040 units に正規化
・送り幅は 930 units とし、広げた字形の右側はアプリ側で小幅の DiaPro NBSP を付加して保護
・縦の表示領域を 836 units、ベースラインを -86〜750 に正規化
・フォント全体の ascender / descender を JR-Nishi と同じ 781 / -118 に調整
・文字コードと内部フォント名は元ファイルから維持
+23
View File
@@ -1,3 +1,26 @@
export const series: { key: string; title: string; ids: string[] }[] = [
{ key: "32", title: "キハ32形", ids: ["32ns", "32s", "32kpuy1", "32kpuy2", "32tht", "32thtk", "32oni1", "32oni1k", "32toai", "32at"] },
{ key: "54", title: "キハ54形", ids: ["54s", "54st", "54nany1", "54nany2", "54smek1"] },
{ key: "40", title: "キハ40", ids: ["40s", "40w"] },
{ key: "185", title: "キハ185系", ids: ["185mrt", "185cm", "185g", "185tu", "185tu_uzu", "185iyor", "185iyoy", "185toai", "185mm1", "185mm2", "185ym1", "185ym2", "185ap1"] },
{ key: "1000", title: "1000形", ids: ["1000"] },
{ key: "1200", title: "1200形・1201形", ids: ["1200", "1201"] },
{ key: "1500", title: "1500形", ids: ["1501", "1550", "1551"] },
{ key: "2000", title: "2000系・N2000系", ids: ["2000asi", "2000uwa", "N2000", "2000nl", "2000-3", "2000ganp1", "2002a"] },
{ key: "2600", title: "2600系", ids: ["2600", "2600apr", "2600apb"] },
{ key: "2700", title: "2700系", ids: ["2700", "2700asi", "2700smn", "2700uzu", "2700apy", "2700apr", "2700apy1", "2700apr1"] },
{ key: "3600", title: "3600系", ids: ["3600"] },
{ key: "5000", title: "5000系", ids: ["5001", "5001k"] },
{ key: "6000", title: "6000系", ids: ["6000f"] },
{ key: "7000", title: "7000系", ids: ["7000"] },
{ key: "7200", title: "7200系", ids: ["7200"] },
{ key: "8000", title: "8000系", ids: ["8000no", "8000nr", "8001nr", "8000ap", "8000nn"] },
{ key: "8600", title: "8600系", ids: ["8600"] },
{ key: "9000", title: "9000系", ids: ["9000"] },
{ key: "9640", title: "9640形", ids: ["9640", "9640ht", "9640mo1", "9640mo2", "9640tyg", "9640tyb", "9640jgr", "9640jbl"] },
{ key: "other", title: "JR西日本・他", ids: ["285", "213w", "W741", "Wk141", "EF65", "EF210a", "EF210n", "EF210n1", "EF210l3", "T45000"] },
];
export default () =>{
return [
//{ "id": "32", "name": "キハ32形", "icon": require("./32.png") },
+14
View File
@@ -18,4 +18,18 @@ export default [
lng: 133.816444,
isSpot: true,
},
{
Station_JP: ".小歩危展望台",
Station_EN: "Koboke Observatory",
MyStation: "0",
StationNumber: null,
DispNum: "3",
StationTimeTable: "",
StationMap: "https://maps.app.goo.gl/WBMN5R2tk2tusavk7",
JrHpUrl: "https://miyoshi-tourism.jp/spot/5438/",
jslodApi: "spot",
lat: 33.9372609,
lng: 133.753258,
isSpot: true,
},
];
Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

@@ -1,4 +1,4 @@
import React, { useRef } from "react";
import React, { useRef, useState } from "react";
import { Platform } from "react-native";
import ActionSheet from "react-native-actions-sheet";
import { EachTrainInfoCore } from "./EachTrainInfoCore";
@@ -6,6 +6,16 @@ import { useSheetMaxHeight } from "./useSheetMaxHeight";
export const EachTrainInfo = ({ payload }) => {
const actionSheetRef = useRef(null);
const maxHeight = useSheetMaxHeight();
const [sheetOpened, setSheetOpened] = useState(false);
const handleOpen = () => {
setSheetOpened(true);
};
const handleClose = () => {
setSheetOpened(false);
};
if (!payload) return <></>;
return (
<ActionSheet
@@ -15,10 +25,11 @@ export const EachTrainInfo = ({ payload }) => {
drawUnderStatusBar={false}
isModal={Platform.OS === "ios" && !Platform.isPad}
containerStyle={{ maxHeight }}
onOpen={handleOpen}
onClose={handleClose}
//useBottomSafeAreaPadding={Platform.OS == "android"}
>
<EachTrainInfoCore {...{ actionSheetRef, ...payload }} />
<EachTrainInfoCore {...{ actionSheetRef, sheetOpened, ...payload }} />
</ActionSheet>
);
};
@@ -122,6 +122,16 @@ export const EachStopList: FC<props> = ({
const StationNumbers = Stations.filter((d) => d.StationNumber != null).map(
(d) => d.StationNumber as string
);
const trimmedPlatformNum = platformNum?.trim();
const hasPlatformNum = !!trimmedPlatformNum;
const isPlatformNumNumeric = hasPlatformNum && /^\d+$/.test(trimmedPlatformNum);
const platformDisplay = hasPlatformNum
? isPlatformNumNumeric
? parseInt(trimmedPlatformNum, 10) === 0
? "⓪"
: String.fromCharCode(0x2460 + parseInt(trimmedPlatformNum, 10) - 1)
: `(${trimmedPlatformNum})`
: "";
// SE文字列を表示用に変換
const [seString, seType] = parseSeString(se);
@@ -255,10 +265,18 @@ export const EachStopList: FC<props> = ({
textAlignVertical: "center",
}}
>
{station}{platformNum &&
(parseInt(platformNum) === 0
? "⓪"
: String.fromCharCode(0x2460 + parseInt(platformNum) - 1))}
{station}
{hasPlatformNum && (
<Text
style={{
fontSize: fontScale(isPlatformNumNumeric ? 20 : 14),
color: colors.text,
fontStyle: isCommunity ? "italic" : "normal",
}}
>
{platformDisplay}
</Text>
)}
</Text>
<View style={{ flex: 1 }} />
</View>
@@ -13,6 +13,7 @@ import { useStationList } from "../../../stateBox/useStationList";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { customTrainDataDetector } from "@/components/custom-train-data";
import type { NavigateFunction } from "@/types";
import { stackAwareNavigate } from "@/lib/rootNavigation";
type props = {
@@ -163,16 +164,16 @@ export const TrainDataView:FC<props> = ({
//disabled={!onLine}
//onLongPress={openEditWindow}
onLongPress={()=>{
if (!onLine) return;
if (!currentTrainData) return;
setInjectData({ type:"train", value:currentTrainData?.num, fixed:true});
navigate("positions", { screen: "Apps" });
stackAwareNavigate("positions");
SheetManager.hide("EachTrainInfo");
}}
onPress={() => {
if (!onLine) return;
setInjectData({ type: "station", value: currentPosition[0], fixed: false });
navigate("positions", { screen: "Apps" });
stackAwareNavigate("positions");
SheetManager.hide("EachTrainInfo");
}}
>
@@ -22,6 +22,7 @@ import { ShowSpecialTrain } from "./EachTrainInfo/ShowSpecialTrain";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { HeaderText } from "./EachTrainInfoCore/HeaderText";
import { useStationList } from "../../stateBox/useStationList";
import { useCurrentTrain } from "../../stateBox/useCurrentTrain";
import { useThemeColors } from "@/lib/theme";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useResponsive } from "@/lib/responsive";
@@ -41,6 +42,7 @@ export const EachTrainInfoCore = ({
openStationACFromEachTrainInfo,
from,
navigate,
sheetOpened = false,
}) => {
const { stationList } = useStationList();
const { allCustomTrainData } = useAllTrainDiagram();
@@ -49,6 +51,7 @@ export const EachTrainInfoCore = ({
const { setTrainInfo } = useTrainMenu();
const { height } = useWindowDimensions();
const { isLandscape } = useDeviceOrientationChange();
const { getCurrentStationData } = useCurrentTrain();
const scrollRef = useRef<any>(null);
// Custom hooks for data management
@@ -72,7 +75,9 @@ export const EachTrainInfoCore = ({
} = useExtendedStations(trainData, setTrainData);
// UI state
const [showThrew, setShowThrew] = useState(false);
// 走行中の列車は初期状態から通過駅を表示する(後から showThrew を true に変更すると
// ActionSheet の onSheetLayout が再発火してスプリングアニメーションが途中でリスタートするため)
const [showThrew, setShowThrew] = useState(() => !!getCurrentStationData(data.trainNum));
const [isJumped, setIsJumped] = useState(false);
// Auto scroll to current position
@@ -82,7 +87,7 @@ export const EachTrainInfoCore = ({
scrollRef,
isJumped,
setIsJumped,
setShowThrew
sheetOpened
);
// Back button handler
@@ -1,4 +1,4 @@
import React, { FC, useMemo, useState } from "react";
import React, { FC, useMemo } from "react";
import { Text, View, TextStyle, TouchableOpacity } from "react-native";
import { SheetManager } from "react-native-actions-sheet";
import { migrateTrainName } from "../../../lib/eachTrainInfoCoreLib/migrateTrainName";
@@ -16,7 +16,9 @@ import type { NavigateFunction } from "@/types";
import { useUnyohub } from "@/stateBox/useUnyohub";
import { useElesite } from "@/stateBox/useElesite";
import { useThemeColors } from "@/lib/theme";
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
import { useResponsive } from "@/lib/responsive";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
type Props = {
data: { trainNum: string; limited: string };
@@ -47,11 +49,11 @@ export const HeaderText: FC<Props> = ({
from,
scrollRef,
}) => {
const { limited, trainNum } = data;
const { trainNum } = data;
const { fixed } = useThemeColors();
const { fontScale } = useResponsive();
const { updatePermission } = useTrainMenu();
const { iconSetting } = useTrainMenu();
const { allCustomTrainData, getTodayOperationByTrainId } =
useAllTrainDiagram();
const { expoPushToken } = useNotification();
@@ -59,22 +61,22 @@ export const HeaderText: FC<Props> = ({
getUnyohubByTrainNumber,
getUnyohubEntriesByTrainNumber,
useUnyohub: unyohubEnabled,
unyohubData,
} = useUnyohub();
const { getElesiteEntriesByTrainNumber, useElesite: elesiteEnabled } =
useElesite();
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
// 追加ソースのON/OFFをここで管理(将来ソースが増えたらここに足す)
const additionalSources = {
unyohub: unyohubEnabled,
elesite: elesiteEnabled,
};
const hasAdditionalSources = Object.values(additionalSources).some(Boolean);
// 列車名、種別、フォントの取得
const [
typeName,
trainName,
fontAvailable,
fontFamily,
isOneMan,
infogram,
priority,
@@ -95,7 +97,7 @@ export const HeaderText: FC<Props> = ({
to_data,
directions,
} = result;
const [typeString, fontAvailable, isOneMan] = getStringConfig(
const [typeString, fontFamily, isOneMan] = getStringConfig(
type,
trainNum,
);
@@ -107,7 +109,7 @@ export const HeaderText: FC<Props> = ({
(train_num_distance !== "" && !isNaN(parseInt(train_num_distance))
? ` ${parseInt(trainNum) - parseInt(train_num_distance)}`
: ""),
fontAvailable,
fontFamily,
isOneMan,
infogram,
priority,
@@ -120,7 +122,7 @@ export const HeaderText: FC<Props> = ({
return [
typeString,
"",
fontAvailable,
fontFamily,
isOneMan,
infogram,
priority,
@@ -132,8 +134,8 @@ export const HeaderText: FC<Props> = ({
case to_data && to_data !== "":
return [
typeString,
to_data + "行き",
fontAvailable,
`${to_data}行き`,
fontFamily,
isOneMan,
infogram,
priority,
@@ -146,9 +148,9 @@ export const HeaderText: FC<Props> = ({
return [
typeString,
migrateTrainName(
trainData[trainData.length - 1].split(",")[0] + "行き",
`${trainData[trainData.length - 1].split(",")[0]}行き`,
),
fontAvailable,
fontFamily,
isOneMan,
infogram,
priority,
@@ -160,9 +162,8 @@ export const HeaderText: FC<Props> = ({
}
}, [trainData, trainNum, allCustomTrainData]);
const todayOperation = getTodayOperationByTrainId(trainNum).filter(
(d) => d.state !== 100,
);
const allTodayOperation = getTodayOperationByTrainId(trainNum) ?? [];
const todayOperation = allTodayOperation.filter((d) => d.state !== 100);
let iconTrainDirection =
parseInt(trainNum.replace(/[^\d]/g, "")) % 2 == 0 ? true : false;
@@ -171,11 +172,33 @@ export const HeaderText: FC<Props> = ({
}
const unyohubLookupNum = customTrainData?.train_number_override || trainNum;
const isFreightRetsuban = unyohubLookupNum.includes("レ");
const unyohubTrainNumForSourceScreen = isFreightRetsuban
? unyohubLookupNum.replace(/レ/g, "")
: unyohubLookupNum;
const freightUnyohubCandidates = (() => {
const digits = unyohubTrainNumForSourceScreen.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 unyohubFormation = getUnyohubByTrainNumber(unyohubLookupNum);
const unyohubEntries = getUnyohubEntriesByTrainNumber(unyohubLookupNum);
const unyohubEntries = isFreightRetsuban
? unyohubData.filter((unyo) =>
unyo.trains?.some(
(t) => !!t.train_number && freightUnyohubCandidates.has(t.train_number),
),
)
: getUnyohubEntriesByTrainNumber(unyohubTrainNumForSourceScreen);
const elesiteEntries = getElesiteEntriesByTrainNumber(trainNum);
// 車番(formations)が空でないエントリが1件以上あれば「運用Hub情報あり」と判定
const hasUnyohubFormation = unyohubEntries.some(
(e) => !!e.formations && e.formations.trim() !== "",
);
@@ -185,11 +208,20 @@ export const HeaderText: FC<Props> = ({
const hasExtraInfo =
priority > 200 ||
todayOperation?.length > 0 ||
todayOperation.length > 0 ||
hasUnyohubFormation ||
hasElesiteFormation;
const [isWrapped, setIsWrapped] = useState(false);
const wrapType = shouldWrapTrainType(typeName, trainName);
const openTrainInfoUrl = () => {
if (!trainInfoUrl) return;
const uri = trainInfoUrl.includes("pdf")
? getPDFViewURL(trainInfoUrl)
: trainInfoUrl;
navigate("generalWebView", { uri, useExitButton: true });
SheetManager.hide("EachTrainInfo");
};
return (
<View
@@ -209,6 +241,7 @@ export const HeaderText: FC<Props> = ({
from={from}
todayOperation={todayOperation}
direction={iconTrainDirection}
iconDisplayMode={iconDisplayMode}
/>
<View
@@ -219,15 +252,23 @@ export const HeaderText: FC<Props> = ({
}}
>
<Text
numberOfLines={wrapType ? 2 : 1}
style={{
fontSize: fontScale(20),
color: fixed.textOnPrimary,
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
fontFamily,
fontWeight: fontFamily ? undefined : "bold",
marginRight: 5,
flexShrink: 0,
...(wrapType
? { width: fontScale(44), lineHeight: fontScale(22) }
: {}),
}}
>
{isWrapped ? typeName.replace(/(.{2})/g, "$1\n").trim() : typeName}
{wrapType ? formatWrappedTrainType(typeName) : typeName}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text>
{isOneMan && <OneManText />}
</View>
@@ -247,26 +288,18 @@ export const HeaderText: FC<Props> = ({
}
: {}),
}}
onPress={() => {
if (!trainInfoUrl) return;
const uri = trainInfoUrl.includes("pdf")
? getPDFViewURL(trainInfoUrl)
: trainInfoUrl;
navigate("generalWebView", { uri, useExitButton: true });
SheetManager.hide("EachTrainInfo");
}}
onPress={openTrainInfoUrl}
disabled={!trainInfoUrl}
>
<Text
style={{
...textConfig,
color: fixed.textOnPrimary,
...(trainName.length > 10 ? { fontSize: fontScale(16) } : { fontSize: fontScale(17) }),
...(trainName.length > 10
? { fontSize: fontScale(16) }
: { fontSize: fontScale(17) }),
flexShrink: 1,
}}
onTextLayout={(e) => {
if (e.nativeEvent.lines.length > 1) setIsWrapped(true);
}}
>
{trainName}
</Text>
@@ -288,7 +321,7 @@ export const HeaderText: FC<Props> = ({
(SheetManager.show as any)("TrainDataSources", {
payload: {
trainNum,
unyohubTrainNum: unyohubLookupNum,
unyohubTrainNum: unyohubTrainNumForSourceScreen,
unyohubEntries,
elesiteEntries,
todayOperation,
@@ -305,7 +338,6 @@ export const HeaderText: FC<Props> = ({
},
});
} else {
// 追加ソースが全てオフ → 元の挙動(直接 DB ページを開く)
const uri = `https://jr-shikoku-data-system.pages.dev/trainData/${trainNum}?userID=${expoPushToken}&from=eachTrainInfo`;
navigate("generalWebView", { uri, useExitButton: false });
SheetManager.hide("EachTrainInfo");
@@ -3,13 +3,14 @@ import { Text } from "react-native";
import { useThemeColors } from "@/lib/theme";
type props = {
infogram: string;
fontSize?: number;
}
export const InfogramText: FC<props> = ({infogram}) => {
export const InfogramText: FC<props> = ({infogram, fontSize = 20}) => {
const { fixed } = useThemeColors();
return (
<Text
style={{
fontSize: 20,
fontSize,
color: fixed.textOnPrimary,
fontFamily: "JNR-font",
}}
@@ -0,0 +1,98 @@
import React, { ComponentProps, FC } from "react";
import { Image, TouchableOpacity, View } from "react-native";
import { Ionicons } from "@expo/vector-icons";
import type { TrainIconEntry } from "@/lib/trainIconEntries";
type StackVariant = "header" | "list";
type StatusIcon = ComponentProps<typeof Ionicons>;
type Props = {
entries: TrainIconEntry[];
direction?: boolean;
hidden?: boolean;
onPressEntry?: (entry: TrainIconEntry, index: number) => void;
statusIcon?: StatusIcon;
variant?: StackVariant;
};
const iconSize = {
header: {
width: 24,
height: 30,
stackedWidth: 12,
stackedHeight: 15,
marginRight: 5,
stackedMarginLeft: -10,
stackedMarginTop: 10,
statusSize: 24,
},
list: {
width: 20,
height: 22,
stackedWidth: 10,
stackedHeight: 12,
marginRight: 2,
stackedMarginLeft: -8,
stackedMarginTop: 8,
statusSize: 18,
},
} as const;
export const TrainIconStack: FC<Props> = ({
entries,
direction,
hidden = false,
onPressEntry,
statusIcon,
variant = "header",
}) => {
const size = iconSize[variant];
return (
<View style={{ flexDirection: "row", alignItems: "flex-start" }}>
{entries.map((entry, index) => {
const trainIcon = direction
? entry.vehicle_info_img
: entry.vehicle_info_right_img || entry.vehicle_info_img;
if (!trainIcon) return null;
const content = (
<View>
<View style={{ opacity: hidden ? 0 : 1 }}>
<Image
source={{ uri: trainIcon }}
style={{
height: index > 0 ? size.stackedHeight : size.height,
width: index > 0 ? size.stackedWidth : size.width,
marginRight: size.marginRight,
marginLeft: index > 0 ? size.stackedMarginLeft : 0,
marginTop: index > 0 ? size.stackedMarginTop : 0,
}}
resizeMethod="resize"
/>
</View>
{statusIcon && hidden && (
<View style={{ position: "absolute", top: 0, left: 0 }}>
<Ionicons {...statusIcon} size={size.statusSize} />
</View>
)}
</View>
);
if (!onPressEntry) {
return <View key={`${trainIcon}-${index}`}>{content}</View>;
}
return (
<TouchableOpacity
key={`${trainIcon}-${index}`}
onPress={() => onPressEntry(entry, index)}
disabled={!entry.vehicle_info_url}
>
{content}
</TouchableOpacity>
);
})}
</View>
);
};
@@ -1,5 +1,4 @@
import { useEffect, MutableRefObject } from 'react';
import { InteractionManager } from 'react-native';
export const useAutoScroll = (
@@ -8,31 +7,27 @@ export const useAutoScroll = (
scrollRef: MutableRefObject<any>,
isJumped: boolean,
setIsJumped: (value: boolean) => void,
setShowThrew: (value: boolean) => void
sheetOpened: boolean = false
) => {
useEffect(() => {
if (isJumped || !points?.length || !scrollRef) return;
// ActionSheetのスプリングアニメーション完了後まで待機
if (!sheetOpened || isJumped || !points?.length || !scrollRef) return;
const currentPositionIndex = points.findIndex((d) => d === true);
if (currentPositionIndex === -1) return;
// ActionSheetの開閉アニメーション完了後にレイアウト変更を行う
const handle = InteractionManager.runAfterInteractions(() => {
setShowThrew(true);
// 5駅以内の場合はスクロールしない
if (currentPositionIndex < 5) {
setIsJumped(true);
return;
}
// 5駅以内の場合はスクロールしない
if (currentPositionIndex < 5) {
setIsJumped(true);
return;
}
const scrollPosition = currentPositionIndex * 44 - 50;
const timer = setTimeout(() => {
scrollRef.current?.scrollTo({ y: scrollPosition, animated: true });
setIsJumped(true);
}, 100);
const scrollPosition = currentPositionIndex * 44 - 50;
setTimeout(() => {
scrollRef.current?.scrollTo({ y: scrollPosition, animated: true });
setIsJumped(true);
}, 100);
});
return () => handle.cancel();
}, [points, trainDataWithThrough, scrollRef, isJumped, setIsJumped, setShowThrew]);
return () => clearTimeout(timer);
}, [sheetOpened, points, trainDataWithThrough, scrollRef, isJumped, setIsJumped]);
};
@@ -1,25 +1,25 @@
import { useState, useEffect } from 'react';
import { useStationList } from '@/stateBox/useStationList';
const computeStopStationIDs = (data: string[], stationList: any[][]): string[][] =>
data.map((item) => {
const [stationName] = item.split(',');
return stationList
.map((lineStations) => lineStations.filter((s) => s.StationName === stationName))
.reduce((acc, s) => acc.concat(s), [])
.map((s) => s.StationNumber);
});
export const useStopStationIDs = (trainDataWithThrough: string[]) => {
const { stationList } = useStationList();
const [stopStationIDList, setStopStationIDList] = useState<string[][]>([]);
// 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ
const [stopStationIDList, setStopStationIDList] = useState<string[][]>(() =>
computeStopStationIDs(trainDataWithThrough, stationList)
);
useEffect(() => {
const stationIDs = trainDataWithThrough.map((item) => {
const [stationName] = item.split(',');
const matchingStations = stationList
.map((lineStations) =>
lineStations.filter((station) => station.StationName === stationName)
)
.reduce((acc, stations) => acc.concat(stations), [])
.map((station) => station.StationNumber);
return matchingStations;
});
setStopStationIDList(stationIDs);
setStopStationIDList(computeStopStationIDs(trainDataWithThrough, stationList));
}, [trainDataWithThrough, stationList]);
return stopStationIDList;
@@ -1,108 +1,116 @@
import { useState, useEffect } from 'react';
import { lineListPair, stationIDPair } from '@/lib/getStationList';
import { useStationList } from '@/stateBox/useStationList';
import { OriginalStationList } from '@/lib/CommonTypes';
const computeThroughStations = (
trainData: string[],
stationList: any[][],
originalStationList: OriginalStationList
): { trainDataWithThrough: string[]; haveThrough: boolean } => {
if (!trainData.length) return { trainDataWithThrough: [], haveThrough: false };
let haveThrough = false;
const isCancel: boolean[] = [];
const stopStationList = trainData.map((item, index, array) => {
const [station, se] = item.split(',');
const [, nextSe] = array[index + 1]?.split(',') || [];
if (nextSe) {
// 運休判定ロジック:
// 1. 両方が休系(休編、休発、休着など)→ 運休区間
// 2. 着/着編 → 休発/休発編:到着後に運休開始 → 通過駅は通常運行
// 3. 休着/休着編 → 発/発編:運休終了後に出発 → 通過駅は通常運行
// 4. その他の休の組み合わせ → 運休区間
const bothCanceled = se.includes('休') && nextSe.includes('休');
const normalArrivalToSuspendStart =
(se === '着' || se === '着編') && (nextSe.includes('休') && nextSe.includes('発'));
const suspendEndToNormalDeparture =
(se.includes('休') && se.includes('着')) && (nextSe === '発' || nextSe === '発編');
isCancel.push(bothCanceled && !normalArrivalToSuspendStart && !suspendEndToNormalDeparture);
}
if (se === '通編') haveThrough = true;
return stationList.map((a) => a.filter((d) => d.StationName === station));
});
const allThroughStationList = stopStationList.map((firstItem, index, array) => {
if (index === array.length - 1) return [];
const secondItem = array[index + 1];
let betweenStationLine = '';
let baseStationNumberFirst = '';
let baseStationNumberSecond = '';
Object.keys(stationIDPair).forEach((lineName, lineIndex) => {
if (!lineName) return;
const haveFirst = firstItem[lineIndex];
const haveSecond = secondItem[lineIndex];
if (haveFirst?.length && haveSecond?.length) {
betweenStationLine = lineName;
baseStationNumberFirst = haveFirst[0].StationNumber;
baseStationNumberSecond = haveSecond[0].StationNumber;
}
});
if (!betweenStationLine) return [];
const allThroughStation: string[] = [];
let reverse = false;
originalStationList[lineListPair[stationIDPair[betweenStationLine]]]?.forEach((station) => {
const throughStatus = isCancel[index] ? '通休編' : '通過';
if (
station.StationNumber > baseStationNumberFirst &&
station.StationNumber < baseStationNumberSecond
) {
allThroughStation.push(`${station.Station_JP},${throughStatus},`);
haveThrough = true;
reverse = false;
} else if (
station.StationNumber < baseStationNumberFirst &&
station.StationNumber > baseStationNumberSecond
) {
allThroughStation.push(`${station.Station_JP},${throughStatus},`);
haveThrough = true;
reverse = true;
}
});
if (reverse) allThroughStation.reverse();
return allThroughStation;
});
let mainArray = [...trainData];
let offset = 0;
trainData.forEach((_, index) => {
offset += 1;
const throughStations = allThroughStationList[index];
if (!throughStations?.length) return;
mainArray.splice(offset, 0, ...throughStations);
offset += throughStations.length;
});
return { trainDataWithThrough: mainArray, haveThrough };
};
export const useThroughStations = (trainData) => {
const { originalStationList, stationList } = useStationList();
const [trainDataWithThrough, setTrainDataWithThrough] = useState([]);
const [haveThrough, setHaveThrough] = useState(false);
// 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ
const [state, setState] = useState(() =>
computeThroughStations(trainData, stationList, originalStationList)
);
useEffect(() => {
if (!trainData.length) {
setTrainDataWithThrough([]);
return;
}
const isCancel = [];
const stopStationList = trainData.map((item, index, array) => {
const [station, se] = item.split(',');
const [, nextSe] = array[index + 1]?.split(',') || [];
if (nextSe) {
// 運休判定ロジック:
// 1. 両方が休系(休編、休発、休着など)→ 運休区間
// 2. 着/着編 → 休発/休発編:到着後に運休開始 → 通過駅は通常運行
// 3. 休着/休着編 → 発/発編:運休終了後に出発 → 通過駅は通常運行
// 4. その他の休の組み合わせ → 運休区間
const bothCanceled = se.includes('休') && nextSe.includes('休');
const normalArrivalToSuspendStart =
(se === '着' || se === '着編') && (nextSe.includes('休') && nextSe.includes('発'));
const suspendEndToNormalDeparture =
(se.includes('休') && se.includes('着')) && (nextSe === '発' || nextSe === '発編');
const isCanceled = bothCanceled && !normalArrivalToSuspendStart && !suspendEndToNormalDeparture;
isCancel.push(isCanceled);
}
if (se === '通編') setHaveThrough(true);
return stationList.map((a) => a.filter((d) => d.StationName === station));
});
const allThroughStationList = stopStationList.map((firstItem, index, array) => {
if (index === array.length - 1) return [];
const secondItem = array[index + 1];
let betweenStationLine = '';
let baseStationNumberFirst = '';
let baseStationNumberSecond = '';
Object.keys(stationIDPair).forEach((lineName, lineIndex) => {
if (!lineName) return;
const haveFirst = firstItem[lineIndex];
const haveSecond = secondItem[lineIndex];
if (haveFirst?.length && haveSecond?.length) {
betweenStationLine = lineName;
baseStationNumberFirst = haveFirst[0].StationNumber;
baseStationNumberSecond = haveSecond[0].StationNumber;
}
});
if (!betweenStationLine) return [];
const allThroughStation = [];
let reverse = false;
originalStationList[lineListPair[stationIDPair[betweenStationLine]]]?.forEach((station) => {
const throughStatus = isCancel[index] ? '通休編' : '通過';
if (
station.StationNumber > baseStationNumberFirst &&
station.StationNumber < baseStationNumberSecond
) {
allThroughStation.push(`${station.Station_JP},${throughStatus},`);
setHaveThrough(true);
reverse = false;
} else if (
station.StationNumber < baseStationNumberFirst &&
station.StationNumber > baseStationNumberSecond
) {
allThroughStation.push(`${station.Station_JP},${throughStatus},`);
setHaveThrough(true);
reverse = true;
}
});
if (reverse) allThroughStation.reverse();
return allThroughStation;
});
let mainArray = [...trainData];
let offset = 0;
trainData.forEach((_, index) => {
offset += 1;
const throughStations = allThroughStationList[index];
if (!throughStations?.length) return;
mainArray.splice(offset, 0, ...throughStations);
offset += throughStations.length;
});
setTrainDataWithThrough(mainArray);
setState(computeThroughStations(trainData, stationList, originalStationList));
}, [trainData, stationList, originalStationList]);
return { trainDataWithThrough, haveThrough };
return { trainDataWithThrough: state.trainDataWithThrough, haveThrough: state.haveThrough };
};
@@ -2,28 +2,34 @@ import { useState, useEffect } from 'react';
import { useAllTrainDiagram } from '@/stateBox/useAllTrainDiagram';
import { searchSpecialTrain } from '@/lib/eachTrainInfoCoreLib/searchSpecialTrain';
const parseTrainData = (trainNum: string, trainList: Record<string, string>) => {
if (!trainNum) return { data: [], trueIDs: [] };
const TD = trainList[trainNum];
if (!TD) {
const specialTrainActualIDs = searchSpecialTrain(trainNum, trainList);
return { data: [], trueIDs: specialTrainActualIDs || [] };
}
return { data: TD.split('#').filter((d) => d !== ''), trueIDs: [] };
};
export const useTrainDiagramData = (trainNum) => {
const { allTrainDiagram: trainList } = useAllTrainDiagram();
const [trainData, setTrainData] = useState([]);
const [trueTrainID, setTrueTrainID] = useState([]);
const [isManuallyExtended, setIsManuallyExtended] = useState(false);
// 初回レンダリング時にコンテキストから同期的にデータを取得することで
// ActionSheetのアニメーション中に高さが変わるのを防ぐ
const [trainData, setTrainData] = useState(() => parseTrainData(trainNum, trainList).data);
const [trueTrainID, setTrueTrainID] = useState(() => parseTrainData(trainNum, trainList).trueIDs);
useEffect(() => {
if (!trainNum) return;
// 手動で拡張されている場合は上書きしない
if (isManuallyExtended) return;
const TD = trainList[trainNum];
if (!TD) {
const specialTrainActualIDs = searchSpecialTrain(trainNum, trainList);
setTrueTrainID(specialTrainActualIDs || []);
setTrainData([]);
return;
}
setTrainData(TD.split('#').filter((d) => d !== ''));
const { data, trueIDs } = parseTrainData(trainNum, trainList);
setTrueTrainID(trueIDs);
setTrainData(data);
}, [trainNum, trainList, isManuallyExtended]);
const setTrainDataExtended = (data) => {
@@ -1,5 +1,4 @@
import React, { ComponentProps, FC, useEffect, useState } from "react";
import { View, Image, TouchableOpacity } from "react-native";
import React, { ComponentProps, FC, useEffect, useMemo, useState } from "react";
import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { SheetManager } from "react-native-actions-sheet";
@@ -8,6 +7,9 @@ import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useInterval } from "@/lib/useInterval";
import type { NavigateFunction } from "@/types";
import { OperationLogs } from "@/lib/CommonTypes";
import type { IconDisplayMode } from "@/lib/iconDisplayMode";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import { TrainIconStack } from "./TrainIconStack";
type GlyphNames = ComponentProps<typeof Ionicons>["name"];
@@ -17,94 +19,54 @@ type Props = {
from: string;
todayOperation: OperationLogs[];
direction?: boolean;
iconDisplayMode: IconDisplayMode;
};
type apt = {
name: GlyphNames;
color: string;
};
export const TrainIconStatus: FC<Props> = (props) => {
const { data, navigate, from, todayOperation, direction } = props;
const {
data,
navigate,
from,
todayOperation,
direction,
iconDisplayMode,
} = props;
const [anpanmanStatus, setAnpanmanStatus] = useState<apt>();
const { allCustomTrainData } = useAllTrainDiagram();
const [trainIconData, setTrainIcon] = useState<
{ vehicle_info_img: string;vehicle_info_right_img: string; vehicle_info_url: string }[]
>([]);
const customTrainData = useMemo(
() => customTrainDataDetector(data.trainNum, allCustomTrainData),
[data.trainNum, allCustomTrainData],
);
const trainIconData = useMemo(
() =>
data.trainNum
? resolveTrainIconEntries({
trainNum: data.trainNum,
customTrainData,
todayOperation,
iconDisplayMode,
})
: [],
[data.trainNum, customTrainData, todayOperation, iconDisplayMode],
);
useEffect(() => {
if (!data.trainNum) return;
const { train_info_img: vehicle_info_img, vehicle_info_url } =
customTrainDataDetector(data.trainNum, allCustomTrainData);
if (todayOperation.length !== 0) {
const returnData =
todayOperation
.sort((a, b) => {
// trainIdからカンマ以降の数字を抽出する関数
const extractOrderNumber = (trainId: string): number => {
const parts = trainId.split(',');
if (parts.length > 1) {
const num = parseInt(parts[1].trim(), 10);
return isNaN(num) ? Infinity : num;
}
return Infinity; // カンマなし = 末尾に移動
};
// data.trainNumと一致するtrainIdを探す関数
const findMatchingTrainId = (operation: OperationLogs): string | null => {
const allTrainIds = [
...(operation.train_ids || []),
...(operation.related_train_ids || []),
];
// data.trainNumの接頭辞と一致するものを探す
for (const trainId of allTrainIds) {
const prefix = trainId.split(',')[0]; // カンマ前の部分
if (prefix === data.trainNum) {
return trainId;
}
}
return null;
};
const aTrainId = findMatchingTrainId(a);
const bTrainId = findMatchingTrainId(b);
// マッチしたものがない場合は元の順序を保持
if (!aTrainId || !bTrainId) {
return aTrainId ? -1 : bTrainId ? 1 : 0;
}
const aOrder = extractOrderNumber(aTrainId);
const bOrder = extractOrderNumber(bTrainId);
return aOrder - bOrder;
})
.map((op) => ({
vehicle_info_img: op.vehicle_img || vehicle_info_img,
vehicle_info_right_img: op.vehicle_img_right || op.vehicle_img || vehicle_info_img,
vehicle_info_url: op.vehicle_info_url,
})) || [];
setTrainIcon(returnData);
} else if (vehicle_info_img) {
setTrainIcon([{ vehicle_info_img, vehicle_info_right_img: vehicle_info_img, vehicle_info_url }]);
}
// アンパンマンステータスAPIのエンドポイント判定
let anpanmanApiPath: string | null = null;
switch (data.trainNum) {
// 予讃線 → yosan-anpanman
// しおかぜ 8000 アンパン
case "10M":
case "22M":
case "9M":
case "21M":
// いしづち 8000 アンパン
case "1010M":
case "1022M":
case "1009M":
case "1021M":
// いしづち 三桁 アンパン
case "1041M":
case "1044M":
// 宇和海 2000 アンパン
case "1058D":
case "1066D":
case "1074D":
@@ -113,8 +75,6 @@ export const TrainIconStatus: FC<Props> = (props) => {
case "1067D":
anpanmanApiPath = "yosan-anpanman";
break;
// 土讃線 → dosan-anpanman
// 南風 2700 アンパン
case "32D":
case "36D":
case "44D":
@@ -132,12 +92,12 @@ export const TrainIconStatus: FC<Props> = (props) => {
fetch(
`https://n8n.haruk.in/webhook/${anpanmanApiPath}?trainNum=${
data.trainNum
}&month=${dayjs().format("M")}&day=${dayjs().format("D")}`
}&month=${dayjs().format("M")}&day=${dayjs().format("D")}`,
{ cache: "no-store" },
)
.then((d) => d.json())
.then((d) => {
if (d.trainStatus == "" || d.trainStatus == "○") {
//setAnpanmanStatus({name:"checkmark-circle-outline",color:"blue"});
} else if (d.trainStatus == "▲") {
setAnpanmanStatus({ name: "warning-outline", color: "yellow" });
} else if (d.trainStatus == "×") {
@@ -146,11 +106,8 @@ export const TrainIconStatus: FC<Props> = (props) => {
})
.catch(() => {});
}
}, [data.trainNum, allCustomTrainData, todayOperation]);
}, [data.trainNum, allCustomTrainData, todayOperation, iconDisplayMode]);
// JSスレッドでの点滅(useInterval + useState
// reanimated の withRepeat はUIスレッドで毎フレーム更新し続けるため
// ActionSheetのスプリングアニメーションと競合する
const [showIcon, setShowIcon] = useState(false);
useInterval(() => {
if (anpanmanStatus) {
@@ -159,47 +116,18 @@ export const TrainIconStatus: FC<Props> = (props) => {
}, 1000, !!anpanmanStatus);
return (
<>
{trainIconData.map(
({ vehicle_info_img: trainIcon, vehicle_info_right_img: trainIconRight, vehicle_info_url: address }, index) => (
<TouchableOpacity
key={`${trainIcon}-${index}`}
onPress={() => {
navigate("howto", {
info: address,
goTo: from == "LED" ? "menu" : from,
});
SheetManager.hide("EachTrainInfo");
}}
disabled={!address}
>
<View>
<View style={{ opacity: anpanmanStatus && showIcon ? 0 : 1 }}>
<Image
source={{ uri: direction ? trainIcon : trainIconRight || trainIcon }}
style={{
height: index > 0 ? 15 : 30,
width: index > 0 ? 12 : 24,
marginRight: 5,
marginLeft: index > 0 ? -10 : 0,
marginTop: index > 0 ? 10 : 0,
}}
resizeMethod="resize"
/>
</View>
{anpanmanStatus && showIcon && (
<View style={{ position: "absolute", top: 0, left: 0 }}>
<Ionicons
{...anpanmanStatus}
size={24}
style={{ marginRight: 5 }}
/>
</View>
)}
</View>
</TouchableOpacity>
)
)}
</>
<TrainIconStack
entries={trainIconData}
direction={direction}
hidden={!!anpanmanStatus && showIcon}
statusIcon={anpanmanStatus}
onPressEntry={(entry) => {
navigate("howto", {
info: entry.vehicle_info_url,
goTo: from == "LED" ? "menu" : from,
});
SheetManager.hide("EachTrainInfo");
}}
/>
);
};
@@ -36,6 +36,10 @@ export const JRSTraInfo = () => {
const maxHeight = useSheetMaxHeight();
const viewShot = useRef(null);
useEffect(() => {
setLoadingDelayData(true);
}, []);
const onCapture = async () => {
const url = await viewShot.current.capture();
@@ -48,7 +48,9 @@ export const StationDeteilView = (props) => {
const [usePDFView, setUsePDFView] = useState(undefined);
useEffect(() => {
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW).then(setUsePDFView);
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW)
.then(setUsePDFView)
.catch(() => setUsePDFView("false"));
}, []);
const info =
currentStation &&
@@ -3,6 +3,7 @@ import { TouchableOpacity, View, Text, Linking } from "react-native";
import { useCurrentTrain } from "@/stateBox/useCurrentTrain";
import { useThemeColors } from "@/lib/theme";
import AntDesign from "react-native-vector-icons/AntDesign";
import { stackAwareNavigate } from "@/lib/rootNavigation";
type Props = {
stationNumber: string;
onExit: () => void;
@@ -24,12 +25,12 @@ export const StationTrainPositionButton: FC<Props> = (props) => {
flex: 1,
}}
onLongPress={() => {
navigate("positions", { screen: "Apps" });
stackAwareNavigate("positions");
setInjectData({ type: "station", value:stationNumber, fixed: true });
onExit();
}}
onPress={() => {
navigate("positions", { screen: "Apps" });
stackAwareNavigate("positions");
setInjectData({ type: "station", value: stationNumber, fixed: false });
onExit();
}}
@@ -1,4 +1,4 @@
import React, { FC, useState, useEffect, useRef } from "react";
import React, { FC, useState, useEffect, useMemo, useRef } from "react";
import {
View,
Text,
@@ -21,6 +21,10 @@ 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";
@@ -51,7 +55,7 @@ export type TrainDataSourcesPayload = {
};
const HUB_LOGO_PNG = require("@/assets/relationLogo/unyohub_logo.webp");
const ELESITE_LOGO_PNG = require("@/assets/relationLogo/elesite_logo.png");
const ELESITE_LOGO_PNG = require("@/assets/relationLogo/elesite_logo.jpg");
/** ISO 8601 日時文字列を "HH:MM" 形式にフォーマット */
const formatHHMM = (iso: string): string => {
@@ -83,6 +87,226 @@ const formatDateHHMM = (datetime: string): string => {
}
};
/* ------------------------------------------------------------------ */
/* 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,
}) => {
@@ -128,7 +352,45 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
destinationStation,
} = payload;
const hubTrainNum = unyohubTrainNumProp || trainNum;
// __メモ書き サフィックスを除去して列番部分だけを返す
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 が明示されていればそれを使う
@@ -153,8 +415,34 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
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
@@ -226,7 +514,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
</View>
);
// 鉄道運用Hub: 車番(formations)が空でないエントリのみ抽出して判定
// 鉄道運用Hub: 車番(formations) が空でないエントリのみ表示対象にする
const hasNonEmptyFormations = unyohubEntries.some(
(e) => !!e.formations && e.formations.trim() !== "",
);
@@ -242,6 +530,12 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
.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 =
@@ -269,32 +563,36 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
// outbound → position_forward 昇順 (pos=1 が宇和島/南端側)
// inbound → position_forward 降順 (pos=MAX が宇和島/南端側)
const matchedDirection = nonEmptyFormationEntries[0]?.trains?.find(
(t) => t.train_number === hubTrainNum,
(t) => matchesHubTrainNum(t.train_number),
)?.direction;
const hubSortDescending = matchedDirection === "inbound";
const formationNames =
[...nonEmptyFormationEntries]
.sort((a, b) => {
const posA =
a.trains?.find((t) => t.train_number === hubTrainNum)
?.position_forward ?? 0;
const posB =
b.trains?.find((t) => t.train_number === hubTrainNum)
?.position_forward ?? 0;
return hubSortDescending ? posB - posA : posA - posB;
})
.slice(0, 4)
.map((e) => e.formations)
.join("・") +
(nonEmptyFormationEntries.length > 4
? `${nonEmptyFormationEntries.length - 4}`
: "");
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 && (
<Text style={[styles.unitIdText, { color: colors.textAccent, opacity: isUnyohubStale ? 0.4 : 1 }]}>{formationNames}</Text>
<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>
@@ -334,46 +632,25 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
</View>
);
// えれサイト最終投稿時刻(last_reported_at が最新のエントリ)
const elesiteLastReportedAt =
elesiteEntries
.map((e) => e.report_info?.last_reported_at)
.filter((d): d is string => !!d)
.sort()
.at(-1) ?? null;
// えれサイト: units が1件以上あるエントリのみ「データあり」と判定
const elesiteHasNonEmptyFormations = elesiteEntries.some(
(e) => (e.formation_config?.units?.length ?? 0) > 0,
const elesiteHasNonEmptyFormations = elesiteDisplayGroups.some(
(group) => group.hasFormations,
);
const elesiteNonEmptyFormationEntries = elesiteEntries
.filter((e) => (e.formation_config?.units?.length ?? 0) > 0)
.sort((a, b) => {
// high松(left_station)側のユニットを先に表示
// (heading_to === "left") === is_leading が true → high松(left)端のユニット
const aNav = a.trains?.find((t) => t.train_number === trainNum)?.nav;
const bNav = b.trains?.find((t) => t.train_number === trainNum)?.nav;
const aIsLeft =
(aNav?.heading_to === "left") === (aNav?.is_leading === true);
const bIsLeft =
(bNav?.heading_to === "left") === (bNav?.is_leading === true);
if (aIsLeft === bIsLeft) return 0;
return aIsLeft ? -1 : 1;
});
// えれサイト: 編成名テキスト(formation_config.units 優先)
const elesiteFormationNames =
elesiteNonEmptyFormationEntries
.slice(0, 4)
.map((e) => {
const units = e.formation_config?.units;
return units?.length
? units.map((u) => u.formation).join("+")
: e.formations;
})
.join("・") +
(elesiteNonEmptyFormationEntries.length > 4
? `${elesiteNonEmptyFormationEntries.length - 4}`
: "");
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
@@ -394,23 +671,15 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
const elesiteFormationDetail = (
<View style={styles.operationDetailBlock}>
{elesiteHasNonEmptyFormations && (
<Text style={styles.unitIdText}>{elesiteFormationNames}</Text>
{elesiteDisplayGroups.length > 0 && (
<ElesiteLineGroupCycler
groups={elesiteDisplayGroups}
color="#44bb44"
onActiveGroupChange={(group) =>
setActiveElesiteGroupKey(group?.key ?? null)
}
/>
)}
{elesiteCount > 0
? (() => {
const fc = (elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0])
?.formation_config;
return fc?.left_station && fc?.right_station ? (
<RefDirectionBanner
rows={[
{ leftLabel: fc.left_station, rightLabel: fc.right_station },
]}
color="#44bb44"
/>
) : null;
})()
: undefined}
</View>
);
return (
@@ -483,9 +752,11 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
label="外部コミュニティデータ"
sub={
hasNonEmptyFormations
? unyohubLastPostedDatetime
? `最終投稿: ${formatDateHHMM(unyohubLastPostedDatetime)}`
: ""
? unyohubSubItems.length > 1
? <FadingSubCycler items={unyohubSubItems} color={colors.textSecondary} />
: unyohubLastPostedDatetime
? `最終投稿: ${formatDateHHMM(unyohubLastPostedDatetime)}`
: ""
: unyoCount > 0
? "数日の運用報告なし"
: "この列車の運用データはありません"
@@ -496,7 +767,8 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
disabled={unyoCount === 0}
onPress={() =>
openWebView(
`https://jr-shikoku-data-system.pages.dev/unyohub-connection-train-data/${hubTrainNum}`,
`https://jr-shikoku-data-system.pages.dev/unyohub-connection-train-data/${originalHubTrainNum}`,
true,
)
}
@@ -514,23 +786,20 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
elesiteCount === 0
? "この列車の運用データはありません"
: !elesiteHasNonEmptyFormations
? "本日の運用報告なし"
: elesiteLastReportedAt
? `最終投稿: ${formatHHMM(elesiteLastReportedAt)}`
: ""
? activeElesiteGroupLabel || "本日の運用報告なし"
: elesiteDisplayGroups.length > 1
? activeElesiteGroupLabel || ""
: activeElesiteGroup?.lastReportedAt
? `最終投稿: ${formatHHMM(activeElesiteGroup.lastReportedAt)}`
: activeElesiteGroupLabel || ""
}
badge={elesiteHasNonEmptyFormations ? elesiteCount : null}
badge={elesiteBadgeCount}
badgeColor="#44bb44"
detail={elesiteFormationDetail}
disabled={elesiteCount === 0}
onPress={() => {
const matchedEntry =
elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0];
const matchedTrain = matchedEntry?.trains?.find(
(t) => t.train_number === trainNum,
);
const url =
matchedTrain?.timetable_url || "https://www.elesite-next.com/";
activeElesiteGroup?.timetableUrl || "https://www.elesite-next.com/";
SheetManager.hide("TrainDataSources");
Linking.openURL(url);
}}
@@ -813,7 +1082,7 @@ const TrainInfoDetail: FC<{
uwasa,
optional_text,
} = data;
const { fontAvailable } = getTrainType({
const { fontFamily } = getTrainType({
type: data.type,
whiteMode: false,
});
@@ -851,13 +1120,16 @@ const TrainInfoDetail: FC<{
style={[
styles.typeTagText,
{
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
paddingTop: fontAvailable ? 2 : 0,
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>
)}
@@ -942,15 +1214,28 @@ const TrainInfoDetail: FC<{
{/* うわさ / optional_text */}
{(!!uwasa || !!optional_text) && (
<View style={[styles.noteSection, { borderLeftColor: colors.borderSecondary }]}>
<View style={styles.noteSection}>
{!!uwasa && (
<View style={styles.noteRow}>
<View
style={[
styles.noteRow,
styles.rumorRow,
{
backgroundColor: "rgba(245, 158, 11, 0.1)",
borderColor: colors.textWarning,
},
]}
>
<MaterialCommunityIcons
name="message-text-outline"
size={12}
color={colors.iconSecondary}
name="alert-circle-outline"
size={14}
color={colors.textWarning}
style={styles.rumorIcon}
/>
<Text style={[styles.noteText, { color: colors.textSecondary }]}>{uwasa}</Text>
<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 && (
@@ -978,7 +1263,7 @@ type SourceCardProps = {
color: string;
title: string;
label: string;
sub?: string;
sub?: string | React.ReactNode;
badge: number | string | null;
badgeColor: string;
disabled?: boolean;
@@ -1030,9 +1315,13 @@ const SourceCard: FC<SourceCardProps> = ({
<Text style={[styles.labelText, { color: colors.textQuaternary }]}>{label}</Text>
</View>
{sub && (
<Text style={[styles.subText, { color: colors.textSecondary }]} numberOfLines={1}>
{sub}
</Text>
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>
@@ -1195,6 +1484,14 @@ const styles = StyleSheet.create({
subText: {
fontSize: 12,
},
routePagerText: {
fontSize: 11,
fontWeight: "600",
marginBottom: 4,
},
subNodeWrap: {
marginTop: 2,
},
subTextDisabled: {
color: "#bbb",
},
@@ -1227,6 +1524,9 @@ const styles = StyleSheet.create({
color: "#0077aa",
letterSpacing: 0.5,
},
elesiteFormationWrap: {
marginBottom: 4,
},
footer: {
height: 20,
},
@@ -1302,15 +1602,35 @@ const styles = StyleSheet.create({
fontSize: 11,
},
noteSection: {
gap: 4,
borderLeftWidth: 2,
paddingLeft: 8,
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,
@@ -19,9 +19,30 @@ import { AS } from "@/storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useThemeColors } from "@/lib/theme";
import { useSheetMaxHeight } from "./useSheetMaxHeight";
const toAndroidAliasIconName = (id: string) =>
id
.split(/[^a-zA-Z0-9]+/)
.filter(Boolean)
.map((part, index) =>
index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)
)
.join("");
const findCurrentIconItem = (
iconList: Array<{ id: string; name: string; icon: number }>,
currentIcon: string | null
) =>
iconList.find(
({ id }) =>
id === currentIcon ||
(Platform.OS === "android" && toAndroidAliasIconName(id) === currentIcon)
) ?? null;
export const TrainIconUpdate = () => {
const [iconList] = useState(icons());
const [currentIcon] = useState(getAppIconName());
const currentIconItem = findCurrentIconItem(iconList, currentIcon);
const actionSheetRef = useRef(null);
const insets = useSafeAreaInsets();
const viewShot = useRef(null);
@@ -93,7 +114,7 @@ export const TrainIconUpdate = () => {
</Text>
<View style={{ flex: 1 }} />
</View>
{currentIcon ? (
{currentIconItem ? (
<View
style={{
flexDirection: "column",
@@ -114,7 +135,7 @@ export const TrainIconUpdate = () => {
}}
>
<Image
source={iconList.filter(({ id }) => id == currentIcon)[0].icon}
source={currentIconItem.icon}
style={{ width: 80, height: 80, borderRadius: 8 }}
resizeMode="contain"
/>
+69 -50
View File
@@ -1,5 +1,6 @@
import React, { useState, useRef, FC } from "react";
import {
Animated,
View,
Text,
TouchableOpacity,
@@ -7,7 +8,6 @@ import {
TextInput,
ScrollView,
Linking,
Image,
} from "react-native";
import { useAllTrainDiagram } from "../stateBox/useAllTrainDiagram";
import { useBottomTabBarHeight } from "@react-navigation/bottom-tabs";
@@ -23,10 +23,18 @@ import { Switch } from "@rneui/themed";
import { migrateTrainName } from "@/lib/eachTrainInfoCoreLib/migrateTrainName";
import { OneManText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/OneManText";
import { getStringConfig } from "@/lib/getStringConfig";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
import { InfogramText } from "./ActionSheetComponents/EachTrainInfoCore/HeaderTextParts/InfogramText";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import { TrainIconStack } from "./ActionSheetComponents/EachTrainInfoCore/TrainIconStack";
import { formatWrappedTrainType, shouldWrapTrainType } from "@/lib/trainTypeWrap";
export const AllTrainDiagramView: FC = () => {
const { colors, fixed } = useThemeColors();
const { goBack, navigate } = useNavigation<any>();
const { iconSetting } = useTrainMenu();
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
const tabBarHeight = useBottomTabBarHeight();
const {
keyList,
@@ -38,7 +46,7 @@ export const AllTrainDiagramView: FC = () => {
const [useStationName, setUseStationName] = useState(false);
const [useRegex, setUseRegex] = useState(false);
const containerRef = useRef<View>(null);
const { keyboardVisible: keyBoardVisible, measuredOffset: measuredPadding } =
const { keyboardVisible: keyBoardVisible, animatedOffset } =
useKeyboardAvoid({ measureRef: containerRef, tabBarHeight });
const regexTextStyle = {
color: fixed.textOnPrimary,
@@ -79,12 +87,28 @@ export const AllTrainDiagramView: FC = () => {
openTrainInfo: (d: string) => void;
};
const Item: FC<ItemProps> = ({ id, openTrainInfo }) => {
const { train_info_img, train_name, type, train_num_distance, to_data } =
customTrainDataDetector(id, allCustomTrainData);
const todayOperation = getTodayOperationByTrainId(id).filter(d=> d.state !== 100);
const [isWrapped, setIsWrapped] = useState(false);
const [typeString, fontAvailable, isOneMan] = getStringConfig(type, id);
const customTrainData = customTrainDataDetector(id, allCustomTrainData);
const {
train_name,
type,
train_num_distance,
to_data,
infogram,
directions,
} = customTrainData;
const todayOperation = (getTodayOperationByTrainId(id) ?? []).filter(d=> d.state !== 100);
const trainIconData = resolveTrainIconEntries({
trainNum: id,
customTrainData,
todayOperation,
iconDisplayMode,
});
let iconTrainDirection =
parseInt(id.replace(/[^\d]/g, "")) % 2 == 0 ? true : false;
if (directions != undefined) {
iconTrainDirection = directions ? true : false;
}
const [typeString, fontFamily, isOneMan] = getStringConfig(type, id);
const trainNameString = (() => {
switch (true) {
@@ -109,6 +133,7 @@ export const AllTrainDiagramView: FC = () => {
return migrateTrainName(hoge + "行き");
}
})();
const wrapType = shouldWrapTrainType(typeString, trainNameString);
return (
<TouchableOpacity
style={{
@@ -123,29 +148,11 @@ export const AllTrainDiagramView: FC = () => {
onPress={() => openTrainInfo(id)}
>
<View style={{ marginHorizontal: 5, flexDirection: "row" }}>
{todayOperation.length > 0
? todayOperation.map((operation, index) => (
<Image
key={index}
source={{ uri: operation.vehicle_img || train_info_img }}
style={{
width: 20,
height: 22,
marginHorizontal: 2,
display: index == 0 ? "flex" : "none", //暫定対応:複数アイコンがある場合は最初のアイコンのみ表示
}}
/>
))
: train_info_img && (
<Image
source={{ uri: train_info_img }}
style={{
width: 20,
height: 22,
marginHorizontal: 2,
}}
/>
)}
<TrainIconStack
entries={trainIconData}
direction={iconTrainDirection}
variant="list"
/>
</View>
<View
@@ -157,37 +164,49 @@ export const AllTrainDiagramView: FC = () => {
>
{typeString && (
<Text
numberOfLines={wrapType ? 2 : 1}
style={{
fontSize: 20,
color: fixed.textOnPrimary,
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
fontFamily,
fontWeight: fontFamily ? undefined : "bold",
marginRight: 5,
flexShrink: 0,
...(wrapType
? { width: 44, lineHeight: 22 }
: {}),
}}
>
{isWrapped
? typeString.replace(/(.{2})/g, "$1\n").trim()
: typeString}
{wrapType ? formatWrappedTrainType(typeString) : typeString}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text>
)}
{isOneMan && <OneManText />}
</View>
{trainNameString && (
<Text
{(trainNameString || infogram) && (
<View
style={{
fontSize: 20,
fontWeight: "bold",
color: fixed.textOnPrimary,
flexDirection: "row",
alignItems: "center",
flexShrink: 1,
}}
onTextLayout={(e) => {
if (e.nativeEvent.lines.length > 1) {
setIsWrapped(true);
}
}}
>
{trainNameString}
</Text>
{trainNameString && (
<Text
style={{
fontSize: 20,
fontWeight: "bold",
color: fixed.textOnPrimary,
flexShrink: 1,
}}
>
{trainNameString}
</Text>
)}
{infogram ? <InfogramText infogram={infogram} fontSize={18} /> : null}
</View>
)}
<View style={{ flex: 1 }} />
<Text style={{ fontSize: 20, fontWeight: "bold", color: fixed.textOnPrimary }}>
@@ -197,7 +216,7 @@ export const AllTrainDiagramView: FC = () => {
);
};
return (
<View ref={containerRef} style={{ flex: 1, backgroundColor: fixed.primary, paddingBottom: measuredPadding }}>
<Animated.View ref={containerRef} style={{ flex: 1, backgroundColor: fixed.primary, paddingBottom: animatedOffset }}>
<FlatList
contentContainerStyle={{ justifyContent: "flex-end", flexGrow: 1 }}
style={{ flex: 1 }}
@@ -358,6 +377,6 @@ export const AllTrainDiagramView: FC = () => {
string="閉じる"
style={{ display: keyBoardVisible ? "none" : "flex" }}
/>
</View>
</Animated.View>
);
};
+279 -32
View File
@@ -1,10 +1,12 @@
import React from "react";
import { InteractionManager } from "react-native";
import {
View,
Platform,
useWindowDimensions,
StatusBar,
useColorScheme,
Button,
} from "react-native";
import * as Updates from "expo-updates";
import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -16,14 +18,18 @@ import { useCurrentTrain } from "../stateBox/useCurrentTrain";
import { useDeviceOrientationChange } from "../stateBox/useDeviceOrientationChange";
import { SheetManager } from "react-native-actions-sheet";
import { useNavigation } from "@react-navigation/native";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import { useTrainMenu } from "../stateBox/useTrainMenu";
import { AppsWebView } from "./Apps/WebView";
import { NewMenu } from "./Apps/NewMenu";
import { MapsButton } from "./Apps/MapsButton";
import { ReloadButton } from "./Apps/ReloadButton";
import { useStationList } from "../stateBox/useStationList";
import { positionsLifecycleRef } from "../lib/rootNavigation";
import { FixedPositionBox } from "./Apps/FixedPositionBox";
import { PlaybackTimeline } from "./Apps/PlaybackTimeline";
import { RecordingStatusBar } from "./Apps/RecordingStatusBar";
import * as Sentry from "@sentry/react-native";
export default function Apps() {
const { webview, fixedPosition, setFixedPosition } = useCurrentTrain();
@@ -31,10 +37,15 @@ export default function Apps() {
const { navigate } = useNavigation<any>();
const { isLandscape } = useDeviceOrientationChange();
const { top } = useSafeAreaInsets();
const handleLayout = () => {};
const { originalStationList } = useStationList();
const { mapSwitch, trainInfo, setTrainInfo, selectedLine } = useTrainMenu();
const isDark = useColorScheme() === "dark";
const isFocused = useIsFocused();
const [hasActivatedScreen, setHasActivatedScreen] = React.useState(false);
const [hasActivatedWebView, setHasActivatedWebView] = React.useState(false);
const [hasStableWebViewSession, setHasStableWebViewSession] = React.useState(false);
const lastFocusSignatureRef = React.useRef("");
const lastLayoutSignatureRef = React.useRef("");
const lineColor = selectedLine && stationIDPair[selectedLine]
? lineColorList[stationIDPair[selectedLine]]
@@ -81,20 +92,220 @@ export default function Apps() {
}
};
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
const resetTransientPositionsSession = React.useCallback(() => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: hasStableWebViewSession
? "positions stable session preserved before leave"
: "positions transient session reset before leave",
data: {
stableWebViewSession: hasStableWebViewSession,
},
});
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
isUnstable: false,
blockTabExit: false,
};
if (hasStableWebViewSession) {
return;
}
try {
webview.current?.stopLoading?.();
} catch {}
setHasActivatedWebView(false);
setHasActivatedScreen(false);
setHasStableWebViewSession(false);
}, [hasStableWebViewSession, webview]);
React.useEffect(() => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen mounted",
});
return () => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen unmounted",
});
};
}, []);
React.useEffect(() => {
if (!isFocused) {
if (!hasActivatedWebView && !hasStableWebViewSession) {
// allow one hidden startup prewarm so the positions session can begin before first tab focus
} else {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions webview preserved on blur",
data: {
stableWebViewSession: hasStableWebViewSession,
activatedWebView: hasActivatedWebView,
},
});
return;
}
}
if (hasActivatedScreen && hasActivatedWebView) return;
const activationReason = isFocused
? (hasActivatedScreen ? "focus" : "first_focus")
: "background_prewarm";
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen activation scheduled",
data: {
reason: activationReason,
},
});
let cancelled = false;
const activate = () => {
if (cancelled) {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen activation cancelled before webview mount",
data: {
reason: activationReason,
},
});
return;
}
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions screen activation committed",
data: {
reason: activationReason,
},
});
setHasActivatedScreen(true);
setHasActivatedWebView(true);
};
if (isFocused) {
const task = InteractionManager.runAfterInteractions(() => {
setTimeout(activate, 250);
});
return () => {
cancelled = true;
task.cancel?.();
};
}
const timer = setTimeout(activate, 350);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, isFocused]);
React.useEffect(() => {
const shouldGuardTabExit = isFocused && hasActivatedWebView && !hasStableWebViewSession;
positionsLifecycleRef.current = {
isUnstable: shouldGuardTabExit,
blockTabExit: shouldGuardTabExit,
resetBeforeLeave: resetTransientPositionsSession,
deactivateStack: positionsLifecycleRef.current.deactivateStack,
};
return () => {
positionsLifecycleRef.current = {
isUnstable: false,
blockTabExit: false,
resetBeforeLeave: null,
deactivateStack: positionsLifecycleRef.current.deactivateStack,
};
};
}, [hasActivatedWebView, hasStableWebViewSession, isFocused, resetTransientPositionsSession]);
React.useEffect(() => {
Sentry.setContext("positions_screen", {
focused: isFocused,
activatedScreen: hasActivatedScreen,
activatedWebView: hasActivatedWebView,
stableWebViewSession: hasStableWebViewSession,
mapSwitch,
selectedLine: selectedLine ?? null,
trainNum: trainInfo?.trainNum ?? null,
landscape: isLandscape,
width,
height,
});
}, [hasActivatedScreen, hasActivatedWebView, hasStableWebViewSession, height, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum, width]);
React.useEffect(() => {
const signature = JSON.stringify({
focused: isFocused,
mapSwitch,
selectedLine: selectedLine ?? null,
trainNum: trainInfo?.trainNum ?? null,
landscape: isLandscape,
});
if (lastFocusSignatureRef.current === signature) return;
lastFocusSignatureRef.current = signature;
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: isFocused ? "positions focused" : "positions blurred",
data: {
mapSwitch,
selectedLine: selectedLine ?? null,
trainNum: trainInfo?.trainNum ?? null,
landscape: isLandscape,
},
});
}, [hasActivatedWebView, isFocused, isLandscape, mapSwitch, selectedLine, trainInfo?.trainNum]);
const handleLayout = React.useCallback((event) => {
const layout = event?.nativeEvent?.layout;
if (!layout) return;
const signature = [Math.round(layout.width), Math.round(layout.height), isLandscape ? "landscape" : "portrait"].join(":");
if (lastLayoutSignatureRef.current === signature) return;
lastLayoutSignatureRef.current = signature;
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions layout",
data: {
width: Math.round(layout.width),
height: Math.round(layout.height),
landscape: isLandscape,
},
});
}, [isLandscape]);
return (
<View style={{ flex: 1, backgroundColor: bgColor }}>
{lineColor && lineColorDark && (
<LinearGradient
colors={[lineColorDark, lineColor]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
{isFocused && (
<View
pointerEvents="none"
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
/>
>
{mapSwitch === "true" && lineColor && lineColorDark ? (
<LinearGradient
colors={[lineColorDark, lineColor]}
start={{ x: 0, y: 0 }}
end={{ x: 0, y: 1 }}
style={{ flex: 1 }}
/>
) : (
<View
style={{
flex: 1,
backgroundColor: "#0099CC",
}}
/>
)}
</View>
)}
{lineColor && (
<StatusBar
barStyle="light-content"
/>
{isFocused && hasActivatedScreen && (
<StatusBar barStyle="light-content" />
)}
<View
style={{
@@ -104,28 +315,64 @@ export default function Apps() {
}}
onLayout={handleLayout}
>
<AppsWebView
{...{
openStationACFromEachTrainInfo,
}}
/>
{hasActivatedScreen ? (
<>
{hasActivatedWebView ? (
<AppsWebView
openStationACFromEachTrainInfo={openStationACFromEachTrainInfo}
onInitialLoadReady={() => {
if (hasStableWebViewSession) return;
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "positions webview became stable",
});
positionsLifecycleRef.current = {
...positionsLifecycleRef.current,
isUnstable: false,
blockTabExit: false,
};
setHasStableWebViewSession(true);
}}
/>
) : (
<View style={{ flex: 1 }} />
)}
<MapsButton
onPress={() => {
navigate("trainMenu", { webview });
}}
/>
{isFocused && (
<MapsButton
onPress={() => {
Sentry.addBreadcrumb({
category: "positions.screen",
level: "info",
message: "maps button press",
data: {
mapSwitch,
selectedLine: selectedLine ?? null,
hasActivatedWebView,
},
});
navigate("trainMenu", { webview });
}}
/>
)}
{fixedPosition.type && <FixedPositionBox />}
{mapSwitch == "true" ? (
<ReloadButton
onPress={() => Updates.reloadAsync()}
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
/>
) : (
<NewMenu />
)}
{isFocused && fixedPosition.type && <FixedPositionBox />}
{isFocused && <PlaybackTimeline />}
{isFocused && <RecordingStatusBar />}
{isFocused &&
(mapSwitch == "true" ? (
<ReloadButton
onPress={() => Updates.reloadAsync()}
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
/>
) : (
<NewMenu />
))}
</>
) : (
<View style={{ flex: 1 }} />
)}
</View>
</View>
);
@@ -58,7 +58,7 @@ type props = {
export const FixedStation: FC<props> = ({ stationID }) => {
const { colors, fixed } = useThemeColors();
const { mapSwitch } = useTrainMenu();
const { mapSwitch, playbackCurrentTimeIso } = useTrainMenu();
const {
currentTrain,
fixedPosition,
@@ -151,11 +151,11 @@ export const FixedStation: FC<props> = ({ stationID }) => {
if (!currentTrain) return () => {};
const data = trainTimeAndNumber
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
.filter((d) => !d.isThrough)
.filter((d) => d.lastStation != station[0].Station_JP); //最終列車表示設定
setSelectedTrain(data);
}, [trainTimeAndNumber, currentTrain, tick /*liveActivity periodic refresh*/]);
}, [trainTimeAndNumber, currentTrain, stationList, playbackCurrentTimeIso, tick /*liveActivity periodic refresh*/]);
useEffect(() => {
liveNotifyIdRef.current = liveNotifyId;
@@ -332,9 +332,10 @@ export const FixedStation: FC<props> = ({ stationID }) => {
}, [selectedTrain, currentTrain, liveNotifyId, buildTrainsInfo]);
// バナー表示と同時にLive Activityを自動開始(selectedTrainが揃ってから)
// iOSのみ一時的に無効化中(Androidは有効)
useEffect(() => {
if (station.length === 0 || hasStartedRef.current || liveNotifyId) return;
if (!isLiveActivityAvailable()) return;
// iOSのLive Activityは無効化
if (Platform.OS === 'ios') return;
hasStartedRef.current = true;
const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
+283 -119
View File
@@ -26,6 +26,9 @@ import { Ionicons } from "@expo/vector-icons";
import dayjs from "dayjs";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import { normalizeIconDisplayMode } from "@/lib/iconDisplayMode";
import { resolveTrainDataIcon } from "@/lib/trainDataIcon";
import { resolveTrainIconEntries } from "@/lib/trainIconEntries";
import {
startTrainFollowActivity,
updateTrainFollowActivity,
@@ -37,6 +40,45 @@ type props = {
trainID: string;
};
type TrainPathEntry = {
raw: string;
station: string;
se: string;
time: string;
index: number;
isThrough: boolean;
hasTime: boolean;
};
const normalizeTrainPosLabel = (value: string) =>
value
.replace(
/(下り)|(上り)|\(下り\)|\(上り\)|(徳島線)|(高徳線)|(坂出方)|(児島方)/g,
""
)
.trim();
const isHiddenThroughPointEntry = (raw: string) => {
const [station = "", se = ""] = (raw || "").split(",");
return station.startsWith(".") && se.includes("通");
};
const calcDistanceMinute = (
time: string,
delayTime: number,
playbackCurrentTimeIso?: string | null
) => {
if (!time || time === "") return null;
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const hour = parseInt(time.split(":")[0], 10);
const target = now
.hour(hour < 4 ? hour + 24 : hour)
.minute(parseInt(time.split(":")[1], 10));
let diff = target.diff(now, "minute") + delayTime;
if (now.hour() < 4 && hour < 4) diff -= 1440;
return diff;
};
export const FixedTrain: FC<props> = ({ trainID }) => {
const { colors, fixed } = useThemeColors();
const {
@@ -48,8 +90,10 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setFixedPositionSize,
} = useCurrentTrain();
const { mapSwitch } = useTrainMenu();
const { allCustomTrainData, allTrainDiagram } = useAllTrainDiagram();
const { mapSwitch, iconSetting, playbackCurrentTimeIso } = useTrainMenu();
const { allCustomTrainData, allTrainDiagram, getTodayOperationByTrainId } =
useAllTrainDiagram();
const iconDisplayMode = normalizeIconDisplayMode(iconSetting);
const [liveNotifyId, setLiveNotifyId] = useState<string | null>(null);
const liveNotifyIdRef = useRef<string | null>(null);
@@ -59,11 +103,28 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
const [customData, setCustomData] = useState<CustomTrainData>(
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
);
const todayOperation = useMemo(
() => (getTodayOperationByTrainId(trainID) ?? []).filter((d) => d.state !== 100),
[getTodayOperationByTrainId, trainID]
);
const customTrainIcon = useMemo(() => {
const iconEntry = resolveTrainIconEntries({
trainNum: trainID,
customTrainData: customData,
todayOperation,
iconDisplayMode,
})[0];
return (
iconEntry?.vehicle_info_img ||
resolveTrainDataIcon(customData, iconDisplayMode)
);
}, [customData, iconDisplayMode, todayOperation, trainID]);
useEffect(() => {
setCustomData(
getCurrentTrainData(trainID, currentTrain, allCustomTrainData)
);
}, [currentTrain, trainID]);
}, [currentTrain, trainID, allCustomTrainData]);
useEffect(() => {
const stationData = getCurrentStationData(trainID);
if (stationData) {
@@ -85,8 +146,11 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
);
const computedTrainDataWithThrough = useMemo(() => {
const trainData = allTrainDiagram[trainID]?.split("#");
if (!trainData) return [];
const trainData =
allTrainDiagram[trainID]
?.split("#")
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
if (trainData.length === 0) return [];
// 駅名ごとに駅情報を集約するマップを構築
const stationByNameMap = new Map();
@@ -132,6 +196,8 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
originalStationList[
lineListPair[stationIDPair[betweenStationLine]]
].forEach((d) => {
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
if (isHiddenPassPoint) return;
if (
d.StationNumber > baseStationNumberFirst &&
d.StationNumber < baseStationNumberSecond
@@ -189,6 +255,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setStopStationList(computedStopStationIDList);
}, [computedStopStationIDList]);
const [currentPosition, setCurrentPosition] = useState<string[]>([]);
const parsedTrainPath = useMemo<TrainPathEntry[]>(
() =>
trainDataWidhThrough.map((raw, index) => {
const [station = "", se = "", time = ""] = (raw || "").split(",");
return {
raw,
station,
se,
time,
index,
isThrough: se.includes("通"),
hasTime: time !== "",
};
}),
[trainDataWidhThrough]
);
useEffect(() => {
let position = getPosition(train);
@@ -223,96 +305,127 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
}
}, [train, stopStationIDList]);
const [nextStationData, setNextStationData] = useState<StationProps[]>([]);
const [untilStationData, setUntilStationData] = useState<StationProps[]>([]);
const [currentPointData, setCurrentPointData] = useState<StationProps[]>([]);
const [nextStopStationData, setNextStopStationData] = useState<StationProps[]>([]);
const [untilStationData, setUntilStationData] = useState<string[]>([]);
const [probably, setProbably] = useState(false);
const [isCurrentPointDisplay, setIsCurrentPointDisplay] = useState(false);
const [currentDisplayIndex, setCurrentDisplayIndex] = useState(0);
const [tick, setTick] = useState(0);
useEffect(() => {
if (playbackCurrentTimeIso) return;
const interval = setInterval(() => setTick((value) => value + 1), 15000);
return () => clearInterval(interval);
}, [playbackCurrentTimeIso]);
useEffect(() => {
//棒線駅判定を入れて、棒線駅なら時間を見て分数がマイナスならcontinue;
const points = findReversalPoints(currentPosition, stopStationIDList);
if (!points) return;
if (points.length == 0) return;
let searchCountFirst = points.findIndex((d) => d == true);
let searchCountLast = points.findLastIndex((d) => d == true);
if (!points || points.length === 0) return;
const delayTime = train?.delay == "入線" ? 0 : train?.delay;
let additionalSkipCount = 0;
const pointIndexes = points.reduce(
(acc: number[], isCurrent, index) => {
if (isCurrent) acc.push(index);
return acc;
},
[] as number[]
);
if (!pointIndexes.length) return;
// 2駅間走行中の場合: ダイヤ順で後ろのインデックスが進行方向の駅
// Direction に関係なく、travel-order で大きい方が「向かう駅」
let searchStart: number;
if (currentPosition.length === 2) {
const idx0 = stopStationIDList.findIndex(d => d.includes(currentPosition[0]));
const idx1 = stopStationIDList.findIndex(d => d.includes(currentPosition[1]));
const aheadIdx = Math.max(
idx0 >= 0 ? idx0 : -1,
idx1 >= 0 ? idx1 : -1
);
searchStart = aheadIdx >= 0 ? aheadIdx : searchCountLast;
} else {
searchStart = searchCountFirst;
}
const firstMatchedIndex = pointIndexes[0];
const isCurrentStationCluster = currentPosition.length <= 1;
const delayTime =
train?.delay == "入線" ? 0 : parseInt(String(train?.delay), 10) || 0;
for (
let searchCount = searchStart;
searchCount < trainDataWidhThrough.length;
searchCount++
) {
const nextPos = trainDataWidhThrough[searchCount];
let anchorIndex = firstMatchedIndex;
let usedTimeEstimation = false;
const shouldShowCurrentPoint = isCurrentStationCluster;
const hasIntermediateCurrentPoints = pointIndexes.length > 1;
if (nextPos) {
const [station, se, time] = nextPos.split(",");
if (!isCurrentStationCluster && hasIntermediateCurrentPoints) {
let upcomingTimedIndex = -1;
let lastPassedTimedIndex = -1;
// 通過駅はスキップ
if (se.includes("通")) {
for (
let searchCount = firstMatchedIndex;
searchCount < parsedTrainPath.length;
searchCount++
) {
const entry = parsedTrainPath[searchCount];
if (!entry?.raw || !entry.hasTime) continue;
const distanceMinute = calcDistanceMinute(
entry.time,
delayTime,
playbackCurrentTimeIso
);
if (distanceMinute == null) continue;
if (distanceMinute < 0) {
lastPassedTimedIndex = searchCount;
continue;
}
// 駅に停車中(1点一致)の場合は時刻判定不要
if (searchCountFirst == searchCountLast) {
setNextStationData(getStationDataFromName(station));
break;
}
// 2駅間走行中: 時刻で既に通過済みか判定
let distanceMinute = 0;
if (time != "") {
const now = dayjs();
const hour = parseInt(time.split(":")[0]);
const distanceTime = now
.hour(hour < 4 ? hour + 24 : hour)
.minute(parseInt(time.split(":")[1]));
distanceMinute = distanceTime.diff(now, "minute") + delayTime;
if (now.hour() < 4) {
if (hour < 4) {
distanceMinute = distanceMinute - 1440;
}
}
}
if (distanceMinute >= 0) {
setNextStationData(getStationDataFromName(station));
break;
} else {
additionalSkipCount++;
continue;
}
upcomingTimedIndex = searchCount;
break;
}
const baseIndex =
lastPassedTimedIndex >= 0 ? lastPassedTimedIndex + 1 : firstMatchedIndex;
if (upcomingTimedIndex >= 0) {
const hasUntimedGapBeforeUpcoming = parsedTrainPath
.slice(baseIndex, upcomingTimedIndex)
.some((entry) => entry?.raw && !entry.hasTime);
anchorIndex = hasUntimedGapBeforeUpcoming ? baseIndex : upcomingTimedIndex;
} else if (lastPassedTimedIndex >= 0) {
const lastPassedEntry = parsedTrainPath[lastPassedTimedIndex];
anchorIndex = lastPassedEntry?.isThrough
? Math.min(lastPassedTimedIndex + 1, parsedTrainPath.length - 1)
: lastPassedTimedIndex;
}
usedTimeEstimation = anchorIndex !== firstMatchedIndex;
}
let trainList = [];
for (
let searchCount = searchCountFirst - 1;
searchCount < points.length;
searchCount++
) {
trainList.push(trainDataWidhThrough[searchCount]);
}
if (additionalSkipCount > 0) {
trainList = trainList.slice(additionalSkipCount);
setProbably(true);
} else {
setProbably(false);
}
const anchorEntry = parsedTrainPath[anchorIndex];
const currentPointName = anchorEntry?.station || "";
setCurrentPointData(
currentPointName ? getStationDataFromName(currentPointName) : []
);
const nextStopSearchStart = shouldShowCurrentPoint
? anchorIndex + 1
: anchorIndex;
const nextStopEntry = parsedTrainPath.find(
(entry, index) =>
index >= nextStopSearchStart && !!entry?.station && !entry.isThrough
);
const nextStopName = nextStopEntry?.station || "";
setNextStopStationData(
nextStopName ? getStationDataFromName(nextStopName) : []
);
const visibleStart = Math.max(anchorIndex - 1, 0);
const trainList = parsedTrainPath
.slice(visibleStart)
.map((entry) => entry.raw)
.filter((entry) => !!entry);
setProbably(usedTimeEstimation);
setIsCurrentPointDisplay(shouldShowCurrentPoint);
setCurrentDisplayIndex(Math.max(anchorIndex - visibleStart, 0));
setUntilStationData(trainList);
}, [currentPosition, trainDataWidhThrough]);
}, [
currentPosition,
parsedTrainPath,
playbackCurrentTimeIso,
stopStationIDList,
tick,
train?.delay,
getStationDataFromName,
]);
const [ToData, setToData] = useState("");
useEffect(() => {
if (customData.to_data && customData.to_data != "") {
@@ -331,7 +444,9 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
setStation(data);
}, [ToData]);
const lineColor =
station.length > 0
customData.to_data_color && customData.to_data_color.length > 0
? customData.to_data_color[0]
: station.length > 0
? lineColorList[station[0]?.StationNumber?.slice(0, 1)]
: "black";
//const lineColor = "red";
@@ -351,7 +466,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
(last: number, d: string, i: number) => (d ? i : last), -1
);
const filteredTrainData = trainDataWidhThrough.filter((d, idx) => {
if (!d) return false;
if (!d || isHiddenThroughPointEntry(d)) return false;
const [, se] = d.split(",");
if (!se) return true;
// 着を含み発を含まないエントリは終着駅のみ許可
@@ -403,13 +518,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
};
});
const shouldShowCurrentLabel =
isCurrentPointDisplay || currentPointData[0]?.Station_JP === train?.Pos;
const currentStationLabel = shouldShowCurrentLabel
? currentPointData[0]?.Station_JP || train?.Pos || ""
: train?.Pos || "";
const bannerStationData = shouldShowCurrentLabel
? currentPointData
: nextStopStationData;
// 全駅リスト中の現在地インデックス
const currentStationIndex = (() => {
const pos = train?.Pos || "";
const pos = currentStationLabel;
if (!pos) return 0;
// Pos は "駅名" (駅にいる時) or "駅A~駅B" (走行中) の形式
const posStations = pos.split("").map((s: string) =>
s.replace(/(下り)|(上り)|\(下り\)|\(上り\)/g, "").trim()
normalizeTrainPosLabel(s)
);
// 完全一致
const firstIdx = allStations.findIndex((s) => s.name === posStations[0]);
@@ -423,7 +547,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
})();
const nextStationIndex = (() => {
const name = nextStationData[0]?.Station_JP;
const name = nextStopStationData[0]?.Station_JP;
if (!name) return -1;
const idx = stationStops.indexOf(name);
if (idx >= 0) return idx;
@@ -448,11 +572,10 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
if (!liveNotifyId || !train) return;
const delayNum = train.delay === "入線" ? 0 : parseInt(String(train.delay)) || 0;
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
const positionStatus =
nextStationData[0]?.Station_JP === train.Pos ? "ただいま" : "次は";
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
updateTrainFollowActivity(liveNotifyId, {
currentStation: train.Pos || "",
nextStation: nextStationData[0]?.Station_JP || "",
currentStation: currentStationLabel,
nextStation: nextStopStationData[0]?.Station_JP || "",
delayMinutes: delayNum,
scheduledArrival: "",
trainNumber: trainID,
@@ -468,12 +591,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
allStations,
currentStationIndex,
}).catch(() => {});
}, [train, nextStationData, liveNotifyId, stationStops, nextStationIndex, currentStationIndex]);
}, [
train,
nextStopStationData,
liveNotifyId,
stationStops,
nextStationIndex,
currentStationIndex,
currentStationLabel,
shouldShowCurrentLabel,
]);
// バナー表示と同時にLive Activityを自動開始
// iOSのみ一時的に無効化中(Androidは有効)
useEffect(() => {
if (!train || hasStartedRef.current || liveNotifyId) return;
if (!isLiveActivityAvailable()) return;
// iOSのLive Activityは無効化
if (Platform.OS === 'ios') return;
hasStartedRef.current = true;
const startActivity = async () => {
if (Platform.OS === 'android' && Platform.Version >= 33) {
@@ -484,15 +617,14 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
}
const delayNum = train?.delay === "入線" ? 0 : parseInt(String(train?.delay)) || 0;
const delayStatus = delayNum > 0 ? `${delayNum}分遅れ` : "定刻";
const positionStatus =
nextStationData[0]?.Station_JP === train?.Pos ? "ただいま" : "次は";
const positionStatus = shouldShowCurrentLabel ? "ただいま" : "次は";
try {
const id = await startTrainFollowActivity({
trainNumber: trainID,
lineName: "",
destination: ToData,
currentStation: train?.Pos || "",
nextStation: nextStationData[0]?.Station_JP || "",
currentStation: currentStationLabel,
nextStation: nextStopStationData[0]?.Station_JP || "",
delayMinutes: delayNum,
scheduledArrival: "",
trainType: customTrainType.shortName,
@@ -512,7 +644,22 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
}
};
startActivity();
}, [train]);
}, [
train,
nextStopStationData,
currentStationLabel,
shouldShowCurrentLabel,
ToData,
trainID,
customTrainType.shortName,
trainNameText,
customTrainType.color,
lineColor,
stationStops,
nextStationIndex,
allStations,
currentStationIndex,
]);
return (
<View
@@ -554,7 +701,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
}}
>
<Image
source={{ uri: customData.train_info_img || "" }}
source={{ uri: customTrainIcon }}
width={fixedPositionSize === 226 ? 23 : 14}
height={fixedPositionSize === 226 ? 26 : 17}
style={{ margin: 5 }}
@@ -571,19 +718,18 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
<Text
style={{
fontSize: trainNameText.length > 4 ? 12 : 14,
fontFamily: customTrainType.fontAvailable
? "JR-Nishi"
: undefined,
fontWeight: !customTrainType.fontAvailable
? "bold"
: undefined,
marginTop: customTrainType.fontAvailable ? 3 : 0,
fontFamily: customTrainType.fontFamily,
fontWeight: customTrainType.fontFamily ? undefined : "bold",
marginTop: customTrainType.fontFamily ? 3 : 0,
color: fixed.textOnPrimary,
textAlignVertical: "center",
textAlign: "left",
}}
>
{customTrainType.shortName}
{customTrainType.fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text>
{customData.train_name && (
<Text
@@ -688,9 +834,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
marginVertical: -1,
}}
>
{nextStationData[0]?.Station_JP == train?.Pos
? "ただいま"
: "次は"}
{shouldShowCurrentLabel ? "ただいま" : "次は"}
</Text>
{probably && (
<Text
@@ -708,7 +852,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
)}
</View>
<StationNumberMaker
currentStation={nextStationData}
currentStation={bannerStationData}
singleSize={20}
useEach={true}
/>
@@ -720,7 +864,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
flex: 1,
}}
>
{nextStationData[0]?.Station_JP || "不明"}
{bannerStationData[0]?.Station_JP || "不明"}
</Text>
{fixedPositionSize !== 226 && (
<View
@@ -744,6 +888,7 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
train={train}
lineColor={lineColor}
trainDataWithThrough={untilStationData}
currentDisplayIndex={currentDisplayIndex}
isSmall={fixedPositionSize !== 226}
/>
</View>
@@ -870,6 +1015,7 @@ const CurrentPositionBox = ({
train,
lineColor,
trainDataWithThrough,
currentDisplayIndex,
isSmall,
}) => {
const { colors } = useThemeColors();
@@ -879,13 +1025,13 @@ const CurrentPositionBox = ({
const { isBetween, Pos: PosData } = trainPosition(train);
if (isBetween === true) {
const { from, to } = PosData;
firstText = from;
secondText = to;
firstText = normalizeTrainPosLabel(from);
secondText = normalizeTrainPosLabel(to);
marginText = "→";
} else {
const { Pos } = PosData;
if (Pos !== "") {
firstText = Pos;
firstText = normalizeTrainPosLabel(Pos);
}
}
const delayTime = train?.delay == "入線" ? 0 : parseInt(train?.delay);
@@ -938,6 +1084,7 @@ const CurrentPositionBox = ({
(() => {
// 着→発ペアを同一駅で統合(EachStopListと同様)
const merged: { d: string; arrivalTime: string | null }[] = [];
let mergedCurrentDisplayIndex = Math.max(currentDisplayIndex, 0);
for (let i = 0; i < trainDataWithThrough.length; i++) {
const d = trainDataWithThrough[i];
if (!d) continue;
@@ -958,11 +1105,17 @@ const CurrentPositionBox = ({
const [prevSt, prevSe, prevTime] = prev.split(",");
if (prevSt === st && prevSe?.includes("着")) {
merged.push({ d, arrivalTime: prevTime });
if (i === currentDisplayIndex || i - 1 === currentDisplayIndex) {
mergedCurrentDisplayIndex = merged.length - 1;
}
continue;
}
}
}
merged.push({ d, arrivalTime: null });
if (i === currentDisplayIndex) {
mergedCurrentDisplayIndex = merged.length - 1;
}
}
return merged.map(({ d, arrivalTime }, index) => (
<EachStopData
@@ -972,6 +1125,7 @@ const CurrentPositionBox = ({
delayTime={delayTime}
isSmall={isSmall}
secondText={secondText}
currentDisplayIndex={mergedCurrentDisplayIndex}
arrivalTime={arrivalTime}
/>
));
@@ -987,18 +1141,28 @@ type eachStopType = {
isSmall: boolean;
index: number;
secondText: string;
currentDisplayIndex: number;
arrivalTime?: string | null;
};
const EachStopData: FC<eachStopType> = (props) => {
const { colors } = useThemeColors();
const { d, delayTime, isSmall, index, secondText, arrivalTime } = props;
const { playbackCurrentTimeIso } = useTrainMenu();
const {
d,
delayTime,
isSmall,
index,
secondText,
currentDisplayIndex,
arrivalTime,
} = props;
if (!d) return null;
if (d == "") return null;
const [station, se, time] = d.split(",");
const calcMinute = (t: string) => {
if (!t || t === "") return null;
const now = dayjs();
const now = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const hour = parseInt(t.split(":")[0]);
const dt = now
.hour(hour < 4 ? hour + 24 : hour)
@@ -1073,7 +1237,7 @@ const EachStopData: FC<eachStopType> = (props) => {
style={{
fontSize: isSmall ? 8 : 14,
color:
index == 1 && secondText == ""
index === currentDisplayIndex && secondText === ""
? "#ffe852ff"
: se.includes("通")
? "#020202ff"
@@ -1083,14 +1247,14 @@ const EachStopData: FC<eachStopType> = (props) => {
fontWeight: "bold",
}}
>
{index == 1 && secondText == ""
{index === currentDisplayIndex && secondText === ""
? "→"
: se.includes("通")
? null
: "●"}
</Text>
</View>
{index == 0 && secondText != "" && (
{index === 0 && secondText !== "" && (
<View
style={{
flexDirection: "column",
@@ -1,6 +1,7 @@
import { useState, useEffect } from "react";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { useStationList } from "@/stateBox/useStationList";
import { OriginalStationList } from "@/lib/CommonTypes";
interface StationIDPair {
[key: string]: string;
@@ -10,12 +11,10 @@ interface LineListPair {
[key: string]: string;
}
interface OriginalStationList {
[key: string]: Array<{
Station_JP: string;
StationNumber: string;
}>;
}
const isHiddenThroughPointEntry = (raw: string) => {
const [station = "", se = ""] = (raw || "").split(",");
return station.startsWith(".") && se.includes("通");
};
/**
* 通過駅を含む列車データを生成するカスタムフック
@@ -33,8 +32,11 @@ export const useTrainDataWithThrough = (
const [trainDataWithThrough, setTrainDataWithThrough] = useState<string[]>([]);
useEffect(() => {
const trainData = allTrainDiagram[trainID]?.split("#");
if (!trainData) return;
const trainData =
allTrainDiagram[trainID]
?.split("#")
.filter((entry) => !isHiddenThroughPointEntry(entry)) ?? [];
if (trainData.length === 0) return;
// 各停車駅の情報を取得
const stopStationList = trainData.map((i) => {
@@ -76,6 +78,8 @@ export const useTrainDataWithThrough = (
if (!lineStations) return [];
lineStations.forEach((d) => {
const isHiddenPassPoint = d.Station_JP?.startsWith(".");
if (isHiddenPassPoint) return;
// 順方向の判定
if (
d.StationNumber > baseStationNumberFirst &&
+283
View File
@@ -0,0 +1,283 @@
import React, { FC, useRef, useCallback, useState } from "react";
import {
View,
Text,
TouchableOpacity,
PanResponder,
LayoutChangeEvent,
} from "react-native";
import { Ionicons } from "@expo/vector-icons";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import dayjs from "dayjs";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useThemeColors } from "../../lib/theme";
import { useResponsive } from "../../lib/responsive";
/**
* 再生中に走行位置WebViewの上部に表示するタイムラインコントローラー。
* FixedPositionBox と同じ absolute 配置で zIndex を上に設定する。
*/
export const PlaybackTimeline: FC = () => {
const { top } = useSafeAreaInsets();
const { colors, fixed } = useThemeColors();
const { moderateScale } = useResponsive();
const {
recorderState,
activeRecording,
playbackIndex,
playbackPaused,
stopPlayback,
pausePlayback,
resumePlayback,
seekToSnapshot,
} = useTrainMenu();
// ─── すべてのフックは早期returnより前に呼ぶ ───
const trackViewRef = useRef<View>(null);
const trackWidthRef = useRef(1);
const trackPageXRef = useRef(0); // スクリーン絶対座標でのトラック左端
const [isScrubbing, setIsScrubbing] = useState(false);
const [scrubIndex, setScrubIndex] = useState<number | null>(null);
const scrubIndexRef = useRef<number | null>(null);
// PanResponder のハンドラ内で最新値を参照するために ref を使う
const seekRef = useRef(seekToSnapshot);
seekRef.current = seekToSnapshot;
const totalRef = useRef(0);
// gestureState.moveX(画面絶対座標)からインデックスを計算
// locationX はドラッグ中に子 View をまたぐと基準がズレて端に飛ぶため使わない
const indexFromPageX = useCallback((pageX: number) => {
if (totalRef.current <= 1) return 0;
const relX = pageX - trackPageXRef.current;
const width = Math.max(trackWidthRef.current, 1);
const clampedX = Math.max(0, Math.min(relX, width));
const idx = Math.round((clampedX / width) * (totalRef.current - 1));
return Math.max(0, Math.min(idx, totalRef.current - 1));
}, []);
const endScrubbing = useCallback((index: number) => {
seekRef.current(index);
setIsScrubbing(false);
setScrubIndex(null);
scrubIndexRef.current = null;
}, []);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (evt, gestureState) => {
const idx = indexFromPageX(gestureState.x0);
pausePlayback();
setIsScrubbing(true);
setScrubIndex(idx);
scrubIndexRef.current = idx;
},
onPanResponderMove: (evt, gestureState) => {
const idx = indexFromPageX(gestureState.moveX);
setScrubIndex(idx);
scrubIndexRef.current = idx;
},
onPanResponderRelease: (evt, gestureState) => {
const idx = scrubIndexRef.current ?? indexFromPageX(gestureState.moveX);
endScrubbing(idx);
},
onPanResponderTerminate: (evt, gestureState) => {
const idx = scrubIndexRef.current ?? indexFromPageX(gestureState.moveX);
endScrubbing(idx);
},
})
).current;
const onTrackLayout = useCallback((e: LayoutChangeEvent) => {
trackWidthRef.current = e.nativeEvent.layout.width;
// レイアウト確定後に絶対座標を取得
trackViewRef.current?.measure((_x, _y, _w, _h, pageX) => {
trackPageXRef.current = pageX;
});
}, []);
const btnSize = moderateScale(36);
const iconSize = moderateScale(20);
// ─── 早期return ───
if (recorderState !== "playing" || !activeRecording) return null;
const total = activeRecording.snapshots.length;
totalRef.current = total; // PanResponder が参照する最新値を更新
const displayIndex = isScrubbing && scrubIndex !== null ? scrubIndex : playbackIndex;
const snap = activeRecording.snapshots[displayIndex];
// スナップショット時刻 = 録画開始時刻 + elapsed
const snapTime = dayjs(activeRecording.recordedAt).add(snap.t, "ms");
const timeLabel = snapTime.format("HH:mm:ss");
// 録画の総時間をフォーマット
const totalSec = Math.round(activeRecording.durationMs / 1000);
const totalLabel =
totalSec >= 60
? `${Math.floor(totalSec / 60)}:${String(totalSec % 60).padStart(2, "0")}`
: `${totalSec}s`;
const progress = total > 1 ? displayIndex / (total - 1) : 0;
return (
<View
style={{
position: "absolute",
top,
left: 0,
right: 0,
zIndex: 2000,
backgroundColor: colors.surface + "f2", // 少し透過
borderBottomWidth: 1,
borderBottomColor: colors.borderSecondary,
paddingHorizontal: 12,
paddingTop: 6,
paddingBottom: 8,
}}
pointerEvents="box-none"
>
{/* 上段: ボタン + 時刻 + コマ数 */}
<View
style={{
flexDirection: "row",
alignItems: "center",
gap: 4,
}}
>
{/* 先頭コマへ */}
<TouchableOpacity
onPress={() => seekToSnapshot(0)}
style={[styles.btn(btnSize, colors.borderSecondary)]}
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
>
<Ionicons name="play-skip-back" size={iconSize} color={colors.textPrimary} />
</TouchableOpacity>
{/* 前のコマ */}
<TouchableOpacity
onPress={() => seekToSnapshot(playbackIndex - 1)}
style={[styles.btn(btnSize, colors.borderSecondary)]}
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
>
<Ionicons name="play-back" size={iconSize} color={colors.textPrimary} />
</TouchableOpacity>
{/* 再生 / 一時停止 */}
<TouchableOpacity
onPress={playbackPaused ? resumePlayback : pausePlayback}
style={[styles.btn(btnSize, fixed.primary)]}
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
>
<Ionicons
name={playbackPaused ? "play" : "pause"}
size={iconSize}
color={fixed.textOnPrimary}
/>
</TouchableOpacity>
{/* 次のコマ */}
<TouchableOpacity
onPress={() => seekToSnapshot(playbackIndex + 1)}
style={[styles.btn(btnSize, colors.borderSecondary)]}
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
>
<Ionicons name="play-forward" size={iconSize} color={colors.textPrimary} />
</TouchableOpacity>
{/* 末尾コマへ */}
<TouchableOpacity
onPress={() => seekToSnapshot(total - 1)}
style={[styles.btn(btnSize, colors.borderSecondary)]}
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
>
<Ionicons name="play-skip-forward" size={iconSize} color={colors.textPrimary} />
</TouchableOpacity>
{/* スペーサー */}
<View style={{ flex: 1 }} />
{/* 時刻表示 */}
<View style={{ alignItems: "flex-end" }}>
<Text style={{ fontSize: moderateScale(15), fontWeight: "bold", color: colors.textPrimary, fontVariant: ["tabular-nums"] }}>
{timeLabel}
</Text>
<Text style={{ fontSize: moderateScale(10), color: colors.textSecondary, fontVariant: ["tabular-nums"] }}>
{displayIndex + 1}/{total} {totalLabel}
</Text>
</View>
{/* 停止ボタン */}
<TouchableOpacity
onPress={stopPlayback}
style={[styles.btn(btnSize, "#e53935"), { marginLeft: 6 }]}
hitSlop={{ top: 8, bottom: 8, left: 4, right: 4 }}
>
<Ionicons name="stop" size={iconSize} color="#fff" />
</TouchableOpacity>
</View>
{/* 下段: スクラバートラック */}
<View
ref={trackViewRef}
style={{ marginTop: 6, paddingVertical: 8, marginVertical: -8 }}
onLayout={onTrackLayout}
{...panResponder.panHandlers}
>
{/* トラック背景 */}
<View
style={{
height: 6,
borderRadius: 3,
backgroundColor: colors.borderSecondary,
overflow: "hidden",
}}
>
{/* 進捗バー */}
<View
style={{
position: "absolute",
left: 0,
top: 0,
bottom: 0,
width: `${progress * 100}%`,
backgroundColor: fixed.primary,
borderRadius: 3,
}}
/>
</View>
{/* ドラッグハンドル */}
<View
style={{
position: "absolute",
top: -4,
left: `${progress * 100}%`,
marginLeft: -8,
width: 14,
height: 14,
borderRadius: 7,
backgroundColor: fixed.primary,
borderWidth: 2,
borderColor: fixed.textOnPrimary,
elevation: 2,
}}
/>
</View>
</View>
);
};
// ヘルパー: ボタンスタイル生成
const styles = {
btn: (size: number, bg: string) => ({
width: size,
height: size,
borderRadius: size / 2,
backgroundColor: bg,
alignItems: "center" as const,
justifyContent: "center" as const,
}),
};
+163
View File
@@ -0,0 +1,163 @@
import React, { FC, useEffect, useRef, useState } from "react";
import { View, Text, AppState, InteractionManager } from "react-native";
import { activateKeepAwakeAsync, deactivateKeepAwake } from "expo-keep-awake";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useThemeColors } from "../../lib/theme";
import { useResponsive } from "../../lib/responsive";
const KEEP_AWAKE_TAG = "recording-status-bar";
const isActivityUnavailableError = (error: unknown) =>
String(error).includes("The current activity is no longer available");
/**
* 録画中に走行位置画面上部に表示するステータスバー。
* PlaybackTimeline と同じ absolute 配置。
* 録画中はスリープを抑制する。
*/
export const RecordingStatusBar: FC = () => {
const { top } = useSafeAreaInsets();
const { colors } = useThemeColors();
const { moderateScale } = useResponsive();
const { recorderState, recordingSnapshotCount } = useTrainMenu();
// 経過時間(秒)
const [elapsedSec, setElapsedSec] = useState(0);
const startTimeRef = useRef<number>(Date.now());
// 録画開始時にタイマーをリセットして1秒ごとに更新
useEffect(() => {
if (recorderState !== "recording") {
setElapsedSec(0);
return;
}
startTimeRef.current = Date.now();
setElapsedSec(0);
const timer = setInterval(() => {
setElapsedSec(Math.floor((Date.now() - startTimeRef.current) / 1000));
}, 1000);
return () => clearInterval(timer);
}, [recorderState]);
// 録画中はスリープ抑制
useEffect(() => {
if (recorderState !== "recording") return;
if (__DEV__) return;
let mounted = true;
const activate = async () => {
if (!mounted || AppState.currentState !== "active") return;
try {
await activateKeepAwakeAsync(KEEP_AWAKE_TAG);
} catch (error) {
if (!isActivityUnavailableError(error)) {
console.warn("RecordingStatusBar: failed to activate keep awake", error);
}
}
};
const interactionHandle = InteractionManager.runAfterInteractions(() => {
void activate();
});
const subscription = AppState.addEventListener("change", (state) => {
if (state === "active") {
void activate();
return;
}
deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {});
});
return () => {
mounted = false;
interactionHandle.cancel();
subscription.remove();
deactivateKeepAwake(KEEP_AWAKE_TAG).catch(() => {});
};
}, [recorderState]);
if (recorderState !== "recording") return null;
const minutes = Math.floor(elapsedSec / 60);
const seconds = elapsedSec % 60;
const timeLabel = `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
return (
<View
style={{
position: "absolute",
top,
left: 0,
right: 0,
zIndex: 2000,
backgroundColor: "rgba(229, 57, 53, 0.92)",
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 14,
paddingVertical: 7,
gap: 10,
}}
pointerEvents="none"
>
{/* 点滅 REC ドット */}
<BlinkDot />
<Text
style={{
color: "#fff",
fontWeight: "bold",
fontSize: moderateScale(13),
letterSpacing: 1,
}}
>
REC
</Text>
<Text
style={{
color: "#fff",
fontSize: moderateScale(15),
fontWeight: "bold",
fontVariant: ["tabular-nums"],
letterSpacing: 1,
}}
>
{timeLabel}
</Text>
<View style={{ flex: 1 }} />
<Text
style={{
color: "rgba(255,255,255,0.85)",
fontSize: moderateScale(11),
}}
>
{recordingSnapshotCount}
</Text>
</View>
);
};
/** 1秒ごとに点滅する録画インジケータードット */
const BlinkDot: FC = () => {
const [visible, setVisible] = useState(true);
useEffect(() => {
const timer = setInterval(() => setVisible((v) => !v), 700);
return () => clearInterval(timer);
}, []);
return (
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor: visible ? "#fff" : "transparent",
borderWidth: 1.5,
borderColor: "#fff",
}}
/>
);
};
+3 -2
View File
@@ -22,10 +22,11 @@ type ReloadButton = {
}
export const ReloadButton:FC<ReloadButton> = ({ onPress, right }) => {
const { fixed } = useThemeColors();
const { mapSwitch, LoadError = false } = useTrainMenu();
const { mapSwitch, LoadError = false, mockApiFeatureEnabled } = useTrainMenu();
const { top } = useSafeAreaInsets();
const { moderateScale } = useResponsive();
const buttonSize = moderateScale(50);
const buttonColor = LoadError ? "red" : mockApiFeatureEnabled ? "#7c3aed" : fixed.primary;
const styles: stylesType = {
touch: {
position: "absolute",
@@ -33,7 +34,7 @@ export const ReloadButton:FC<ReloadButton> = ({ onPress, right }) => {
right: 10 + right,
width: buttonSize,
height: buttonSize,
backgroundColor: LoadError ? "red" : fixed.primary,
backgroundColor: buttonColor,
borderColor: fixed.textOnPrimary,
borderStyle: "solid",
borderWidth: 1,
+240 -15
View File
@@ -1,4 +1,4 @@
import React from "react";
import React, { useCallback, useEffect, useRef } from "react";
import { Platform } from "react-native";
import { WebView } from "react-native-webview";
@@ -15,13 +15,19 @@ import { useCurrentTrain } from "../../stateBox/useCurrentTrain";
import { useDeviceOrientationChange } from "../../stateBox/useDeviceOrientationChange";
import { SheetManager } from "react-native-actions-sheet";
import { useNavigation } from "@react-navigation/native";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useStationList } from "../../stateBox/useStationList";
import { useThemeColors } from "@/lib/theme";
export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
import { useWebViewRemount } from "@/lib/useWebViewRemount";
import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor";
import * as Sentry from "@sentry/react-native";
import { setAppLifecycleWebViewActive } from "@/lib/observability/appLifecycleCrashSentinel";
export const AppsWebView = ({ openStationACFromEachTrainInfo, onInitialLoadReady }) => {
const initialPositionsUrl = "https://train.jr-shikoku.co.jp/sp.html";
const { webview, currentTrain } = useCurrentTrain();
const { navigate } = useNavigation<any>();
const isFocused = useIsFocused();
const { favoriteStation } = useFavoriteStation();
const { isLandscape } = useDeviceOrientationChange();
const { isDark } = useThemeColors();
@@ -34,14 +40,142 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
setLoadError,
setTrainInfo,
injectJavascript,
injectJavascriptBeforeContentLoaded,
mockApiFeatureEnabled,
mockTrainPositions,
} = useTrainMenu();
var urlcache = "";
let once = false;
const addWebViewBreadcrumb = (
message: string,
data?: Record<string, string | number | boolean | null | undefined>
) => {
Sentry.addBreadcrumb({
category: "positions.webview",
level: "info",
message,
data,
});
};
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({
pingEnabled: Platform.OS === "ios",
backgroundThresholdMs: null,
isFocused,
pauseWatchdogWhenUnfocused: Platform.OS === "ios",
ignoreProcessTerminationWhenUnfocused: Platform.OS === "ios",
onRemount: (reason, data) => {
addWebViewBreadcrumb("webview remount requested", {
reason,
...(data ?? {}),
});
},
});
const lastRemountKeyRef = useRef<typeof remountKey | null>(null);
useEffect(() => {
setAppLifecycleWebViewActive("positions_main", true);
return () => setAppLifecycleWebViewActive("positions_main", false);
}, []);
// コマが変化したとき(再生・シーク)に WebView 内の _MOCK_TRAIN を差し替えて再描画
const mountedRef = useRef(false);
const urlCacheRef = useRef("");
const initialInjectDoneRef = useRef(false);
const pendingInitialInjectRef = useRef<string | null>(null);
const loadEndTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const focusedRef = useRef(isFocused);
const initialLoadReadyNotifiedRef = useRef(false);
const previousMockApiFeatureEnabledRef = useRef(mockApiFeatureEnabled);
useEffect(() => {
const previousEnabled = previousMockApiFeatureEnabledRef.current;
if (previousEnabled === mockApiFeatureEnabled) return;
previousMockApiFeatureEnabledRef.current = mockApiFeatureEnabled;
addWebViewBreadcrumb("mock mode changed; remounting webview", {
previousEnabled,
enabled: mockApiFeatureEnabled,
focused: focusedRef.current,
});
remount();
}, [mockApiFeatureEnabled, remount]);
useEffect(() => {
focusedRef.current = isFocused;
addWebViewBreadcrumb(isFocused ? "webview focused" : "webview blurred", {
landscape: isLandscape,
mockApi: mockApiFeatureEnabled,
});
if (!isFocused) return;
if (!pendingInitialInjectRef.current) return;
addWebViewBreadcrumb("webview pending initial inject resumed on focus");
webview?.current?.injectJavaScript(pendingInitialInjectRef.current);
pendingInitialInjectRef.current = null;
initialInjectDoneRef.current = true;
}, [isFocused, isLandscape, mockApiFeatureEnabled]);
useEffect(() => {
if (lastRemountKeyRef.current !== remountKey) {
addWebViewBreadcrumb("webview remount key", { remountKey });
lastRemountKeyRef.current = remountKey;
initialInjectDoneRef.current = false;
pendingInitialInjectRef.current = null;
initialLoadReadyNotifiedRef.current = false;
if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current);
loadEndTimeoutRef.current = null;
}
}
}, [remountKey]);
useEffect(() => {
Sentry.setContext("positions_webview", {
focused: isFocused,
landscape: isLandscape,
mockApi: mockApiFeatureEnabled,
remountKey,
currentUrl: urlCacheRef.current || null,
});
}, [isFocused, isLandscape, mockApiFeatureEnabled, remountKey]);
useEffect(() => {
return () => {
addWebViewBreadcrumb("webview cleanup start", {
focused: focusedRef.current,
currentUrl: urlCacheRef.current || null,
});
if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current);
loadEndTimeoutRef.current = null;
}
addWebViewBreadcrumb("webview unmounted");
};
}, []);
useEffect(() => {
// 初回マウント時はスキップ(beforeContentLoaded で既に正しいデータが入っている)
if (!mountedRef.current) {
mountedRef.current = true;
addWebViewBreadcrumb("mock positions effect skipped initial mount", {
mockApi: mockApiFeatureEnabled,
});
return;
}
if (!mockApiFeatureEnabled || !mockTrainPositions) return;
addWebViewBreadcrumb("mock positions injected", {
positionCount: mockTrainPositions.length,
});
const script = generateMockUpdateScript(mockTrainPositions);
webview?.current?.injectJavaScript(script);
}, [mockApiFeatureEnabled, mockTrainPositions, webview]);
const attachWebViewRefs = useCallback((instance) => {
webview.current = instance;
webViewRef.current = instance;
}, [webViewRef, webview]);
const onNavigationStateChange = ({ url }) => {
if (url == urlcache) return;
if (url == urlCacheRef.current) return;
//URL二重判定回避
urlcache = url;
urlCacheRef.current = url;
addWebViewBreadcrumb("webview navigation", { url });
switch (true) {
case url.includes("https://train.jr-shikoku.co.jp/usage.htm"):
if (Platform.OS === "android") navigate("howto", { info: url });
@@ -55,7 +189,20 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
break;
}
};
const getRestorableUrl = () => {
const cachedUrl = urlCacheRef.current;
if (!cachedUrl) return initialPositionsUrl;
if (cachedUrl.includes("https://train.jr-shikoku.co.jp/usage.htm")) {
return initialPositionsUrl;
}
if (cachedUrl.includes("https://train.jr-shikoku.co.jp/train.html")) {
return initialPositionsUrl;
}
return cachedUrl;
};
const onMessage = (event) => {
pingHandlers.onMessage?.(event);
const { data } = event.nativeEvent;
/**
* {type,trainNum,limited}
@@ -63,14 +210,27 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
* {type,event,id,name,pdf,map,url,chk}
*/
if (data.includes("train.html")) {
addWebViewBreadcrumb("webview train link", { data });
navigate("trainbase", { info: data, from: "Train" });
return;
}
if (!originalStationList) {
addWebViewBreadcrumb("webview message blocked waiting station data");
alert("駅名標データを取得中...");
return;
}
const dataSet = JSON.parse(data);
let dataSet;
try {
dataSet = JSON.parse(data);
} catch (error) {
addWebViewBreadcrumb("webview message parse failed", {
dataPreview: data.slice(0, 120),
});
throw error;
}
addWebViewBreadcrumb("webview message", {
type: dataSet.type ?? "unknown",
});
switch (dataSet.type) {
case "LoadError": {
setLoadError(true);
@@ -134,8 +294,30 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
}
};
const onLoadStart = () => {
pingHandlers.onLoadStart?.();
addWebViewBreadcrumb("webview loadStart", {
focused: focusedRef.current,
currentUrl: urlCacheRef.current || null,
});
};
const onLoadEnd = () => {
if (once) return () => {};
pingHandlers.onLoadEnd?.();
addWebViewBreadcrumb("webview loadEnd", {
focused: focusedRef.current,
hasStationData: !!stationData,
hasOriginalStationList: !!originalStationList,
favoriteCount: favoriteStation.length,
});
if (!initialLoadReadyNotifiedRef.current) {
initialLoadReadyNotifiedRef.current = true;
addWebViewBreadcrumb("webview initial load ready", {
focused: focusedRef.current,
});
onInitialLoadReady?.();
}
if (initialInjectDoneRef.current) return () => {};
if (!stationData) return () => {};
if (!originalStationList) return () => {};
if (favoriteStation.length < 1) return () => {};
@@ -143,17 +325,45 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
favoriteStation[0][0].StationNumber
);
if (!string) return () => {};
setTimeout(() => {
if (loadEndTimeoutRef.current) {
clearTimeout(loadEndTimeoutRef.current);
}
addWebViewBreadcrumb("webview initial inject scheduled", {
focused: focusedRef.current,
});
loadEndTimeoutRef.current = setTimeout(() => {
addWebViewBreadcrumb(
focusedRef.current
? "webview initial inject run"
: "webview initial inject run while blurred",
{
focused: focusedRef.current,
}
);
webview?.current?.injectJavaScript(string);
}, 500);
once = true;
pendingInitialInjectRef.current = null;
initialInjectDoneRef.current = true;
loadEndTimeoutRef.current = null;
}, focusedRef.current ? 500 : 700);
};
const handleRenderProcessGone = (event) => {
addWebViewBreadcrumb("webview render process gone", {
didCrash: event.nativeEvent.didCrash,
});
processHandlers.onRenderProcessGone?.(event);
};
const handleContentProcessDidTerminate = () => {
addWebViewBreadcrumb("webview content process terminated");
processHandlers.onContentProcessDidTerminate?.();
};
return (
<WebView
key={isDark ? 'dark' : 'light'}
ref={webview}
source={{ uri: "https://train.jr-shikoku.co.jp/sp.html" }}
key={`positions-webview-${remountKey}`}
ref={attachWebViewRefs}
source={{ uri: getRestorableUrl() }}
originWhitelist={[
"https://train.jr-shikoku.co.jp",
"https://train.jr-shikoku.co.jp/sp.html",
@@ -164,7 +374,22 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
setSupportMultipleWindows
contentMode="mobile"
style={{ backgroundColor: bgColor }}
onLoadStart={onLoadStart}
onError={(event) => {
addWebViewBreadcrumb("webview error", {
description: event.nativeEvent.description || null,
code: event.nativeEvent.code ?? null,
});
}}
onHttpError={(event) => {
addWebViewBreadcrumb("webview http error", {
statusCode: event.nativeEvent.statusCode ?? null,
});
}}
onRenderProcessGone={handleRenderProcessGone}
onContentProcessDidTerminate={handleContentProcessDidTerminate}
{...{ onMessage, onNavigationStateChange, onLoadEnd }}
injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded}
injectedJavaScript={injectJavascript}
/>
);
+100 -22
View File
@@ -9,6 +9,7 @@ import {
TouchableOpacity,
Text,
ScrollView,
Platform,
} from "react-native";
import Carousel, { ICarouselInstance } from "react-native-reanimated-carousel";
import { SheetManager } from "react-native-actions-sheet";
@@ -27,6 +28,10 @@ import Animated, {
} from "react-native-reanimated";
import { useSortMode } from "./useSortMode";
import { StationSource } from "@/types";
import * as Sentry from "@sentry/react-native";
const isMissingStorageKeyError = (error: unknown) =>
String(error).includes("Not Found!");
export const CarouselBox = ({
originalStationList,
@@ -45,6 +50,9 @@ export const CarouselBox = ({
navigate: any;
stationSource: StationSource;
}) => {
const isAndroid = Platform.OS === "android";
const sortControlsEntering = isAndroid ? undefined : FadeIn.duration(200);
const sortControlsExiting = isAndroid ? undefined : FadeOut.duration(150);
const carouselRef = useRef<ICarouselInstance>(null);
const { width } = useWindowDimensions();
const { colors, fixed } = useThemeColors();
@@ -109,6 +117,15 @@ export const CarouselBox = ({
// コンテナ高さ(カルーセル ↔ グリッドで可変)
const containerHeight = useSharedValue(carouselHeight);
const containerHeightStyle = useAnimatedStyle(() => ({ height: containerHeight.value }));
const androidContainerStyle = isAndroid ? { height: isSortMode ? gridHeight : carouselHeight } : null;
const carouselModeProps = {
mode: "parallax" as const,
modeConfig: {
parallaxScrollingScale: 1,
parallaxScrollingOffset: 100,
parallaxAdjacentItemScale: 0.8,
},
};
// ドットエリアのフェード
const dotsOpacity = useSharedValue(1);
@@ -122,6 +139,11 @@ export const CarouselBox = ({
const gridAnimStyle = useAnimatedStyle(() => ({ opacity: gridOpacity.value }));
useEffect(() => {
if (isAndroid) {
setIsGridMounted(isSortMode);
return;
}
const duration = 250;
if (isSortMode) {
setIsGridMounted(true); // フェードイン前にマウント
@@ -137,7 +159,7 @@ export const CarouselBox = ({
if (finished) runOnJS(setIsGridMounted)(false); // フェードアウト完了後にアンマウント
});
}
}, [isSortMode, gridHeight, carouselHeight]);
}, [isAndroid, isSortMode, gridHeight, carouselHeight]);
// ソートモード終了直後フラグ(次の listIndex 変更でアニメーションをスキップ)
const justExitedSortRef = useRef(false);
@@ -150,11 +172,10 @@ export const CarouselBox = ({
// バッジからのインデックス変更をカルーセルに反映
useEffect(() => {
if (listIndex >= 0 && carouselRef.current) {
const animated = !justExitedSortRef.current;
justExitedSortRef.current = false;
carouselRef.current.scrollTo({ index: listIndex, animated });
}
if (listIndex < 0) return;
const animated = !justExitedSortRef.current;
justExitedSortRef.current = false;
carouselRef.current?.scrollTo({ index: listIndex, animated });
}, [listIndex]);
// ドットのスクロール追従
@@ -167,9 +188,39 @@ export const CarouselBox = ({
// ドット表示設定の読み込み
useEffect(() => {
AS.getItem("CarouselSettings/activeDotSettings").then((data) => {
setDotButton(data === "true");
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings read scheduled",
});
AS.getItem("CarouselSettings/activeDotSettings")
.then((data) => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings read success",
data: {
rawValue: data == null ? "<null>" : String(data),
resolved: data === "true",
},
});
setDotButton(data === "true");
})
.catch((error) => {
setDotButton(false);
if (isMissingStorageKeyError(error)) {
return;
}
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "error",
message: "carousel activeDotSettings read failed",
data: {
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
},
});
});
}, []);
const oPSign = () => {
@@ -199,7 +250,38 @@ export const CarouselBox = ({
duration: 600,
update: { type: "spring", springDamping: 0.5 },
});
AS.setItem("CarouselSettings/activeDotSettings", !dotButton ? "true" : "false");
const nextValue = !dotButton ? "true" : "false";
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings write scheduled",
data: {
nextValue,
},
});
void AS.setItem("CarouselSettings/activeDotSettings", nextValue)
.then(() => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "info",
message: "carousel activeDotSettings write success",
data: {
nextValue,
},
});
})
.catch((error) => {
Sentry.addBreadcrumb({
category: "menu.carousel",
level: "error",
message: "carousel activeDotSettings write failed",
data: {
nextValue,
errorName: error instanceof Error ? error.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
},
});
});
setDotButton(!dotButton);
};
@@ -255,10 +337,10 @@ export const CarouselBox = ({
return (
<View style={{ flex: 1, paddingTop: 10 }}>
{/* カルーセル / グリッド(同じ高さ領域を共用・クロスフェード) */}
<Animated.View style={[{ overflow: "visible" }, containerHeightStyle]}>
<Animated.View style={[{ overflow: "visible" }, isAndroid ? androidContainerStyle : containerHeightStyle]}>
{/* カルーセル */}
<Animated.View
style={[{ position: "absolute", width }, carouselAnimStyle]}
style={[{ position: "absolute", width }, isAndroid ? { opacity: isSortMode ? 0 : 1 } : carouselAnimStyle]}
pointerEvents={isSortMode ? "none" : "auto"}
>
<Carousel
@@ -270,13 +352,7 @@ export const CarouselBox = ({
loop={false}
width={width}
style={{ width, alignContent: "center" }}
mode="parallax"
modeConfig={{
parallaxScrollingScale: 1,
parallaxScrollingOffset: 100,
parallaxAdjacentItemScale: 0.8,
}}
scrollAnimationDuration={600}
scrollAnimationDuration={isAndroid ? 450 : 600}
onSnapToItem={setListIndex}
renderItem={RenderItem}
overscrollEnabled={false}
@@ -285,6 +361,8 @@ export const CarouselBox = ({
? 0
: lastValidListIndexRef.current
}
enabled={!isSortMode}
{...carouselModeProps}
/>
</Animated.View>
@@ -293,7 +371,7 @@ export const CarouselBox = ({
<Animated.View
style={[
{ position: "absolute", width, height: gridHeight, paddingHorizontal: gridPad, overflow: "visible" },
gridAnimStyle,
isAndroid ? { opacity: 1 } : gridAnimStyle,
]}
>
<Sortable.Grid
@@ -311,7 +389,7 @@ export const CarouselBox = ({
</Animated.View>
{/* ドットエリア:ソートモード時はフェードアウト */}
<Animated.View style={dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
<Animated.View style={isAndroid ? { opacity: isSortMode ? 0 : 1 } : dotsAnimStyle} pointerEvents={isSortMode ? "none" : "auto"}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
@@ -355,8 +433,8 @@ export const CarouselBox = ({
{/* 並び替えコントロール:ソートモード時に最下部からスライドイン */}
{isSortMode && (
<Animated.View
entering={FadeIn.duration(200)}
exiting={FadeOut.duration(150)}
entering={sortControlsEntering}
exiting={sortControlsExiting}
style={{
flexDirection: "row",
alignItems: "center",
@@ -8,6 +8,7 @@ import {
Platform,
} from "react-native";
import Ionicons from "react-native-vector-icons/Ionicons";
import { stackAwareNavigate } from "@/lib/rootNavigation";
import { SearchUnitBox } from "@/components/Menu/RailScope/SearchUnitBox";
import { StationSource } from "@/types";
import { STORAGE_KEYS } from "@/constants";
@@ -47,7 +48,7 @@ export const CarouselTypeChanger = ({
if (isGpsFollowing) {
setFixedPosition({ type: null, value: null });
} else {
navigate("positions", { screen: "Apps" } as any);
stackAwareNavigate("positions");
setFixedPosition({ type: "nearestStation", value: null });
}
};
+19 -1
View File
@@ -172,7 +172,7 @@ export const FixedContentBottom = (props) => {
disabled={!felicaAvailable}
>
<Text style={{ color: "white", fontWeight: "bold", fontSize: fontScale(20) }}>
IC残高
IC残高(β)
</Text>
<MaterialCommunityIcons name="contactless-payment" color="white" size={moderateScale(50)} />
<Text style={{ color: "white" }}>Felica対応ICカードの</Text>
@@ -268,6 +268,24 @@ export const FixedContentBottom = (props) => {
</Text>
<MaterialCommunityIcons name="chart-gantt" color="white" size={moderateScale(40)} />
</TextBox>
<TextBox
backgroundColor="#7F8C8D"
flex={1}
onPressButton={() => {
const uri = `https://shikoku-railinfo.haruk.in/timetable/?userID=${expoPushToken}&from=eachTrainInfo`;
props.navigate("generalWebView", { uri, useExitButton: false });
SheetManager.hide("EachTrainInfo");
}}
>
<Text style={{ color: "white", fontWeight: "bold", fontSize: fontScale(20) }}>
</Text>
<MaterialCommunityIcons
name="file-document-outline"
color="white"
size={moderateScale(40)}
/>
</TextBox>
</View>
<Text style={{ fontWeight: "bold", fontSize: fontScale(20), color: colors.text }}></Text>
<TextBox
+462 -70
View File
@@ -1,5 +1,13 @@
import React, { useState, useEffect } from "react";
import { View, Text, ScrollView, StyleSheet, Image, TouchableOpacity, Linking } from "react-native";
import {
View,
Text,
ScrollView,
StyleSheet,
Image,
TouchableOpacity,
Linking,
} from "react-native";
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
@@ -8,9 +16,20 @@ import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useThemeColors } from "@/lib/theme";
import {
DEFAULT_JR_DATA_SYSTEM_ENV,
getJrDataSystemTrack,
getJrDataSystemUiVariant,
JR_DATA_SYSTEM_ENV_OPTIONS,
JrDataSystemTrack,
JrDataSystemUiVariant,
JrDataSystemEnvironmentKey,
normalizeJrDataSystemEnvironment,
resolveJrDataSystemEnvironment,
} from "@/lib/jrDataSystemEnvironment";
const HUB_LOGO_PNG = require("@/assets/relationLogo/unyohub_logo.webp");
const ELESITE_LOGO_PNG = require("@/assets/relationLogo/elesite_logo.png");
const ELESITE_LOGO_PNG = require("@/assets/relationLogo/elesite_logo.jpg");
/* ------------------------------------------------------------------ */
/* DataSourceAccordionCard */
/* ------------------------------------------------------------------ */
@@ -58,7 +77,16 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
const { colors } = useThemeColors();
return (
<View style={[styles.accordionCard, { backgroundColor: colors.surface, borderColor: colors.borderSecondary }, enabled && styles.accordionCardEnabled]}>
<View
style={[
styles.accordionCard,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
enabled && styles.accordionCardEnabled,
]}
>
{/* ── ヘッダー行(常時表示) ── */}
<View style={styles.accordionHeader}>
{/* 左:ロゴ */}
@@ -66,8 +94,14 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
{/* 中央:タイトル+タグライン */}
<View style={styles.accordionTitles}>
<Text style={[styles.accordionTitle, { color: colors.textPrimary }]}>{title}</Text>
<Text style={[styles.accordionTagline, { color: colors.textTertiary }]}>{tagline}</Text>
<Text style={[styles.accordionTitle, { color: colors.textPrimary }]}>
{title}
</Text>
<Text
style={[styles.accordionTagline, { color: colors.textTertiary }]}
>
{tagline}
</Text>
</View>
{/* 右:スイッチ */}
@@ -81,19 +115,36 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
{/* スイッチ状態テキスト */}
<View style={styles.accordionStatusRow}>
<View style={[styles.statusDot, { backgroundColor: enabled ? accentColor : colors.textDisabled }]} />
<Text style={[styles.statusText, { color: enabled ? accentColor : colors.textQuaternary }]}>
{enabled ? "有効 — 編成データを取得します" : "無効 — データを取得しません"}
<View
style={[
styles.statusDot,
{ backgroundColor: enabled ? accentColor : colors.textDisabled },
]}
/>
<Text
style={[
styles.statusText,
{ color: enabled ? accentColor : colors.textQuaternary },
]}
>
{enabled
? "有効 — 編成データを取得します"
: "無効 — データを取得しません"}
</Text>
</View>
{/* ── 展開トリガー ── */}
<TouchableOpacity
style={[styles.accordionToggleRow, { borderTopColor: colors.borderCard }]}
style={[
styles.accordionToggleRow,
{ borderTopColor: colors.borderCard },
]}
onPress={() => setExpanded((v) => !v)}
activeOpacity={0.6}
>
<Text style={[styles.accordionToggleLabel, { color: colors.textSecondary }]}>
<Text
style={[styles.accordionToggleLabel, { color: colors.textSecondary }]}
>
{expanded ? "詳細を閉じる" : (detailLabel ?? `${title} について`)}
</Text>
<MaterialCommunityIcons
@@ -105,31 +156,69 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
{/* ── 展開コンテンツ ── */}
{expanded && (
<View style={[styles.accordionBody, { borderTopColor: colors.borderCard, backgroundColor: colors.backgroundTertiary }]}>
<View
style={[
styles.accordionBody,
{
borderTopColor: colors.borderCard,
backgroundColor: colors.backgroundTertiary,
},
]}
>
{/* 説明文 */}
<Text style={[styles.bodyDesc, { color: colors.textSecondary }]}>{description}</Text>
<Text style={[styles.bodyDesc, { color: colors.textSecondary }]}>
{description}
</Text>
{/* 機能リスト */}
<View style={[styles.bodyFeatures, { borderTopColor: colors.borderSecondary }]}>
<View
style={[
styles.bodyFeatures,
{ borderTopColor: colors.borderSecondary },
]}
>
{features.map((f) => (
<View key={f.icon} style={styles.featureRow}>
<View style={styles.featureIcon}>
<MaterialCommunityIcons name={f.icon as any} size={14} color={colors.iconSecondary} />
<MaterialCommunityIcons
name={f.icon as any}
size={14}
color={colors.iconSecondary}
/>
</View>
<Text style={[styles.featureLabel, { color: colors.textPrimary }]}>{f.label}</Text>
<Text style={[styles.featureText, { color: colors.textSecondary }]}>{f.text}</Text>
<Text
style={[styles.featureLabel, { color: colors.textPrimary }]}
>
{f.label}
</Text>
<Text
style={[styles.featureText, { color: colors.textSecondary }]}
>
{f.text}
</Text>
</View>
))}
</View>
{/* リンク */}
<TouchableOpacity
style={[styles.bodyLink, { borderTopColor: colors.borderSecondary }]}
style={[
styles.bodyLink,
{ borderTopColor: colors.borderSecondary },
]}
onPress={() => Linking.openURL(linkUrl)}
activeOpacity={0.7}
>
<MaterialCommunityIcons name="open-in-new" size={13} color={colors.iconSecondary} />
<Text style={[styles.bodyLinkText, { color: colors.textSecondary }]}>{linkLabel}</Text>
<MaterialCommunityIcons
name="open-in-new"
size={13}
color={colors.iconSecondary}
/>
<Text
style={[styles.bodyLinkText, { color: colors.textSecondary }]}
>
{linkLabel}
</Text>
</TouchableOpacity>
</View>
)}
@@ -141,16 +230,45 @@ const DataSourceAccordionCard: React.FC<DataSourceAccordionCardProps> = ({
/* 定数 */
/* ------------------------------------------------------------------ */
const UNYOHUB_FEATURES: Feature[] = [
{ icon: "calendar-today", label: "運用データ", text: "本日・過去数日から投稿があった運用の継続予測運用情報を表示" },
{ icon: "map-outline", label: "対象エリア", text: "JR四国全線" },
{ icon: "train", label: "対象運用", text: "JR四国管内営業列車及び貨物列車に対応、臨時列車/突発運用は非対応" },
{ icon: "plus", label: "追加機能", text: "前日、当日、翌日の運用の投稿が可能" },
{
icon: "calendar-today",
label: "運用データ",
text: "当日・過去数日から投稿があった運用の継続予測運用情報を表示",
},
{ icon: "map-outline", label: "対象エリア", text: "JR四国全線" },
{
icon: "train",
label: "対象運用",
text: "JR四国管内営業列車及び貨物列車,定期回送列車に対応、臨時列車/突発運用は非対応",
},
{
icon: "pencil",
label: "入力方式",
text: "アプリ内連携システムにて当日の運用の投稿が可能",
},
];
const ELESITE_FEATURES: Feature[] = [
{ icon: "calendar-today", label: "運用データ", text: "当日に報告のあった運用情報のみ表示" },
{ icon: "map-outline", label: "対象エリア", text: "予讃線/瀬戸大橋線(なお直通している特急などの列番は含みます)" },
{ icon: "train", label: "対象運用", text: "JR四国管内営業列車対応、臨時列車/突発運用は非対応" },
{
icon: "calendar-today",
label: "運用データ",
text: "当日報告のあった運用情報のみ表示",
},
{
icon: "map-outline",
label: "対象エリア",
text: "予讃線/瀬戸大橋線(直通している特急などの列番は含みます)",
},
{
icon: "train",
label: "対象運用",
text: "JR四国管内営業列車対応、臨時列車/突発運用は非対応",
},
{
icon: "pencil",
label: "入力方式",
text: "アプリ外リンク連携にて当日の運用の投稿が可能",
},
];
/* ------------------------------------------------------------------ */
@@ -158,11 +276,26 @@ const ELESITE_FEATURES: Feature[] = [
/* ------------------------------------------------------------------ */
export const DataSourceSettings = () => {
const navigation = useNavigation();
const { dataSourcePermission, updatePermission } = useTrainMenu();
const { updatePermission } = useTrainMenu();
const { colors, fixed } = useThemeColors();
const canUseElesite = updatePermission || dataSourcePermission.elesite;
const showDebugSelector = __DEV__ || updatePermission;
const [useUnyohub, setUseUnyohub] = useState(false);
const [useElesite, setUseElesite] = useState(false);
const [jrDataSystemEnv, setJrDataSystemEnv] =
useState<JrDataSystemEnvironmentKey>(DEFAULT_JR_DATA_SYSTEM_ENV);
const [jrDataSystemTrack, setJrDataSystemTrack] = useState<JrDataSystemTrack>(
getJrDataSystemTrack(DEFAULT_JR_DATA_SYSTEM_ENV),
);
const [jrDataSystemUiVariant, setJrDataSystemUiVariant] =
useState<JrDataSystemUiVariant>(
getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV),
);
const applyJrDataSystemEnv = (env: JrDataSystemEnvironmentKey) => {
setJrDataSystemEnv(env);
setJrDataSystemTrack(getJrDataSystemTrack(env));
setJrDataSystemUiVariant(getJrDataSystemUiVariant(env));
AS.setItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV, env);
};
useEffect(() => {
AS.getItem(STORAGE_KEYS.USE_UNYOHUB).then((value) => {
@@ -171,6 +304,20 @@ export const DataSourceSettings = () => {
AS.getItem(STORAGE_KEYS.USE_ELESITE).then((value) => {
setUseElesite(value === true || value === "true");
});
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
.then((value) => {
const env = normalizeJrDataSystemEnvironment(value);
setJrDataSystemEnv(env);
setJrDataSystemTrack(getJrDataSystemTrack(env));
setJrDataSystemUiVariant(getJrDataSystemUiVariant(env));
})
.catch(() => {
setJrDataSystemEnv(DEFAULT_JR_DATA_SYSTEM_ENV);
setJrDataSystemTrack(getJrDataSystemTrack(DEFAULT_JR_DATA_SYSTEM_ENV));
setJrDataSystemUiVariant(
getJrDataSystemUiVariant(DEFAULT_JR_DATA_SYSTEM_ENV),
);
});
}, []);
const handleToggleUnyohub = (value: boolean) => {
@@ -179,64 +326,271 @@ export const DataSourceSettings = () => {
};
const handleToggleElesite = (value: boolean) => {
if (!canUseElesite) return;
setUseElesite(value);
AS.setItem(STORAGE_KEYS.USE_ELESITE, value.toString());
};
const handleSelectJrDataSystemTrack = (value: JrDataSystemTrack) => {
setJrDataSystemTrack(value);
const normalizedVariant =
value === "experimental" ? "release" : jrDataSystemUiVariant;
setJrDataSystemUiVariant(normalizedVariant);
const env = resolveJrDataSystemEnvironment(value, normalizedVariant);
applyJrDataSystemEnv(env);
};
const handleSelectJrDataSystemUiVariant = (value: JrDataSystemUiVariant) => {
setJrDataSystemUiVariant(value);
const env = resolveJrDataSystemEnvironment(jrDataSystemTrack, value);
applyJrDataSystemEnv(env);
};
return (
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
<SheetHeaderItem
title="情報ソース設定"
LeftItem={{
title: "戻る",
title: " 戻る",
onPress: () => navigation.goBack(),
position: "left",
}}
/>
<ScrollView style={[styles.content, { backgroundColor: colors.backgroundSecondary }]} contentContainerStyle={styles.contentInner}>
<Text style={[styles.sectionTitle, { color: colors.textTertiary }]}></Text>
<ScrollView
style={[
styles.content,
{ backgroundColor: colors.backgroundSecondary },
]}
contentContainerStyle={styles.contentInner}
>
<Text style={[styles.sectionTitle, { color: colors.textTertiary }]}>
</Text>
<DataSourceAccordionCard
logo={HUB_LOGO_PNG}
accentColor="#0099CC"
title="鉄道運用Hub"
tagline="コミュニティによる列車運用情報サービス"
enabled={useUnyohub}
onToggle={handleToggleUnyohub}
description={
"鉄道運用Hubはオープンソースのユーザー投稿型鉄道運用情報データベースアプリケーションです。JR 四国をはじめ全国多数の路線系統に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
}
features={UNYOHUB_FEATURES}
linkLabel="unyohub.2pd.jp を開く(JR四国)"
linkUrl="https://unyohub.2pd.jp/railroad_shikoku/"
/>
<DataSourceAccordionCard
logo={HUB_LOGO_PNG}
accentColor="#0099CC"
title="鉄道運用Hub"
tagline="コミュニティによる列車運用情報サービス"
enabled={useUnyohub}
onToggle={handleToggleUnyohub}
description={
"鉄道運用Hubはオープンソースのユーザー投稿型鉄道運用情報データベースアプリケーションです。JR 四国をはじめ全国多数の路線系統に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
}
features={UNYOHUB_FEATURES}
linkLabel="unyohub.2pd.jp を開く(JR四国)"
linkUrl="https://unyohub.2pd.jp/railroad_shikoku/"
/>
{canUseElesite && (
<DataSourceAccordionCard
logo={ELESITE_LOGO_PNG}
accentColor="#44bb44"
title="えれサイト"
tagline="コミュニティによる列車運用情報サービス"
enabled={useElesite}
onToggle={handleToggleElesite}
description={
"えれサイトは、鉄道運用情報を共有するためのサイトです。皆様からの投稿を通じて、鉄道運行に関する情報を共有するサイトです。JR 四国の特急・普通列車を中心に対応しています。\n\nデータがある列車では地図上に緑色の「E」バッジが表示され、列車情報画面の編成表示も更新されます。"
}
features={ELESITE_FEATURES}
linkLabel="elesite-next.com を開く"
linkUrl="https://www.elesite-next.com/"
/>
)}
<DataSourceAccordionCard
logo={ELESITE_LOGO_PNG}
accentColor="#44bb44"
title="えれサイト"
tagline="コミュニティによる列車運用情報サービス"
enabled={useElesite}
onToggle={handleToggleElesite}
description={
"えれサイトは、鉄道の運用情報を利用者同士で共有するサービスです。皆様からの投稿をもとに、列車のリアルタイムな動きを反映しています。JR四国の特急・普通列車をはじめ、現在は全国の路線に対応しています。\n\nデータがある列車では地図上にアイコンでマークが表示され、列車情報画面の編成表示も更新されます。"
}
features={ELESITE_FEATURES}
linkLabel="elesite-next.com を開く"
linkUrl="https://www.elesite-next.com/"
/>
<View style={[styles.infoSection, { backgroundColor: colors.backgroundTertiary }]}>
<Text style={[styles.infoText, { color: colors.textCaution }]}>
{"\n\n"}
{"\n\n"}JR四国非公式アプリが管理していないデータであるため
</Text>
</View>
</ScrollView>
<View
style={[
styles.infoSection,
{ backgroundColor: colors.backgroundTertiary },
]}
>
<Text style={[styles.infoText, { color: colors.textCaution }]}>
{"\n\n"}
{"\n\n"}
JR四国非公式アプリが管理していないデータであるため
</Text>
</View>
{showDebugSelector && (
<>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>
稿
</Text>
<Text
style={[
styles.debugDescription,
{ color: colors.textSecondary },
]}
>
/
</Text>
<Text
style={[
styles.debugCurrentText,
{ color: colors.textTertiary },
]}
>
</Text>
<View style={styles.debugOptionRow}>
{[
{
key: "production" as const,
label: "本番",
caption: "一般公開向け",
},
{
key: "experimental" as const,
label: "実験",
caption: "毎日リセット",
},
].map((option) => {
const selected = jrDataSystemTrack === option.key;
return (
<TouchableOpacity
key={option.key}
style={[
styles.debugOptionButton,
{
backgroundColor: selected
? fixed.primary
: colors.backgroundTertiary,
borderColor: selected
? fixed.primary
: colors.borderSecondary,
},
]}
onPress={() => handleSelectJrDataSystemTrack(option.key)}
activeOpacity={0.8}
>
<Text
style={[
styles.debugOptionTitle,
{
color: selected
? fixed.textOnPrimary
: colors.textPrimary,
},
]}
>
{option.label}
</Text>
<Text
style={[
styles.debugOptionCaption,
{
color: selected
? fixed.textOnPrimary
: colors.textTertiary,
},
]}
>
{option.caption}
</Text>
</TouchableOpacity>
);
})}
</View>
<Text
style={[
styles.debugCurrentText,
{ color: colors.textTertiary },
]}
>
UIバージョン
</Text>
<View style={styles.debugOptionRow}>
{[
{
key: "release" as const,
label: "リリース",
caption: "安定版",
},
{
key: "beta" as const,
label: "ベータ",
caption: "夜間ビルド",
},
].map((option) => {
const selected = jrDataSystemUiVariant === option.key;
const disabled = jrDataSystemTrack === "experimental";
return (
<TouchableOpacity
key={option.key}
style={[
styles.debugOptionButton,
{
opacity: disabled ? 0.45 : 1,
backgroundColor: selected
? fixed.primary
: colors.backgroundTertiary,
borderColor: selected
? fixed.primary
: colors.borderSecondary,
},
]}
onPress={() => {
if (disabled) return;
handleSelectJrDataSystemUiVariant(option.key);
}}
activeOpacity={0.8}
>
<Text
style={[
styles.debugOptionTitle,
{
color: selected
? fixed.textOnPrimary
: colors.textPrimary,
},
]}
>
{option.label}
</Text>
<Text
style={[
styles.debugOptionCaption,
{
color: selected
? fixed.textOnPrimary
: colors.textTertiary,
},
]}
>
{option.caption}
</Text>
</TouchableOpacity>
);
})}
</View>
<Text
style={[
styles.debugCurrentText,
{ color: colors.textTertiary },
]}
>
:{" "}
{
JR_DATA_SYSTEM_ENV_OPTIONS.find(
(option) => option.key === jrDataSystemEnv,
)?.baseUrl
}
</Text>
</View>
</>
)}
</ScrollView>
</View>
);
};
@@ -403,4 +757,42 @@ const styles = StyleSheet.create({
color: "#856404",
lineHeight: 18,
},
debugSection: {
borderRadius: 12,
borderWidth: 1,
padding: 14,
gap: 10,
},
debugTitle: {
fontSize: 15,
fontWeight: "bold",
},
debugDescription: {
fontSize: 12,
lineHeight: 18,
},
debugOptionRow: {
flexDirection: "row",
flexWrap: "wrap",
gap: 8,
},
debugOptionButton: {
minWidth: 96,
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 12,
paddingVertical: 10,
gap: 2,
},
debugOptionTitle: {
fontSize: 13,
fontWeight: "bold",
},
debugOptionCaption: {
fontSize: 10,
},
debugCurrentText: {
fontSize: 11,
lineHeight: 16,
},
});
+1 -1
View File
@@ -261,7 +261,7 @@ export function FelicaHistoryPage() {
return (
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
<SheetHeaderItem title="ICカード残高・履歴" />
<SheetHeaderItem title="微妙におかしなICカード残高・履歴(β)" />
<FlatList
style={[styles.scroll, { backgroundColor: colors.background }]}
contentContainerStyle={styles.scrollContent}
+176 -59
View File
@@ -11,10 +11,11 @@ import {
} from "react-native";
import LottieView from "lottie-react-native";
import { useNavigation } from "@react-navigation/native";
import { ListItem } from "@rneui/themed";
import * as Updates from "expo-updates";
import { AS } from "../../storageControl";
import { STORAGE_KEYS } from "@/constants";
import icons from "../../assets/icons/icons";
import icons, { series } from "../../assets/icons/icons";
import { setAlternateAppIcon, getAppIconName } from "expo-alternate-app-icons";
import { SheetHeaderItem } from "../atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
@@ -24,6 +25,28 @@ const NUM_COLUMNS = Math.floor(Dimensions.get("window").width / ITEM_TARGET_WIDT
type IconEntry = { id: string; name: string; icon: number };
type IconRow = IconEntry[];
type IconSection = { key: string; title: string; items: IconEntry[]; rows: IconRow[] };
const toAndroidAliasIconName = (id: string) =>
id
.split(/[^a-zA-Z0-9]+/)
.filter(Boolean)
.map((part, index) =>
index === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1)
)
.join("");
const matchesCurrentIcon = (id: string, currentIcon: string | null) =>
id === currentIcon ||
(Platform.OS === "android" && toAndroidAliasIconName(id) === currentIcon);
const chunkIcons = (items: IconEntry[]) => {
const rows: IconRow[] = [];
for (let i = 0; i < items.length; i += NUM_COLUMNS) {
rows.push(items.slice(i, i + NUM_COLUMNS));
}
return rows;
};
const IconItem = React.memo(({
id, name, icon, isSelected, onPress, textColor,
@@ -52,7 +75,8 @@ const IconItem = React.memo(({
export const LauncherIconSettings = () => {
const { goBack } = useNavigation();
const [allIcons] = useState<IconEntry[]>(icons());
const [currentIcon] = useState(getAppIconName());
const [currentIcon, setCurrentIcon] = useState<string | null>(getAppIconName());
const [expandedSections, setExpandedSections] = useState<string[]>([]);
const [ready, setReady] = useState(false);
const { colors, fixed } = useThemeColors();
@@ -67,21 +91,67 @@ export const LauncherIconSettings = () => {
}, [goBack]);
const currentIconItem = useMemo(
() => allIcons.find(({ id }) => id === currentIcon) ?? null,
() => allIcons.find(({ id }) => matchesCurrentIcon(id, currentIcon)) ?? null,
[allIcons, currentIcon]
);
const rows = useMemo<IconRow[]>(() => {
const result: IconRow[] = [];
for (let i = 0; i < allIcons.length; i += NUM_COLUMNS) {
result.push(allIcons.slice(i, i + NUM_COLUMNS));
const sections = useMemo<IconSection[]>(() => {
const iconMap = new Map(allIcons.map((icon) => [icon.id, icon] as const));
const usedIds = new Set<string>();
const seriesSections = series
.map((group) => {
const items = group.ids
.map((id) => iconMap.get(id))
.filter((item): item is IconEntry => Boolean(item));
items.forEach((item) => usedIds.add(item.id));
return {
key: group.key,
title: group.title,
items,
rows: chunkIcons(items),
};
})
.filter((section) => section.items.length > 0);
const ungroupedItems = allIcons.filter((item) => !usedIds.has(item.id));
if (ungroupedItems.length === 0) {
return seriesSections;
}
return result;
return [
...seriesSections,
{
key: "ungrouped",
title: "未分類",
items: ungroupedItems,
rows: chunkIcons(ungroupedItems),
},
];
}, [allIcons]);
useEffect(() => {
if (expandedSections.length > 0) return;
const currentSection = sections.find((section) =>
section.items.some(({ id }) => matchesCurrentIcon(id, currentIcon))
);
if (currentSection) {
setExpandedSections([currentSection.key]);
return;
}
if (sections[0]) {
setExpandedSections([sections[0].key]);
}
}, [currentIcon, expandedSections.length, sections]);
const onPressIcon = useCallback((id: string) => {
setAlternateAppIcon(id)
const targetIcon = Platform.OS === "android" ? toAndroidAliasIconName(id) : id;
setAlternateAppIcon(targetIcon)
.then(() => {
setCurrentIcon(targetIcon);
AS.setItem(STORAGE_KEYS.ICON_SETTING, "true");
if (Platform.OS === "android") {
ToastAndroid.show(
@@ -97,23 +167,75 @@ export const LauncherIconSettings = () => {
});
}, []);
const renderRow = useCallback(
({ item }: { item: IconRow }) => (
<View style={{ flexDirection: "row", paddingHorizontal: 4 }}>
{item.map(({ id, name, icon }) => (
<IconItem
key={id}
id={id}
name={name}
icon={icon}
isSelected={id === currentIcon}
onPress={onPressIcon}
textColor={colors.text}
/>
))}
</View>
),
[currentIcon, colors.text, onPressIcon]
const toggleSection = useCallback((key: string) => {
setExpandedSections((prev) => (prev.includes(key) ? [] : [key]));
}, []);
const renderSection = useCallback(
({ item }: { item: IconSection }) => {
const expanded = expandedSections.includes(item.key);
const hasCurrent = item.items.some(({ id }) => matchesCurrentIcon(id, currentIcon));
return (
<ListItem.Accordion
animation={{ type: "timing", duration: 180 }}
isExpanded={expanded}
onPress={() => toggleSection(item.key)}
containerStyle={{
marginHorizontal: 0,
marginBottom: 0,
borderTopWidth: 1,
borderTopColor: colors.border,
borderBottomWidth: expanded ? 0 : 1,
borderBottomColor: colors.border,
backgroundColor: colors.background,
paddingHorizontal: 0,
paddingVertical: 0,
}}
content={
<>
<ListItem.Content style={{ paddingHorizontal: 16, paddingVertical: 14 }}>
<ListItem.Title style={{ color: colors.text, fontSize: 16, fontWeight: "500" }}>
{item.title}
</ListItem.Title>
<ListItem.Subtitle style={{ color: hasCurrent ? fixed.primary : colors.textSecondary, fontSize: 12, marginTop: 3 }}>
{item.items.length}{hasCurrent ? " • 現在選択中" : ""}
</ListItem.Subtitle>
</ListItem.Content>
</>
}
noIcon={false}
>
<View style={{ backgroundColor: colors.backgroundSecondary, borderTopWidth: 1, borderTopColor: colors.border }}>
{item.rows.map((row, index) => (
<View
key={`${item.key}-${index}`}
style={{
flexDirection: "row",
paddingHorizontal: 8,
paddingVertical: 6,
borderBottomWidth: index === item.rows.length - 1 ? 0 : 1,
borderBottomColor: colors.border,
}}
>
{row.map(({ id, name, icon }) => (
<IconItem
key={id}
id={id}
name={name}
icon={icon}
isSelected={matchesCurrentIcon(id, currentIcon)}
onPress={onPressIcon}
textColor={colors.text}
/>
))}
</View>
))}
</View>
</ListItem.Accordion>
);
},
[colors.backgroundSecondary, colors.backgroundTertiary, colors.border, colors.text, colors.textSecondary, currentIcon, expandedSections, fixed.primary, onPressIcon, toggleSection]
);
return (
@@ -132,55 +254,50 @@ export const LauncherIconSettings = () => {
) : (
<FlatList
style={{ flex: 1, backgroundColor: colors.background }}
data={rows}
keyExtractor={(_item, index) => String(index)}
renderItem={renderRow}
data={sections}
keyExtractor={(item) => item.key}
renderItem={renderSection}
ItemSeparatorComponent={undefined}
ListHeaderComponent={
currentIconItem ? (
<>
<Text
style={{
backgroundColor: colors.backgroundTertiary,
color: colors.text,
}}
>
: {currentIconItem.name}
</Text>
<View
style={{
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
backgroundColor: colors.backgroundTertiary,
padding: 10,
}}
>
<View style={{ backgroundColor: colors.background, borderBottomWidth: 1, borderBottomColor: colors.border }}>
<ListItem containerStyle={{ backgroundColor: colors.background, paddingVertical: 10 }}>
<ListItem.Content>
<ListItem.Title style={{ color: colors.text, fontSize: 14 }}>
</ListItem.Title>
<ListItem.Subtitle style={{ color: fixed.primary, fontSize: 12, marginTop: 2 }}>
{currentIconItem.id}
</ListItem.Subtitle>
</ListItem.Content>
</ListItem>
<View
style={{
borderWidth: 1,
borderRadius: 10,
borderColor: colors.border,
margin: 10,
padding: 10,
backgroundColor: colors.background,
flexDirection: "row",
alignItems: "center",
paddingHorizontal: 16,
paddingBottom: 14,
}}
>
<Image
source={currentIconItem.icon}
style={{ width: 80, height: 80, borderRadius: 8 }}
style={{ width: 56, height: 56, borderRadius: 8 }}
resizeMode="contain"
/>
<View style={{ marginLeft: 14, flex: 1 }}>
<Text style={{ color: colors.text, fontSize: 15, fontWeight: "500" }}>
JR四国非公式アプリ
</Text>
<Text style={{ color: colors.textSecondary, fontSize: 12, marginTop: 4 }}>
</Text>
</View>
</View>
<Text style={{ color: colors.text }}>JR四国非公式アプリ</Text>
</View>
</>
) : null
}
getItemLayout={(_data, index) => ({
length: 120,
offset: 120 * index,
index,
})}
initialNumToRender={5}
maxToRenderPerBatch={5}
windowSize={5}
+35 -9
View File
@@ -24,23 +24,49 @@ export const LayoutSettings = ({
setTrainPosition,
headerSize,
setHeaderSize,
allowHubIcon = false,
}) => {
const { goBack } = useNavigation() as any;
const { colors, fixed } = useThemeColors();
const visibleIconSetting =
allowHubIcon || iconSetting !== "hub" ? iconSetting : "original";
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
<SheetHeaderItem title="レイアウト設定" LeftItem={{ title: " 設定", onPress: goBack }} />
<ScrollView style={{ flex: 1, backgroundColor: colors.background }}>
<View style={{ flex: 1 }}>
<SwitchArea
str="列車アイコン表示"
bool={iconSetting}
setBool={setIconSetting}
falseImage={require("../../assets/configuration/icon_default.jpg")}
trueImage={require("../../assets/configuration/icon_original.jpg")}
falseText={"本家\n(文字アイコン)"}
trueText={"オリジナル\n(車種アイコン)"}
/>
<View
style={{
backgroundColor: "#00000010",
borderRadius: 10,
margin: 5,
}}
>
<TripleSwitchArea
str="列車アイコン表示"
bool={visibleIconSetting}
setBool={setIconSetting}
firstItem={{
firstImage: require("../../assets/configuration/icon_default.jpg"),
firstText: "本家\n(文字アイコン)",
firstValue: "default",
}}
secondItem={{
secondImage: require("../../assets/configuration/icon_original.jpg"),
secondText: "オリジナル\n(車種アイコン)",
secondValue: "original",
}}
thirdItem={
allowHubIcon
? {
thirdImage: require("../../assets/relationLogo/unyohub_logo.webp"),
thirdText: "鉄道運用Hub\n(Hubアイコン)",
thirdValue: "hub",
}
: undefined
}
/>
</View>
<SwitchArea
str="列車表示"
bool={uiSetting}
@@ -0,0 +1,86 @@
import React from "react";
import { View, Text, ScrollView } from "react-native";
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
type OperationInfoSettingsProps = {
operationLandscapeEnabled: boolean;
setOperationLandscapeEnabled: (value: boolean) => void;
operationCaptureEnabled: boolean;
setOperationCaptureEnabled: (value: boolean) => void;
};
export const OperationInfoSettings = ({
operationLandscapeEnabled,
setOperationLandscapeEnabled,
operationCaptureEnabled,
setOperationCaptureEnabled,
}: OperationInfoSettingsProps) => {
const { goBack } = useNavigation();
const { colors, fixed } = useThemeColors();
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
<SheetHeaderItem
title="運行情報設定(β)"
LeftItem={{ title: " 設定", onPress: goBack }}
/>
<ScrollView style={{ flex: 1, backgroundColor: colors.background }}>
<SettingRow
title="横倒し表示機能"
description="端末を横向きにしたとき、運行情報ページを見やすく再構成した専用表示を有効にします。"
value={operationLandscapeEnabled}
onValueChange={setOperationLandscapeEnabled}
/>
<SettingRow
title="スクリーンショット切り出し機能"
description="運行情報ページ内に、項目単位・全体単位の切り出しボタンを表示します。"
value={operationCaptureEnabled}
onValueChange={setOperationCaptureEnabled}
/>
</ScrollView>
</View>
);
};
type SettingRowProps = {
title: string;
description: string;
value: boolean;
onValueChange: (value: boolean) => void;
};
const SettingRow = ({ title, description, value, onValueChange }: SettingRowProps) => {
const { colors, fixed } = useThemeColors();
return (
<View
style={{
paddingHorizontal: 15,
paddingVertical: 14,
borderBottomWidth: 1,
borderBottomColor: colors.borderSecondary ?? "#ccc",
backgroundColor: colors.surface,
gap: 8,
}}
>
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
<View style={{ flex: 1 }}>
<Text style={{ fontSize: 16, fontWeight: "600", color: colors.text }}>
{title}
</Text>
<Text style={{ marginTop: 4, fontSize: 13, lineHeight: 19, color: colors.textSecondary }}>
{description}
</Text>
</View>
<Switch
value={value}
onValueChange={onValueChange}
color={fixed.primary}
/>
</View>
</View>
);
};
@@ -0,0 +1,528 @@
import React, { useRef, useState } from "react";
import {
Alert,
ScrollView,
StyleSheet,
Text,
TouchableOpacity,
View,
} from "react-native";
import { Switch } from "@rneui/themed";
import { useNavigation } from "@react-navigation/native";
import { MaterialCommunityIcons } from "@expo/vector-icons";
import Swipeable from "react-native-gesture-handler/Swipeable";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import { useNotification } from "@/stateBox/useNotifications";
export const ResearchToolsSettings = () => {
const navigation = useNavigation<any>();
const { colors, fixed } = useThemeColors();
const { expoPushToken } = useNotification();
const {
updatePermission,
mockApiFeatureEnabled,
setMockApiFeatureEnabled,
recorderState,
recordingSnapshotCount,
recordingList,
startRecording,
stopRecording,
startPlayback,
stopPlayback,
deleteRecording,
exportRecordingFile,
exportAllRecordingsFile,
importRecordingFile,
} = useTrainMenu();
const showResearchTools = __DEV__ || updatePermission;
const recordingSwipeRefs = useRef<Record<string, { close: () => void } | null>>({});
const [recordingFileStatus, setRecordingFileStatus] = useState<{
type: "info" | "success" | "error";
text: string;
}>({ type: "info", text: "録画JSONの書き出しと読み込みができます。" });
const closeRecordingSwipe = (id: string) => {
recordingSwipeRefs.current[id]?.close();
};
const confirmDeleteRecording = (id: string, label: string) => {
closeRecordingSwipe(id);
Alert.alert("録画を削除", `${label} の録画を削除しますか?`, [
{ text: "キャンセル", style: "cancel" },
{
text: "削除",
style: "destructive",
onPress: () => {
void deleteRecording(id);
},
},
]);
};
const setFileStatus = (type: "info" | "success" | "error", text: string) => {
setRecordingFileStatus({ type, text });
};
const handleExportRecordingFile = async (id: string, label: string) => {
try {
await exportRecordingFile(id);
setFileStatus("success", `${label} の録画JSONを書き出しました。`);
} catch (error) {
setFileStatus(
"error",
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
);
}
};
const handleExportAllRecordingsFile = async () => {
try {
await exportAllRecordingsFile();
setFileStatus("success", `${recordingList.length}件の録画JSONを書き出しました。`);
} catch (error) {
setFileStatus(
"error",
`録画JSONを書き出せませんでした: ${(error as Error).message}`,
);
}
};
const handleImportRecordingFile = async () => {
try {
const result = await importRecordingFile();
if (!result) {
setFileStatus("info", "録画JSONの読み込みをキャンセルしました。");
return;
}
setFileStatus(
"success",
result.overwrittenCount > 0
? `${result.importedCount}件を読み込みました。${result.overwrittenCount}件は同じIDのため上書きしました。`
: `${result.importedCount}件を読み込みました。`,
);
} catch (error) {
setFileStatus(
"error",
`録画JSONを読み込めませんでした: ${(error as Error).message}`,
);
}
};
const openRecordingDatabase = () => {
const uri = `https://experimental.shikoku-railinfo.haruk.in/position-board?from=eachTrainInfo&userID=${encodeURIComponent(expoPushToken || "")}`;
const params = {
uri,
importRecordingDownloads: true,
useExitButton: false,
};
const parentNavigation = navigation.getParent?.();
if (parentNavigation) {
parentNavigation.navigate("generalWebView", params);
return;
}
navigation.navigate("generalWebView", params);
};
return (
<View style={[styles.container, { backgroundColor: fixed.primary }]}>
<SheetHeaderItem
title="調査ツール"
LeftItem={{
title: " 戻る",
onPress: () => navigation.goBack(),
position: "left",
}}
/>
<ScrollView
style={[styles.content, { backgroundColor: colors.backgroundSecondary }]}
contentContainerStyle={styles.contentInner}
>
{!showResearchTools ? (
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>調</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>調</Text>
</View>
) : (
<>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: モックAPI検証</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}></Text>
<View style={styles.switchRow}>
<Text style={[styles.debugCurrentText, { color: colors.textPrimary, fontSize: 14 }]}>API検証機能</Text>
<Switch
value={mockApiFeatureEnabled}
onValueChange={setMockApiFeatureEnabled}
color={fixed.primary}
/>
</View>
</View>
<View
style={[
styles.debugSection,
{
backgroundColor: colors.surface,
borderColor: colors.borderSecondary,
},
]}
>
<Text style={[styles.debugTitle, { color: colors.textPrimary }]}>デバッグ: 走行位置録画</Text>
<Text style={[styles.debugDescription, { color: colors.textSecondary }]}>OFFになります</Text>
<View style={styles.statusRow}>
<View
style={{
width: 10,
height: 10,
borderRadius: 5,
backgroundColor:
recorderState === "recording"
? "#e53935"
: recorderState === "playing"
? "#43a047"
: colors.borderSecondary,
}}
/>
<Text style={[styles.debugCurrentText, { color: colors.textSecondary, fontSize: 13 }]}>
{recorderState === "recording"
? `録画中… ${recordingSnapshotCount} スナップショット`
: recorderState === "playing"
? "再生中"
: `${recordingList.length} 件の録画`}
</Text>
</View>
<View style={styles.buttonRow}>
{recorderState === "idle" && (
<TouchableOpacity onPress={startRecording} style={styles.recordButton}>
<Text style={styles.primaryButtonText}> </Text>
</TouchableOpacity>
)}
{recorderState === "recording" && (
<TouchableOpacity onPress={stopRecording} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}> </Text>
</TouchableOpacity>
)}
{recorderState === "playing" && (
<TouchableOpacity onPress={stopPlayback} style={[styles.neutralButton, { backgroundColor: colors.borderSecondary }]}>
<Text style={[styles.neutralButtonText, { color: colors.textPrimary }]}> </Text>
</TouchableOpacity>
)}
</View>
<View style={[styles.fileBox, { backgroundColor: colors.backgroundTertiary }]}>
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "600" }}>JSONファイル</Text>
<View style={styles.buttonRow}>
<TouchableOpacity
onPress={() => {
void handleImportRecordingFile();
}}
disabled={recorderState === "recording"}
style={{
backgroundColor: recorderState === "recording" ? colors.borderSecondary : fixed.primary,
borderRadius: 8,
paddingHorizontal: 12,
paddingVertical: 8,
opacity: recorderState === "recording" ? 0.6 : 1,
}}
>
<Text style={{ color: fixed.textOnPrimary, fontWeight: "bold", fontSize: 12 }}>JSONを読み込む</Text>
</TouchableOpacity>
<TouchableOpacity
onPress={() => {
void handleExportAllRecordingsFile();
}}
disabled={recordingList.length === 0 || recorderState === "recording"}
style={{
backgroundColor:
recordingList.length === 0 || recorderState === "recording"
? colors.borderSecondary
: colors.surface,
borderRadius: 8,
borderWidth: 1,
borderColor: colors.borderSecondary,
paddingHorizontal: 12,
paddingVertical: 8,
opacity: recordingList.length === 0 || recorderState === "recording" ? 0.6 : 1,
}}
>
<Text style={{ color: colors.textPrimary, fontWeight: "bold", fontSize: 12 }}></Text>
</TouchableOpacity>
</View>
<View
style={{
borderRadius: 8,
paddingHorizontal: 10,
paddingVertical: 8,
backgroundColor:
recordingFileStatus.type === "success"
? "#43a04722"
: recordingFileStatus.type === "error"
? "#e5393522"
: colors.surface,
borderWidth: 1,
borderColor:
recordingFileStatus.type === "success"
? "#43a04755"
: recordingFileStatus.type === "error"
? "#e5393555"
: colors.borderSecondary,
}}
>
<Text style={{ color: colors.textSecondary, fontSize: 12, lineHeight: 18 }}>{recordingFileStatus.text}</Text>
</View>
</View>
{recordingList.length > 0 && recorderState !== "recording" && (
<View style={styles.recordingList}>
{recordingList.map((rec) => {
const isPlaying = recorderState === "playing";
const durationSec = Math.round(rec.durationMs / 1000);
const durationLabel = durationSec >= 60
? `${Math.floor(durationSec / 60)}${durationSec % 60}`
: `${durationSec}`;
const dateLabel = new Date(rec.recordedAt).toLocaleString("ja-JP", {
month: "numeric",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
const recordingRow = (
<TouchableOpacity
onPress={() => startPlayback(rec.id)}
onLongPress={() => {
void handleExportRecordingFile(rec.id, dateLabel);
}}
disabled={isPlaying}
activeOpacity={0.72}
style={{
flexDirection: "row",
alignItems: "center",
backgroundColor: colors.backgroundSecondary,
borderRadius: 8,
padding: 12,
gap: 10,
opacity: isPlaying ? 0.6 : 1,
}}
>
<View style={{ flex: 1 }}>
<Text style={{ color: colors.textPrimary, fontSize: 13, fontWeight: "bold" }}>{dateLabel}</Text>
<Text style={{ color: colors.textSecondary, fontSize: 11 }}>{rec.snapshotCount} / {durationLabel}</Text>
</View>
<View style={{ alignItems: "flex-end", gap: 4 }}>
<View style={styles.playbackHint}>
<MaterialCommunityIcons
name={isPlaying ? "pause-circle-outline" : "play-circle-outline"}
size={18}
color={isPlaying ? colors.textTertiary : "#43a047"}
/>
<Text
style={{
color: isPlaying ? colors.textTertiary : colors.textPrimary,
fontSize: 12,
fontWeight: "600",
}}
>
{isPlaying ? "再生中は操作不可" : "タップで再生"}
</Text>
</View>
{!isPlaying && (
<Text style={{ color: colors.textTertiary, fontSize: 10 }}> / </Text>
)}
</View>
<MaterialCommunityIcons name="chevron-right" size={18} color={colors.iconSecondary} />
</TouchableOpacity>
);
if (isPlaying) {
return <View key={rec.id}>{recordingRow}</View>;
}
return (
<Swipeable
key={rec.id}
ref={(instance) => {
recordingSwipeRefs.current[rec.id] = instance;
}}
friction={2}
overshootRight={false}
rightThreshold={48}
renderRightActions={() => (
<View style={styles.deleteAction}>
<MaterialCommunityIcons name="trash-can-outline" size={18} color="#fff" />
<Text style={styles.deleteActionText}></Text>
</View>
)}
onSwipeableOpen={() => confirmDeleteRecording(rec.id, dateLabel)}
>
{recordingRow}
</Swipeable>
);
})}
</View>
)}
<TouchableOpacity
onPress={openRecordingDatabase}
activeOpacity={0.78}
style={[
styles.databaseButton,
{
backgroundColor: fixed.primary,
borderColor: fixed.primary,
},
]}
>
<MaterialCommunityIcons
name="database-search-outline"
size={18}
color={fixed.textOnPrimary}
/>
<Text style={[styles.databaseButtonText, { color: fixed.textOnPrimary }]}>
</Text>
</TouchableOpacity>
</View>
</>
)}
</ScrollView>
</View>
);
};
const styles = StyleSheet.create({
container: {
flex: 1,
backgroundColor: "#0099CC",
},
content: {
flex: 1,
backgroundColor: "#f8f8fc",
},
contentInner: {
paddingHorizontal: 14,
paddingBottom: 40,
paddingTop: 20,
gap: 12,
},
debugSection: {
borderRadius: 12,
borderWidth: 1,
padding: 14,
gap: 10,
},
debugTitle: {
fontSize: 15,
fontWeight: "bold",
},
debugDescription: {
fontSize: 12,
lineHeight: 18,
},
debugCurrentText: {
fontSize: 11,
lineHeight: 16,
},
switchRow: {
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
},
statusRow: {
flexDirection: "row",
alignItems: "center",
marginTop: 8,
gap: 8,
},
buttonRow: {
flexDirection: "row",
gap: 8,
marginTop: 10,
flexWrap: "wrap",
},
recordButton: {
backgroundColor: "#e53935",
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 8,
},
neutralButton: {
borderRadius: 8,
paddingHorizontal: 14,
paddingVertical: 8,
},
primaryButtonText: {
color: "#fff",
fontWeight: "bold",
fontSize: 13,
},
neutralButtonText: {
fontWeight: "bold",
fontSize: 13,
},
fileBox: {
marginTop: 4,
gap: 8,
borderRadius: 8,
padding: 10,
},
recordingList: {
marginTop: 10,
gap: 6,
},
playbackHint: {
flexDirection: "row",
alignItems: "center",
gap: 4,
},
deleteAction: {
width: 96,
borderRadius: 8,
backgroundColor: "#e53935",
alignItems: "center",
justifyContent: "center",
marginLeft: 6,
},
deleteActionText: {
color: "#fff",
fontSize: 11,
fontWeight: "bold",
marginTop: 4,
},
databaseButton: {
minHeight: 46,
borderRadius: 10,
borderWidth: 1,
paddingHorizontal: 14,
paddingVertical: 12,
flexDirection: "row",
alignItems: "center",
justifyContent: "center",
gap: 8,
marginTop: 4,
},
databaseButtonText: {
fontSize: 14,
fontWeight: "bold",
},
});
+18 -1
View File
@@ -16,8 +16,9 @@ import { SwitchArea } from "../atom/SwitchArea";
import { useNotification } from "../../stateBox/useNotifications";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
const versionCode = "7.0"; // Update this version code as needed
const versionCode = "7.1";
export const SettingTopPage = ({
testNFC,
@@ -29,6 +30,8 @@ export const SettingTopPage = ({
const { expoPushToken } = useNotification();
const { colors, fixed, colorTheme, setColorTheme } = useThemeColors();
const navigation = useNavigation<any>();
const { updatePermission } = useTrainMenu();
const showResearchTools = __DEV__ || updatePermission;
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
@@ -118,6 +121,12 @@ export const SettingTopPage = ({
navigation.navigate("setting", { screen: "SoundSettings" })
}
/>
<SettingList
string="運行情報設定(β)"
onPress={() =>
navigation.navigate("setting", { screen: "OperationInfoSettings" })
}
/>
<SectionHeader title="通知・データ" />
<SettingList
@@ -135,6 +144,14 @@ export const SettingTopPage = ({
navigation.navigate("setting", { screen: "DataSourceSettings" })
}
/>
{showResearchTools && (
<SettingList
string="調査ツール"
onPress={() =>
navigation.navigate("setting", { screen: "ResearchToolsSettings" })
}
/>
)}
<SectionHeader title="その他" />
<SettingList
+115 -20
View File
@@ -10,7 +10,7 @@ import {
ToastAndroid,
} from "react-native";
import { createStackNavigator } from "@react-navigation/stack";
import { TransitionPresets } from "@react-navigation/stack";
import { pushTransitionOptions } from "@/lib/stackOption";
import * as ExpoFelicaReader from "../../modules/expo-felica-reader/src";
import * as Updates from "expo-updates";
import { AS } from "../../storageControl";
@@ -22,15 +22,36 @@ import { FavoriteSettings } from "./FavoriteSettings";
import { NotificationSettings } from "./NotificationSettings";
import { LauncherIconSettings } from "./LauncherIconSettings";
import { DataSourceSettings } from "./DataSourceSettings";
import { ResearchToolsSettings } from "./ResearchToolsSettings";
import { FelicaHistoryPage } from "./FelicaHistoryPage";
import { SoundSettings } from "./SoundSettings";
import { OperationInfoSettings } from "./OperationInfoSettings";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
import {
normalizeIconDisplayMode,
type IconDisplayMode,
} from "@/lib/iconDisplayMode";
const readStoredValue = async <T,>(
key: string,
fallback: T,
mapValue?: (value: any) => T,
) => {
try {
const value = await AS.getItem(key);
return mapValue ? mapValue(value) : (value as T);
} catch {
return fallback;
}
};
const Stack = createStackNavigator();
export default function Setting(props) {
const {
navigation: { navigate },
} = props;
const [iconSetting, setIconSetting] = useState(false);
const { updatePermission } = useTrainMenu();
const [iconSetting, setIconSetting] = useState<IconDisplayMode>("original");
const [mapSwitch, setMapSwitch] = useState(false);
const [stationMenu, setStationMenu] = useState(false);
const [usePDFView, setUsePDFView] = useState(false);
@@ -39,16 +60,54 @@ export default function Setting(props) {
const [headerSize, setHeaderSize] = useState("default");
const [startPage, setStartPage] = useState(false);
const [uiSetting, setUiSetting] = useState("tokyo");
const [operationLandscapeEnabled, setOperationLandscapeEnabled] = useState(false);
const [operationCaptureEnabled, setOperationCaptureEnabled] = useState(false);
useLayoutEffect(() => {
AS.getItem(STORAGE_KEYS.ICON_SWITCH).then(setIconSetting);
AS.getItem(STORAGE_KEYS.MAP_SWITCH).then(setMapSwitch);
AS.getItem(STORAGE_KEYS.STATION_SWITCH).then(setStationMenu);
AS.getItem(STORAGE_KEYS.USE_PDF_VIEW).then(setUsePDFView);
AS.getItem(STORAGE_KEYS.TRAIN_SWITCH).then(setTrainMenu);
AS.getItem(STORAGE_KEYS.TRAIN_POSITION_SWITCH).then(setTrainPosition);
AS.getItem(STORAGE_KEYS.HEADER_SIZE).then(setHeaderSize);
AS.getItem(STORAGE_KEYS.START_PAGE).then(setStartPage);
AS.getItem(STORAGE_KEYS.UI_SETTING).then(setUiSetting);
void Promise.all([
readStoredValue(STORAGE_KEYS.ICON_SWITCH, "original", normalizeIconDisplayMode),
readStoredValue(STORAGE_KEYS.MAP_SWITCH, false, (value) => value === true || value === "true"),
readStoredValue(STORAGE_KEYS.STATION_SWITCH, false, (value) => value === true || value === "true"),
readStoredValue(STORAGE_KEYS.USE_PDF_VIEW, false, (value) => value === true || value === "true"),
readStoredValue(STORAGE_KEYS.TRAIN_SWITCH, false, (value) => value === true || value === "true"),
readStoredValue(STORAGE_KEYS.TRAIN_POSITION_SWITCH, false, (value) => value === true || value === "true"),
readStoredValue(STORAGE_KEYS.HEADER_SIZE, "default"),
readStoredValue(STORAGE_KEYS.START_PAGE, false, (value) => value === true || value === "true"),
readStoredValue(STORAGE_KEYS.UI_SETTING, "tokyo"),
readStoredValue(
STORAGE_KEYS.OPERATION_INFO_LANDSCAPE_ENABLED,
false,
(value) => value === true || value === "true",
),
readStoredValue(
STORAGE_KEYS.OPERATION_INFO_CAPTURE_ENABLED,
false,
(value) => value === true || value === "true",
),
]).then(([
nextIconSetting,
nextMapSwitch,
nextStationMenu,
nextUsePdfView,
nextTrainMenu,
nextTrainPosition,
nextHeaderSize,
nextStartPage,
nextUiSetting,
nextOperationLandscapeEnabled,
nextOperationCaptureEnabled,
]) => {
setIconSetting(nextIconSetting);
setMapSwitch(nextMapSwitch);
setStationMenu(nextStationMenu);
setUsePDFView(nextUsePdfView);
setTrainMenu(nextTrainMenu);
setTrainPosition(nextTrainPosition);
setHeaderSize(nextHeaderSize);
setStartPage(nextStartPage);
setUiSetting(nextUiSetting);
setOperationLandscapeEnabled(nextOperationLandscapeEnabled);
setOperationCaptureEnabled(nextOperationCaptureEnabled);
});
}, []);
const testNFC = async () => {
console.log("Testing NFC...");
@@ -73,8 +132,10 @@ export default function Setting(props) {
}
};
const updateAndReload = () => {
const iconSettingToSave =
updatePermission || iconSetting !== "hub" ? iconSetting : "original";
Promise.all([
AS.setItem(STORAGE_KEYS.ICON_SWITCH, iconSetting.toString()),
AS.setItem(STORAGE_KEYS.ICON_SWITCH, iconSettingToSave.toString()),
AS.setItem(STORAGE_KEYS.MAP_SWITCH, mapSwitch.toString()),
AS.setItem(STORAGE_KEYS.STATION_SWITCH, stationMenu.toString()),
AS.setItem(STORAGE_KEYS.USE_PDF_VIEW, usePDFView.toString()),
@@ -83,6 +144,8 @@ export default function Setting(props) {
AS.setItem(STORAGE_KEYS.HEADER_SIZE, headerSize),
AS.setItem(STORAGE_KEYS.START_PAGE, startPage.toString()),
AS.setItem(STORAGE_KEYS.UI_SETTING, uiSetting),
AS.setItem(STORAGE_KEYS.OPERATION_INFO_LANDSCAPE_ENABLED, operationLandscapeEnabled.toString()),
AS.setItem(STORAGE_KEYS.OPERATION_INFO_CAPTURE_ENABLED, operationCaptureEnabled.toString()),
]).then(() => Updates.reloadAsync());
};
return (
@@ -91,7 +154,7 @@ export default function Setting(props) {
name="settingTopPage"
options={{
gestureEnabled: false,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -111,7 +174,7 @@ export default function Setting(props) {
name="LayoutSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -136,6 +199,7 @@ export default function Setting(props) {
setUiSetting={setUiSetting}
headerSize={headerSize}
setHeaderSize={setHeaderSize}
allowHubIcon={updatePermission}
/>
)}
</Stack.Screen>
@@ -143,7 +207,7 @@ export default function Setting(props) {
name="NotificationSettings"
options={{
//gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -154,7 +218,7 @@ export default function Setting(props) {
name="LauncherIconSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -165,7 +229,7 @@ export default function Setting(props) {
name="FavoriteSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -176,18 +240,29 @@ export default function Setting(props) {
name="DataSourceSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={DataSourceSettings}
/>
<Stack.Screen
name="ResearchToolsSettings"
options={{
gestureEnabled: true,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={ResearchToolsSettings}
/>
<Stack.Screen
name="FelicaHistoryPage"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
@@ -198,13 +273,33 @@ export default function Setting(props) {
name="SoundSettings"
options={{
gestureEnabled: true,
...TransitionPresets.SlideFromRightIOS,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
component={SoundSettings}
/>
<Stack.Screen
name="OperationInfoSettings"
options={{
gestureEnabled: true,
...pushTransitionOptions,
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
}}
>
{(props) => (
<OperationInfoSettings
{...props}
operationLandscapeEnabled={operationLandscapeEnabled}
setOperationLandscapeEnabled={setOperationLandscapeEnabled}
operationCaptureEnabled={operationCaptureEnabled}
setOperationCaptureEnabled={setOperationCaptureEnabled}
/>
)}
</Stack.Screen>
</Stack.Navigator>
);
}
@@ -19,6 +19,7 @@ type hoge = {
name: string;
timeType: string;
time: string;
platformNum: string | null;
}[];
export const ExGridSimpleView: FC<{
data: hoge;
@@ -63,6 +64,7 @@ export const ExGridSimpleView: FC<{
timeType: string;
time: string;
isOperating: boolean;
platformNum: string | null;
}[];
} = {
"4": [], "5": [], "6": [], "7": [], "8": [], "9": [],
@@ -29,6 +29,7 @@ export const ExGridSimpleViewItem: FC<{
timeType: string;
time: string;
isOperating: boolean;
platformNum: string | null;
};
index: number;
array: {
@@ -233,6 +234,20 @@ export const ExGridSimpleViewItem: FC<{
{d.timeType}
</Text>
)}
{showLastStop && d.platformNum && (
<Text
style={{
fontSize: 10,
position: "absolute",
top: 36,
left: 28,
fontWeight: "bold",
color: isCancelled ? "gray" : colors.text,
}}
>
{d.platformNum}
</Text>
)}
<Text
style={{
fontSize: 12,
-2
View File
@@ -308,8 +308,6 @@ export const ExGridView: FC<{
<Animated.ScrollView
style={[{ width: width }, animatedStyle]}
pinchGestureEnabled={false}
minimumZoomScale={0.5}
maximumZoomScale={3.0}
scrollEnabled={scrollEnabled}
stickyHeaderIndices={
groupKeys.at(0) ? groupKeys.map((_, i) => i * 2) : []
+1
View File
@@ -11,6 +11,7 @@ type hoge = {
name: string;
timeType: string;
time: string;
platformNum: string | null;
};
export const ListView: FC<{
data: hoge[];
+37 -10
View File
@@ -2,7 +2,7 @@ import { migrateTrainName } from "@/lib/eachTrainInfoCoreLib/migrateTrainName";
import { getStringConfig } from "@/lib/getStringConfig";
import { getTrainType } from "@/lib/getTrainType";
import { useAllTrainDiagram } from "@/stateBox/useAllTrainDiagram";
import { FC, useCallback, useEffect, useMemo, useState } from "react";
import { FC, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { View, Text, TouchableOpacity } from "react-native";
import Animated, {
useSharedValue,
@@ -29,6 +29,7 @@ export const ListViewItem: FC<{
name: string;
timeType: string;
time: string;
platformNum: string | null;
};
showVehicle?: boolean;
showAppSource?: boolean;
@@ -58,7 +59,7 @@ export const ListViewItem: FC<{
const [
typeString,
trainName,
fontAvailable,
fontFamily,
isOneMan,
infogram,
priority,
@@ -78,7 +79,7 @@ export const ListViewItem: FC<{
to_data_color,
train_number_override,
} = customTrainDataDetector(d.trainNumber, allCustomTrainData);
const [typeString, fontAvailable, isOneMan] = getStringConfig(
const [typeString, fontFamily, isOneMan] = getStringConfig(
type,
d.trainNumber
);
@@ -110,7 +111,7 @@ export const ListViewItem: FC<{
return [
typeString,
displayName,
fontAvailable,
fontFamily,
isOneMan,
infogram,
priority,
@@ -127,7 +128,7 @@ export const ListViewItem: FC<{
return [
typeString,
"",
fontAvailable,
fontFamily,
isOneMan,
infogram,
priority,
@@ -144,7 +145,7 @@ export const ListViewItem: FC<{
migrateTrainName(
trainDataArray[trainDataArray.length - 1].split(",")[0] + "行き"
),
fontAvailable,
fontFamily,
isOneMan,
infogram,
priority,
@@ -240,12 +241,14 @@ export const ListViewItem: FC<{
}, [showVehicle, showAppSource, d.trainNumber, getTodayOperationByTrainId, getUnyohubByTrainNumber, getElesiteByTrainNumber, isDark]);
const [sourceIndex, setSourceIndex] = useState(0);
const isCyclingRef = useRef(false);
const fadeAnim = useSharedValue(1);
const fadeStyle = useAnimatedStyle(() => ({
opacity: fadeAnim.value,
}));
const advanceSource = useCallback(() => {
isCyclingRef.current = true;
setSourceIndex((i) => (i + 1) % vehicleSources.length);
}, [vehicleSources.length]);
@@ -255,13 +258,20 @@ export const ListViewItem: FC<{
fadeAnim.value = withTiming(0, { duration: 300 }, (finished) => {
if (finished) {
runOnJS(advanceSource)();
fadeAnim.value = withTiming(1, { duration: 300 });
}
});
}, 3000);
return () => clearInterval(cycle);
}, [showVehicle, vehicleSources.length, advanceSource]);
// sourceIndex が変わった(= 新コンテンツが描画された)後にフェードインを開始
useEffect(() => {
if (isCyclingRef.current) {
isCyclingRef.current = false;
fadeAnim.value = withTiming(1, { duration: 300 });
}
}, [sourceIndex]);
useEffect(() => {
setSourceIndex(0);
fadeAnim.value = 1;
@@ -304,15 +314,18 @@ export const ListViewItem: FC<{
<Text
style={{
fontSize: 15,
fontFamily: fontAvailable ? "JR-Nishi" : undefined,
fontWeight: !fontAvailable ? "bold" : undefined,
paddingTop: fontAvailable ? 2 : 0,
fontFamily,
fontWeight: fontFamily ? undefined : "bold",
paddingTop: fontFamily ? 2 : 0,
paddingLeft: 10,
color: isCancelled ? "gray" : color,
textDecorationLine: isCancelled ? "line-through" : "none",
}}
>
{typeString}
{fontFamily === "JR-WEST-PLUS" ? (
<Text style={{ fontFamily: "DiaPro", fontSize: 7 }}>{"\u00A0"}</Text>
) : null}
</Text>
{showVehicle
? (() => {
@@ -376,6 +389,20 @@ export const ListViewItem: FC<{
>
{trainName}
</Text>
{d.platformNum ? (
<View
style={{
backgroundColor: isCancelled ? "gray" : colors.text,
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 1,
}}
>
<Text style={{ fontSize: 11, color: colors.diagramBackground, fontWeight: "bold" }}>
{d.platformNum}
</Text>
</View>
) : null}
</View>
</View>
</TouchableOpacity>
@@ -1,5 +1,6 @@
import { FC, useEffect, useRef, useState } from "react";
import {
Animated,
View,
Text,
ScrollView,
@@ -70,7 +71,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
const { colors, fixed } = useThemeColors();
const tabBarHeight = useBottomTabBarHeight();
const containerRef = useRef<View>(null);
const { keyboardVisible: keyBoardVisible, measuredOffset: keyboardOffset } =
const { keyboardVisible: keyBoardVisible, animatedOffset: keyboardOffset } =
useKeyboardAvoid({ measureRef: containerRef, tabBarHeight });
const [input, setInput] = useState("");
const [displayMode, setDisplayMode] = useState<
@@ -90,6 +91,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
name: string;
timeType: string;
time: string;
platformNum: string | null;
}[];
const [showTypeFiltering, setShowTypeFiltering] = useState(false);
const [showLastStop, setShowLastStop] = useState(false);
@@ -159,7 +161,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
return station === stationName;
})
.forEach((x) => {
const [name, timeType, time] = x.split(",");
const [name, timeType, time, platformNum] = x.split(",");
if (!name || !timeType || !time) return;
const { type } = customTrainDataDetector(d, allCustomTrainData);
@@ -169,6 +171,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
name,
timeType,
time,
platformNum: platformNum ?? null,
};
// //条件によってフィルタリング
if (!threw && timeType && timeType.includes("通")) return;
@@ -274,7 +277,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
}, [currentStationDiagram, currentTrain]);
return (
<View
<Animated.View
ref={containerRef}
style={{ flex: 1, backgroundColor: fixed.primary, paddingBottom: keyboardOffset }}
>
@@ -613,7 +616,7 @@ export const StationDiagramView: FC<props> = ({ route }) => {
{keyBoardVisible || (
<BigButton onPress={() => goBack()} string="閉じる" />
)}
</View>
</Animated.View>
);
};
+28 -2
View File
@@ -1,4 +1,5 @@
import React, { FC } from "react";
import { Image, StyleSheet, View, Platform } from "react-native";
import { Marker } from "react-native-maps";
import { useNavigation } from "@react-navigation/native";
import { useStationList } from "@/stateBox/useStationList";
@@ -18,6 +19,7 @@ export const MapPin: FC<Props> = (props) => {
const { index, indexBase, latlng, D, d, navigate, webview } = props;
const { goBack } = useNavigation();
const { getInjectJavascriptAddress } = useStationList();
return (
<Marker
key={index + indexBase}
@@ -25,13 +27,37 @@ export const MapPin: FC<Props> = (props) => {
latitude: parseFloat(latlng[0]),
longitude: parseFloat(latlng[1]),
}}
anchor={{ x: 0.5, y: 1 }}
tracksViewChanges={false}
onPress={() => {
const address = getInjectJavascriptAddress(D.StationNumber);
if (!address) return;
webview.current?.injectJavaScript(address);
if (navigate) goBack();
}}
image={require("../../assets/reccha-small.png")}
></Marker>
>
<View style={styles.markerWrap}>
<Image
source={require("../../assets/reccha-small.png")}
style={styles.markerImage}
resizeMode="contain"
/>
</View>
</Marker>
);
};
const MAP_PIN_SIZE = Platform.OS === "android" ? 32 : 36;
const styles = StyleSheet.create({
markerWrap: {
width: MAP_PIN_SIZE,
height: MAP_PIN_SIZE,
alignItems: "center",
justifyContent: "center",
},
markerImage: {
width: MAP_PIN_SIZE,
height: MAP_PIN_SIZE,
},
});
+3 -2
View File
@@ -21,7 +21,8 @@ export const SimpleSwitch = ({
<View style={{ flex: 1 }}>
<TouchableOpacity
style={{
backgroundColor: bool == value.toString() ? color : null,
backgroundColor:
bool === value || String(bool) === String(value) ? color : null,
padding: 5,
borderRadius: 5,
margin: 10,
@@ -32,7 +33,7 @@ export const SimpleSwitch = ({
}}
onPress={() => {
LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut);
setBool(value.toString());
setBool(value);
}}
>
<Image
+1 -1
View File
@@ -66,7 +66,7 @@ export const SwitchArea = ({
subText={trueText}
/>
</View>
{bool == "true" && children}
{(bool === true || String(bool) === "true") && children}
</View>
);
};
+15 -11
View File
@@ -5,11 +5,13 @@ export const TripleSwitchArea = ({
str,
bool,
setBool,
firstItem: { firstImage, firstText, firstValue },
secondItem: { secondImage, secondText, secondValue },
thirdItem: { thirdImage, thirdText, thirdValue },
firstItem,
secondItem,
thirdItem,
}) => {
const { colors } = useThemeColors();
const { firstImage, firstText, firstValue } = firstItem;
const { secondImage, secondText, secondValue } = secondItem;
return (
<View style={{ flexDirection: "column", padding: 10 }}>
<Text
@@ -41,14 +43,16 @@ export const TripleSwitchArea = ({
image={secondImage}
subText={secondText}
/>
<SimpleSwitch
bool={bool}
setBool={setBool}
color="red"
value={thirdValue}
image={thirdImage}
subText={thirdText}
/>
{thirdItem ? (
<SimpleSwitch
bool={bool}
setBool={setBool}
color="red"
value={thirdItem.thirdValue}
image={thirdItem.thirdImage}
subText={thirdItem.thirdText}
/>
) : null}
</View>
</View>
);
+44 -3
View File
@@ -1,6 +1,6 @@
import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
import { View, Text, TouchableOpacity, Linking } from "react-native";
import MapView from "react-native-maps";
import { View, Text, TouchableOpacity, Linking, Platform } from "react-native";
import MapView, { Marker } from "react-native-maps";
import { useCurrentTrain } from "../stateBox/useCurrentTrain";
import { useNavigation } from "@react-navigation/native";
import lineColorList from "../assets/originData/lineColorList";
@@ -12,6 +12,10 @@ import { UsefulBox } from "./TrainMenu/UsefulBox";
import { MapsButton } from "./TrainMenu/MapsButton";
import { useStationList } from "@/stateBox/useStationList";
import { useThemeColors } from "@/lib/theme";
import { useUserPosition } from "@/stateBox/useUserPosition";
const ANDROID_USER_LOCATION_MARKER_SIZE = 18;
const ANDROID_USER_LOCATION_INNER_SIZE = 8;
export default function TrainMenu({ style }) {
const { fixed, isDark } = useThemeColors();
const { webview } = useCurrentTrain();
@@ -24,6 +28,7 @@ export default function TrainMenu({ style }) {
mapsStationData: stationData,
} = useTrainMenu();
const { originalStationList } = useStationList();
const { position } = useUserPosition();
useEffect(() => {
const stationPinData = [];
Object.keys(lineList_LineWebID).forEach((d, indexBase) => {
@@ -49,7 +54,7 @@ export default function TrainMenu({ style }) {
<View style={{ height: "100%", backgroundColor: fixed.primary, ...style }}>
<MapView
style={{ flex: 1, width: "100%", height: "100%" }}
showsUserLocation={true}
showsUserLocation={Platform.OS !== "android"}
loadingEnabled={true}
showsMyLocationButton={false}
moveOnMarkerPress={false}
@@ -64,6 +69,42 @@ export default function TrainMenu({ style }) {
longitudeDelta: 1.8,
}}
>
{Platform.OS === "android" && position ? (
<Marker
key="android-user-location"
coordinate={{
latitude: position.coords.latitude,
longitude: position.coords.longitude,
}}
anchor={{ x: 0.5, y: 0.5 }}
tracksViewChanges={false}
zIndex={999}
>
<View
style={{
width: ANDROID_USER_LOCATION_MARKER_SIZE,
height: ANDROID_USER_LOCATION_MARKER_SIZE,
borderRadius: ANDROID_USER_LOCATION_MARKER_SIZE / 2,
backgroundColor: "rgba(10, 132, 255, 0.24)",
borderWidth: 1,
borderColor: "rgba(10, 132, 255, 0.42)",
alignItems: "center",
justifyContent: "center",
}}
>
<View
style={{
width: ANDROID_USER_LOCATION_INNER_SIZE,
height: ANDROID_USER_LOCATION_INNER_SIZE,
borderRadius: ANDROID_USER_LOCATION_INNER_SIZE / 2,
backgroundColor: "#0A84FF",
borderWidth: 1,
borderColor: "#ffffff",
}}
/>
</View>
</Marker>
) : null}
{stationPin.map(({ D, d, latlng, indexBase, index }) => (
<MapPin
index={index}
+7 -223
View File
@@ -1,225 +1,9 @@
import React, { useRef, useState, useEffect, useLayoutEffect } from "react";
import { View, Text, TouchableOpacity, Linking } from "react-native";
//import MapView from "react-native-maps";
import { useCurrentTrain } from "../stateBox/useCurrentTrain";
import { useNavigation } from "@react-navigation/native";
import lineColorList from "../assets/originData/lineColorList";
import { lineListPair, stationIDPair } from "../lib/getStationList";
import { SheetManager } from "react-native-actions-sheet";
import { useTrainMenu } from "../stateBox/useTrainMenu";
//import { MapPin } from "./TrainMenu/MapPin";
import { UsefulBox } from "./TrainMenu/UsefulBox";
import { MapsButton } from "./TrainMenu/MapsButton";
import { useThemeColors } from "@/lib/theme";
export default function TrainMenu({ style }) {
const { fixed } = useThemeColors();
import type { StyleProp, ViewStyle } from "react-native";
type TrainMenuProps = {
style?: StyleProp<ViewStyle>;
};
export default function TrainMenu(_props: TrainMenuProps) {
return null;
const { webview } = useCurrentTrain();
const mapRef = useRef();
const { navigate, goBack } = useNavigation();
const [stationPin, setStationPin] = useState([]);
const {
selectedLine,
setSelectedLine,
mapsStationData: stationData,
} = useTrainMenu();
useEffect(() => {
const stationPinData = [];
Object.keys(stationData).forEach((d, indexBase) => {
stationData[d].forEach((D, index) => {
if (!D.StationMap) return null;
if (selectedLine && selectedLine != d) return;
const latlng = D.StationMap.replace(
"https://www.google.co.jp/maps/place/",
""
).split(",");
if (latlng.length == 0) return null;
stationPinData.push({ D, d, latlng, indexBase: 0, index });
});
});
setStationPin(stationPinData);
}, [stationData, selectedLine]);
useLayoutEffect(() => {
mapRef.current.fitToCoordinates(
stationPin.map(({ latlng }) => ({
latitude: parseFloat(latlng[0]),
longitude: parseFloat(latlng[1]),
})),
{ edgePadding: { top: 80, bottom: 120, left: 50, right: 50 } } // Add margin values here
);
}, [stationPin]);
return (
<View style={{ height: "100%", backgroundColor: fixed.primary, ...style }}>
<MapView
style={{ flex: 1, width: "100%", height: "100%" }}
showsUserLocation={true}
loadingEnabled={true}
showsMyLocationButton={false}
moveOnMarkerPress={false}
showsCompass={false}
ref={mapRef}
//provider={PROVIDER_GOOGLE}
initialRegion={{
latitude: 33.774519,
longitude: 133.533306,
latitudeDelta: 1.8, //小さくなるほどズーム
longitudeDelta: 1.8,
}}
>
{stationPin.map(({ D, d, latlng, indexBase, index }) => (
<MapPin
index={index}
indexBase={indexBase}
latlng={latlng}
D={D}
d={d}
navigate={navigate}
webview={webview}
key={D.StationNumber + d}
/>
))}
</MapView>
<View style={{ position: "relative" }}>
<View
style={{
flexDirection: "row",
position: "absolute",
width: "100vw",
bottom: 0,
flex: 1,
}}
>
<TouchableOpacity
style={{
backgroundColor: selectedLine
? lineColorList[stationIDPair[selectedLine]]
: fixed.primary,
padding: 10,
zIndex: 1,
alignItems: "center",
position: "absolute",
bottom: 0,
width: "100%",
paddingBottom: 50,
}}
onPress={() => SheetManager.show("TrainMenuLineSelector")}
>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 10,
textAlign: "center",
}}
>
    
</Text>
<Text
style={{
color: "white",
fontWeight: "bold",
fontSize: 20,
textAlign: "center",
}}
>
{selectedLine
? lineListPair[stationIDPair[selectedLine]]
: "JR四国 対象全駅"}
</Text>
</TouchableOpacity>
<Text style={{ position: "absolute", bottom: 40 }}>
</Text>
{Object.keys(stationData).map((d) => (
<TouchableOpacity
key={stationIDPair[d]}
style={{
flex: 1,
backgroundColor: lineColorList[stationIDPair[d]],
padding: 5,
margin: 2,
borderRadius: 10,
borderColor: "white",
borderWidth: 1,
borderStyle: "solid",
alignItems: "center",
opacity: selectedLine == d ? 1 : !selectedLine ? 1 : 0.5,
zIndex: 10,
}}
onPress={() => {
const s = selectedLine == d ? undefined : d;
if(!s) return;
setSelectedLine(s);
Object.keys(stationData).forEach((data, indexBase) => {
stationData[data].forEach((D, index) => {
if (!D.StationMap) return null;
if (s && s != data) return;
const latlng = D.StationMap.replace(
"https://www.google.co.jp/maps/place/",
""
).split(",");
if (latlng.length == 0) return null;
if (index == 0 && stationPin.length > 0) {
webview.current
?.injectJavaScript(`MoveDisplayStation('${data}_${D.MyStation}_${D.Station_JP}');
document.getElementById("disp").insertAdjacentHTML("afterbegin", "<div />");`);
}
});
});
}}
>
<Text
style={{ color: "white", fontWeight: "bold", fontSize: 20 }}
>
{stationIDPair[d]}
</Text>
</TouchableOpacity>
))}
</View>
</View>
{navigate && (
<View style={{ flexDirection: "row" }}>
<UsefulBox
backgroundColor={"#F89038"}
icon="train-car"
flex={1}
onPressButton={() =>
navigate("howto", {
info: "https://train.jr-shikoku.co.jp/usage.htm",
})
}
>
使
</UsefulBox>
<UsefulBox
backgroundColor={"#EA4752"}
icon="star"
flex={1}
onPressButton={() => navigate("favoriteList")}
>
</UsefulBox>
<UsefulBox
backgroundColor={"#91C31F"}
icon="clipboard-list-outline"
flex={1}
onPressButton={() =>
Linking.openURL(
"https://nexcloud.haruk.in/apps/forms/ZRHjWFF7znr5Xjr2"
)
}
>
</UsefulBox>
</View>
)}
<MapsButton
onPress={() => {
goBack();
}}
top={0}
mapSwitch={"flex"}
/>
</View>
);
}
+4 -2
View File
@@ -28,6 +28,7 @@ import { getCurrentTrainData } from "@/lib/getCurrentTrainData";
import type { NavigateFunction } from "@/types";
import { PlatformNumber } from "./LED_inside_Component/PlatformNumber";
import { useThemeColors } from "@/lib/theme";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
type Props = {
d: eachTrainDiagramType;
@@ -48,6 +49,7 @@ export const EachData: FC<Props> = (props) => {
} = props;
const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList();
const { playbackCurrentTimeIso } = useTrainMenu();
const { allCustomTrainData } = useAllTrainDiagram();
const { fixed } = useThemeColors();
const openTrainInfo = (d: {
@@ -134,7 +136,7 @@ export const EachData: FC<Props> = (props) => {
const [h, m] = d.time.split(":");
const IntH = parseInt(h);
const IntM = parseInt(m);
const currentTime = dayjs();
const currentTime = playbackCurrentTimeIso ? dayjs(playbackCurrentTimeIso) : dayjs();
const trainTime = currentTime
.set("hour", IntH < 4 ? IntH + 24 : IntH)
.set("minute", IntM);
@@ -145,7 +147,7 @@ export const EachData: FC<Props> = (props) => {
setIsDepartureNow(false);
setIsShow(true);
};
}, [d.time, currentTrainData]);
}, [d.time, currentTrainData, playbackCurrentTimeIso]);
useInterval(() => {
if (isDepartureNow) {
setIsShow(!isShow);
@@ -3,11 +3,8 @@ import { Text, View, LayoutChangeEvent } from "react-native";
import Animated, {
useSharedValue,
useAnimatedStyle,
withTiming,
withRepeat,
withSequence,
withDelay,
cancelAnimation,
useFrameCallback,
} from "react-native-reanimated";
import { useThemeColors } from "@/lib/theme";
@@ -18,40 +15,65 @@ type Props = {
export const ScrollingDescription: FC<Props> = ({ description }) => {
const { fixed } = useThemeColors();
const scrollX = useSharedValue(0);
const isRunning = useSharedValue(false);
const contentWidth = useSharedValue(0);
const viewportWidth = useSharedValue(0);
const [textWidth, setTextWidth] = useState(0);
const [containerWidth, setContainerWidth] = useState(0);
const gap = 20;
const pixelsPerSecond = 84;
// 改行を削除して1行にする
const singleLineDescription = description?.replace(/\n/g, " ") || "";
useEffect(() => {
cancelAnimation(scrollX);
contentWidth.value = textWidth;
viewportWidth.value = containerWidth;
if (!singleLineDescription || textWidth === 0 || containerWidth === 0) {
isRunning.value = false;
scrollX.value = 0;
return;
}
// テキストが画面幅より短い場合はスクロールしない
if (textWidth <= containerWidth) {
isRunning.value = false;
scrollX.value = 0;
return;
}
const distance = textWidth + containerWidth;
const duration = distance * 6; // スクロール速度
// 初期位置を設定(画面の右端から開始)
scrollX.value = containerWidth;
scrollX.value = withRepeat(
withSequence(
withDelay(500, withTiming(-textWidth - 20, { duration })),
withDelay(500, withTiming(containerWidth, { duration: 0 }))
),
-1
);
isRunning.value = true;
return () => {
isRunning.value = false;
cancelAnimation(scrollX);
};
}, [singleLineDescription, textWidth, containerWidth]);
}, [
singleLineDescription,
textWidth,
containerWidth,
scrollX,
isRunning,
contentWidth,
viewportWidth,
]);
useFrameCallback((frameInfo) => {
"worklet";
if (!isRunning.value) {
return;
}
const elapsedMs = frameInfo.timeSincePreviousFrame ?? 16.67;
const nextX = scrollX.value - (pixelsPerSecond * elapsedMs) / 1000;
const resetPoint = -contentWidth.value - gap;
scrollX.value = nextX <= resetPoint ? viewportWidth.value : nextX;
});
const animatedStyle = useAnimatedStyle(() => ({
transform: [{ translateX: scrollX.value }],
@@ -60,14 +82,20 @@ export const ScrollingDescription: FC<Props> = ({ description }) => {
const handleTextLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
if (width > 0) {
setTextWidth(Math.round(width));
const nextWidth = Math.round(width);
setTextWidth((currentWidth) =>
currentWidth === nextWidth ? currentWidth : nextWidth
);
}
};
const handleContainerLayout = (event: LayoutChangeEvent) => {
const { width } = event.nativeEvent.layout;
if (width > 0) {
setContainerWidth(Math.round(width));
const nextWidth = Math.round(width);
setContainerWidth((currentWidth) =>
currentWidth === nextWidth ? currentWidth : nextWidth
);
}
};
+25 -12
View File
@@ -13,6 +13,17 @@ import { getTime, trainTimeFiltering } from "@/lib/trainTimeFiltering";
import { eachTrainDiagramType, StationProps } from "@/lib/CommonTypes";
import { useNavigation } from "@react-navigation/native";
import { useThemeColors } from "@/lib/theme";
import { stackAwareNavigate } from "@/lib/rootNavigation";
import { useStationList } from "@/stateBox/useStationList";
import { useTrainMenu } from "@/stateBox/useTrainMenu";
const readBooleanSetting = async (key: string) => {
try {
return (await AS.getItem(key)) === "true";
} catch {
return false;
}
};
/**
*
@@ -49,8 +60,10 @@ type props = {
export const LED_vision: FC<props> = (props) => {
const { station } = props;
const { navigate, addListener, isFocused } = useNavigation();
const { navigate } = useNavigation();
const { currentTrain } = useCurrentTrain();
const { stationList } = useStationList();
const { playbackCurrentTimeIso } = useTrainMenu();
const [stationDiagram, setStationDiagram] = useState<{
[key: string]: string;
}>({}); //当該駅の全時刻表
@@ -63,14 +76,14 @@ export const LED_vision: FC<props> = (props) => {
const { fixed } = useThemeColors();
useEffect(() => {
AS.getItem("LEDSettings/trainIDSwitch").then((data) => {
setTrainIDSwitch(data === "true");
});
AS.getItem("LEDSettings/trainDescriptionSwitch").then((data) => {
setTrainDescriptionSwitch(data === "true");
});
AS.getItem("LEDSettings/finalSwitch").then((data) => {
setFinalSwitch(data === "true");
void Promise.all([
readBooleanSetting("LEDSettings/trainIDSwitch"),
readBooleanSetting("LEDSettings/trainDescriptionSwitch"),
readBooleanSetting("LEDSettings/finalSwitch"),
]).then(([nextTrainIdSwitch, nextTrainDescriptionSwitch, nextFinalSwitch]) => {
setTrainIDSwitch(nextTrainIdSwitch);
setTrainDescriptionSwitch(nextTrainDescriptionSwitch);
setFinalSwitch(nextFinalSwitch);
});
}, []);
@@ -112,10 +125,10 @@ export const LED_vision: FC<props> = (props) => {
if (!currentTrain) return () => {};
const data = trainTimeAndNumber
.filter((d) => currentTrain.map((m) => m.num).includes(d.train)) //現在の列車に絞る[ToDo]
.filter((d) => trainTimeFiltering({ d, currentTrain, station })) //時間フィルター
.filter((d) => trainTimeFiltering({ d, currentTrain, station, stationList, now: playbackCurrentTimeIso })) //時間フィルター
.filter((d) => !!finalSwitch || d.lastStation != station[0].Station_JP); //最終列車表示設定
setSelectedTrain(data);
}, [trainTimeAndNumber, currentTrain, finalSwitch]);
}, [trainTimeAndNumber, currentTrain, finalSwitch, stationList, playbackCurrentTimeIso]);
const { width } = useWindowDimensions();
const adjustedWidth = width * 0.98;
@@ -163,7 +176,7 @@ export const LED_vision: FC<props> = (props) => {
<AreaDescription
numberOfLines={1}
areaInfo={areaInfo}
onClick={() => alert(areaInfo)}
onClick={() => stackAwareNavigate("information")}
/>
)}
+1 -1
View File
@@ -1 +1 @@
export const news = "2026-02-01";
export const news = "2026-04-01";
+3
View File
@@ -7,6 +7,9 @@ const BASE_URL = 'https://jr-shikoku-api-data-storage.haruk.in';
export const API_ENDPOINTS = {
/** 本日のダイアグラムデータ */
DIAGRAM_TODAY: `${BASE_URL}/tmp/diagram-today.json`,
/** 本日のダイアグラムデータ(experimental環境用) */
DIAGRAM_TODAY_BETA: `${BASE_URL}/tmp/diagram-today-beta.json`,
/** カスタム列車データ */
CUSTOM_TRAIN_DATA: 'https://haruk.in/api/jr/getTrain.php',
+21
View File
@@ -91,6 +91,9 @@ export const STORAGE_KEYS = {
/** えれサイト使用設定 */
USE_ELESITE: 'useElesite',
/** 投稿システム接続先 */
JR_DATA_SYSTEM_ENV: 'jrDataSystemEnv',
/** えれサイトデータ */
ELESITE_DATA: 'elesiteData',
@@ -108,6 +111,24 @@ export const STORAGE_KEYS = {
/** カラーテーマ設定 ("light" | "system" | "dark") */
COLOR_THEME: 'colorTheme',
/** 運行情報横倒し機能の有効化スイッチ(β) */
OPERATION_INFO_LANDSCAPE_ENABLED: 'operationInfoLandscapeEnabled',
/** 運行情報スクリーンショット切り出し機能の有効化スイッチ(β) */
OPERATION_INFO_CAPTURE_ENABLED: 'operationInfoCaptureEnabled',
/** モックAPI検証機能の有効化スイッチ(admin専用) */
MOCK_API_FEATURE_ENABLED: 'mockApiFeatureEnabled',
/** 走行位置録画インデックス(admin専用 / 複数録画のメタ情報一覧) */
MOCK_RECORDINGS_INDEX: 'mockRecordingsIndex',
/** 走行位置録画データプレフィックス(admin専用 / + id でキーを構成) */
MOCK_RECORDING_DATA_PREFIX: 'mockRecordingData_',
/** 走行位置録画データ(旧フォーマット / マイグレーション用) */
MOCK_RECORDING: 'mockRecording',
} as const;
/**
+118
View File
@@ -0,0 +1,118 @@
# EachTrainInfo ActionSheet アニメーション破綻の修正記録
**日付:** 2026-04-08
**ブランチ:** fix/April-Mid-Patch
**コミット:** 8b42644
---
## 症状
iOS (`isModal=true`) でマリンライナー等の**走行中の列車**を EachTrainInfo ActionSheet で表示したとき、スライドアップアニメーションが瞬間表示になる。
Android では発生しない。
---
## 根本原因(3層)
### 1. iOS `onOpen` の発火タイミング(最重要)
ライブラリ `node_modules/react-native-actions-sheet/dist/src/index.js` line 962:
```js
onShow: props.onOpen,
```
iOS の `isModal=true` モードでは ActionSheet の `onOpen` prop が React Native の `Modal.onShow` にバインドされる。
`Modal.onShow` はスプリングアニメーション**開始前**(モーダルが表示された直後)に発火する。
これにより以下の連鎖が起きていた:
1. Modal 表示 → `onOpen``setShowThrew(true)` 実行
2. 通過駅が一気に追加されてシート高さが増加
3. `onSheetLayout` が再発火してスプリングが「ほぼ終点位置」からリスタート
4. 結果:スライドアップに見えず瞬間表示になる
### 2. `useEffect` による非同期な高さ変化
以下の hooks が `useState([])` (空) で初期化し `useEffect` で計算していた:
- `useTrainDiagramData` — 駅リスト本体
- `useThroughStations` — 通過駅挿入後のリスト(実際にレンダリングされる)
- `useStopStationIDs` — 駅ID対応表
初回レンダリング(空・高さ小)→ `useEffect` 完了(フルリスト・高さ大)という変化が `onSheetLayout` を再トリガーしていた。
### 3. `useAutoScroll` の `InteractionManager` が非効果的だった
`InteractionManager.runAfterInteractions()` は JS スレッドのインタラクション完了を待つが、ActionSheet の Reanimated スプリング(UI スレッド)完了は認識しないため、アニメーション途中でスクロールが実行されることがあった。
---
## 修正内容
### `showThrew` の初期値を同期的に決定 ← 最重要
```tsx
// EachTrainInfoCore.tsx(修正前)
const [showThrew, setShowThrew] = useState(false);
// EachTrainInfoCore.tsx(修正後)
const [showThrew, setShowThrew] = useState(() => !!getCurrentStationData(data.trainNum));
```
走行中の列車は最初から `true` にすることで、アニメーション中に通過駅の追加による高さ変化が起きなくなる。
### 各 hooks の lazy initializer 化
純粋計算関数を抽出して `useState` の初期化関数に渡すことで、初回レンダリング時から正確な高さを確保:
```ts
// useThroughStations.ts
const [state, setState] = useState(() =>
computeThroughStations(trainData, stationList, originalStationList)
);
// useStopStationIDs.ts
const [stopStationIDList, setStopStationIDList] = useState<string[][]>(() =>
computeStopStationIDs(trainDataWithThrough, stationList)
);
// useTrainDiagramData.ts
const [trainData, setTrainData] = useState(() => parseTrainData(trainNum, trainList).data);
```
### `sheetOpened` フラグによるスクロールのゲート
```tsx
// EachTrainInfo.tsx
const [sheetOpened, setSheetOpened] = useState(false);
// onOpen → setSheetOpened(true), onClose → setSheetOpened(false)
```
```ts
// useAutoScroll.ts — sheetOpened が true になるまでスクロールしない
if (!sheetOpened || isJumped || ...) return;
```
### `useAutoScroll` から `setShowThrew` 呼び出しを除去
スクロール位置制御と通過駅表示の責務を分離。`setShowThrew``EachTrainInfoCore` の初期化時のみで完結。
---
## 変更ファイル一覧
| ファイル | 変更内容 |
|---|---|
| `components/ActionSheetComponents/EachTrainInfo.tsx` | `sheetOpened` state 追加、`onOpen`/`onClose` ハンドラ実装 |
| `components/ActionSheetComponents/EachTrainInfoCore.tsx` | `showThrew` 同期初期化、`useCurrentTrain` import 追加、`setShowThrew``useAutoScroll` から除去 |
| `components/ActionSheetComponents/EachTrainInfoCore/hooks/useAutoScroll.ts` | `setShowThrew` 引数削除、`sheetOpened` ゲート追加、`InteractionManager` 廃止 |
| `components/ActionSheetComponents/EachTrainInfoCore/hooks/useTrainDiagramData.ts` | `parseTrainData` 純粋関数抽出、lazy initializer 化 |
| `components/ActionSheetComponents/EachTrainInfoCore/hooks/useThroughStations.ts` | `computeThroughStations` 純粋関数抽出、lazy initializer 化 |
| `components/ActionSheetComponents/EachTrainInfoCore/hooks/useStopStationIDs.ts` | `computeStopStationIDs` 純粋関数抽出、lazy initializer 化 |
---
## 将来の注意点
- **ActionSheet に渡すコンテンツの高さはマウント時から固定すること。** `useEffect` で後から高さを変えると `onSheetLayout` が再発火してスプリングアニメーションがリスタートする。
- **iOS で `isModal=true` の場合、`onOpen` はアニメーション完了前に発火する。** `onOpen` の中で state 変更を行うとアニメーションが破綻する可能性がある。
- **Reanimated スプリングは UIスレッドで動くため `InteractionManager.runAfterInteractions()` では待てない。** 代わりに `onOpen` フラグでゲートする。
+51
View File
@@ -0,0 +1,51 @@
## `6.2.1` → `6.2.1.1` の更新内容
### 1. 鉄道運用Hub(Unyohub)連携機能の新規追加・大幅強化
- **連携の基盤実装**: 列車運用Hub(外部データソース)との接続設定・データ取得ロジックを実装
- **ON/OFF管理**: 追加ソースのON/OFF切り替え機能を追加。オフ時の挙動も修正
- **アクセス権限管理**: データ編集権限の取得・判定ロジックを実装。情報ソース設定へのアクセスを権限ベースで制御
- **UIコンポーネントの追加**:
- `DataSourceAccordionCard` コンポーネントを追加(データソース設定画面)
- 運用情報ソースの表示パネル(`TrainSourcesPanel` / `TrainDataSources`)を追加
- 戻るボタンの位置設定と条件付きレンダリング構造を修正
- HUBロゴの SVG・PNG ファイルを追加
- **進行方向・路線表示の改善**:
- 進行方向バナーと基準方向ラベルを追加
- アニメーションで路線を切り替える機能を実装
- **データ取得・ソートロジックの改善**:
- `train_ids` / `related_train_ids` に基づいて `unit_ids` を収集する機能を追加
- 方向に基づいて運用番号を正しい順序でソートする機能を追加
- 車番が空でないエントリのみを対象とする判定ロジックに修正
- 各コンポーネントにキャッシュバスティング(タイムスタンプ付きURL)を実装
- **列車情報詳細のUI改善**:
- タイプタグに色とフォントスタイルを追加
- 列車情報表示をスクロール可能な形式に変更
### 2. WebView・トレインビジョン表示の改善
- **`ScrollingDescription` コンポーネント追加**: テキストを横スクロール表示する汎用コンポーネントを実装
- **停止中の点滅アニメーション**: 停止中マークの点滅アニメーションをWebViewへ動的に注入する機能を追加
- **Font Awesome 依存を削除**: インラインSVGに置き換え、全WebView対応のバッジ表示を実装。バンドルサイズ軽減
- **lodash 依存を削除**: 軽量な変更検出ユーティリティを内製して置き換え
- **データ取得の最適化**: `useInterval``webViewInjectjavascript` での取得処理を最適化し、`localStorage` キャッシュを実装。バックグラウンド復帰時にデータを即時再取得
- **`onclick` 属性処理の改善**: 古いWebViewとの互換性向上のためイベント伝播を制御。`PopUpMenu` / `ShowTrainTimeInfo``Object.defineProperty` でロック
- **アイコンの反転表示対応**
- **`setReload` の遅延実行**: `setTimeout` でスクリプト実行完了後にリロードするよう変更
- **`InjectJavascriptOptions` インターフェース追加**: `injectJavascriptData` 関数の引数をオブジェクト形式に変更
### 3. 駅・列車データの追加
- 駅データ、列車アイコンマッピング、列車タイプ設定を追加
- 不要なコメント削除・列番データ処理の簡素化
### 4. 運休表示の改善
- LEDの行き先表示に運休表示を追加
- 運休表示テキストの簡略化
- 行き先名の取得ロジックを修正(列車名→行き先名に変更)
- 時刻表テキストの結合条件を整理
### 5. バージョン
- **v6.2.1.1** にバージョンアップ
+127
View File
@@ -0,0 +1,127 @@
## `6.2.1.1` → `7.0` の更新内容
### 1. Expo SDK 52 → 55 メジャーアップグレード
- **Expo SDK を 52 → 53 → 54 → 55 に段階アップグレード**: React Native 0.76.6 → 0.83.2、React 18.3 → 19.2 に更新
- **新アーキテクチャ(New Architecture)を有効化**: RN 0.83+ のデフォルト新アーキテクチャに対応。Kotlin 2.1.20 へ更新
- **Reanimated 3.x → 4.x**: `react-native-worklets` を新規追加し、Reanimated 4.2.1 + Carousel 4.0.3 に移行
- **ビルド構成の変更**: `expo start --android/--ios` から `expo run:android/run:ios`(ネイティブビルド)に変更
- **babel.config.js**: `lazyImports` 有効化、`react-native-reanimated/plugin` 追加、`react-compiler` 無効化
- **metro.config.js**: `modules/` ディレクトリを `watchFolders` に追加(ローカルExpoモジュール対応)
- **不要パッケージの削除**: `react-native-router-flux``react-native-snap-carousel``react-native-svg-uri``react-native-auto-height-image``react-native-walkthrough-tooltip``react-native-remote-svg``pushy-react-native``firebase``native-base` を削除
### 2. ダークモード対応
- **テーマシステムの構築**: `lib/theme/colors.ts` に約50のセマンティックカラートークン(light/dark)を定義。`useThemeColors()` フックと `AppThemeProvider` で全体を制御
- **WebView ダークモード**: JR四国列車位置ページにCSS暗色テーマを動的注入。HSLベースの `_darkenBg()` ヘルパーで個別要素の背景色も自動調整
- **全コンポーネントへの適用**: ハードコードされた色(`#ffffff``#0099CC``"black"` 等)をテーマトークンに置き換え。設定画面、ActionSheet、駅詳細、お気に入り一覧、列車情報等に適用
- **カラーテーマ設定**: 設定画面に `ColorThemePicker`(システム/ライト/ダーク選択)を追加
### 3. FeliCa(交通系ICカード)対応
- **残高読み取り**: AndroidNfcF/ iOSCoreNFC)の両方でサービスコード `0x090F``ReadWithoutEncryption` コマンドを発行し、ICカード残高を取得
- **乗降履歴取得**: 最大20件の履歴ブロックを解析。端末種別・処理種別・日時・入出場駅コード・残高・事業者コード等を構造化データとして取得
- **FeliCa履歴ページ**: 347行の新規画面。カード種別表示・差額計算・駅名逆引き(5,931駅のマッピングデータ)・NFCスキャン・長押しコピーに対応
- **`isAvailable()` 関数**: NFC機能の利用可否をプラットフォーム別にチェック
- **iOS App Group連携**: `saveWidgetData()` でUserDefaultsに残高を書き込み、WidgetKitのタイムラインを自動更新
### 4. iOSウィジェット・Live Activity
- **FeliCaBalanceWidget**: ICカード残高をホーム画面に表示。ダーク/ライトモード対応、iPhone限定
- **ShortcutWidget**: アプリ機能へのクイックアクセスタイル。遅延情報・運行情報の取得機能付き
- **DelayInfoWidget**: JR四国の遅延・運休情報を表示
- **OperationInfoWidget**: 列車運行状況を表示
- **TrainFollow Live Activity**: ロック画面上でリアルタイムの列車追跡を表示
- **StationLock Live Activity**: ロック画面上で駅固定の発車標を表示
- **Live Activityの一時無効化**: インフラは実装済みだが、リリース時点では一時的に無効化
### 5. Android通知・ウィジェット
- **列車追跡フォアグラウンドサービス**: 15秒間隔でAPIポーリングし、通知を更新。「列車追跡」と「駅固定」の2モードに対応
- **進捗バー付き通知**: 列車位置をルート上の進捗バーで表示。Android 16Baklava)では `ProgressStyle` API で色分けセグメント・停車駅マーカーを表示
- **駅固定通知**: Inbox形式で次発列車一覧を表示。列車種別ごとにバッジ色分け
- **ShortcutWidget / FelicaQuickAccessWidget**: Androidウィジェットの追加・UIデザイン更新
- **通知権限**: Android向け `POST_NOTIFICATIONS` 権限の明示的リクエストを追加
### 6. ディープリンク・通知ルーティング
- **URLスキーム `jrshikoku://` の実装**: `open/felica``open/traininfo``open/operation``open/settings``open/topmenu` へのルーティング対応
- **通知タップルーティング**: チャンネルID・カテゴリ・コンテンツに基づき、`delay-ex`(遅延速報EX)、`strange-train`(怪レい列車)、`information`(運行情報)の各画面に遷移
- **グローバルナビゲーション**: `rootNavigationRef` によりReactコンポーネントツリー外からのナビゲーションが可能に
- **linking config**: `Apps.tsx``jrshikoku://` スキームの画面階層を定義
### 7. iPad・タブレット対応
- **iPad サポート有効化**: `app.json``supportsTablet``true` に変更
- **ActionSheet改善**: iPadでは `isModal` を無効化し、`useSheetMaxHeight` フックで端末種別に応じた最大高さ制御(スマホのみ制限、タブレットは無制限)
- **ヘッダー表示修正**: `ScrollView``View` + `width: 100%` に変更
- **WebView最適化**: `contentMode="mobile"` を追加し、iPadでもモバイルビューポートを表示
### 8. Samsung DeX対応
- **低密度ディスプレイ検出**: `PixelRatio < 1.5` でDeX環境を自動検出
- **Dimensionsモンキーパッチ**: `Dimensions.get('window')` をスケール済み仮想値に置き換え
- **DensityScaleWrapper**: アプリ全体を `transform: [{ scale }]` でラップし、レイアウトの一貫性を維持
- **フォントサイズ自動補正**: Babelプラグインで全 `Text`/`TextInput` のスタイルを `global.__scaleTextStyle()` でラップし、PixelRatioに応じたフォント補正を実行(最大4倍)
### 9. サウンド機能
- **遅延アナウンス機能**: 駅固定モードで遅延発生時に音声で通知する機能を追加
- **SoundSettings画面**: サウンド設定のON/OFFトグルとプレビュー再生。`expo-audio` を使用
- **プラットフォーム別URI処理**: Androidでは `file://` プレフィックスを除去
### 10. elesite連携
- **新データソースとして統合**: 列車編成・車両情報の取得用に `useElesite` フックを実装
- **HeaderTextコンポーネント改善**: elesiteデータの表示・レイアウトを統合
- **非空の編成ユニットを優先**: ソートロジックの改善
- **権限ベースの設定制御**: 設定画面からelesiteのON/OFF切り替え
### 11. 駅固定・列車追跡の改善
- **バックグラウンド更新**: データ取得をバックグラウンドでも継続。列車追跡の終了条件はフォアグラウンド状態に依存
- **進捗バー再設計**: 全駅対応の進捗表示に変更
- **出発済みフィルタ**: 駅固定通知から出発済み列車を除外
- **棒線駅接近判定の修正**: 通過駅での接近判定ロジックを改善
- **通知書式改善**: 路線色の追加、進捗スタイルのセグメント対応
- **APIタイムアウト追加**: API呼び出しにタイムアウトとエラーハンドリングを追加
### 12. UIコンポーネントの改善
- **カルーセル大幅改修**: グリッド表示モード追加(`GridMiniSign``SortGridCard`)、ソート機能実装、アニメーション追加
- **キーボード回避ロジック共通化**: `useKeyboardAvoid` フックに統合。Androidの偽キーボードイベント対策、デバウンス、高さキャッシュを実装
- **ActionSheet**: TypeScript型定義の整備(`SheetDefinition`)、スクロール処理の簡素化
- **アニメーション最適化**: `useSharedValue`/`useAnimatedStyle` の導入、`LayoutAnimation` の除去によるパフォーマンス向上
- **タブバーグラデーション**: 選択中の路線カラーに基づくグラデーション背景表示
### 13. 設定画面の再構成
- **セクション分け**: 「表示」「カスタマイズ」「通知・データ」「その他」にグループ化
- **新規項目追加**: カラーテーマ選択、サウンド設定、レイアウト設定
- **WidgetSettings削除**: ウィジェット専用設定画面を廃止
- **テーマカラー適用**: 全テキスト・背景色にテーマトークンを使用
### 14. 観光スポット・地図機能
- **SpotSign コンポーネント**: 地図上の観光スポットマーカーを追加
- **与島PA**: トップメニューに追加、座標を正確な位置に修正
- **最寄駅追跡**: `UserPositionProvider``FixedNearestStationBox` でユーザー位置に基づく最寄駅表示
### 15. データ取得・キャッシュの最適化
- **キャッシュTTL変更**: 駅リスト(1時間→1週間)、運行情報(2分→1日)、列車データ(2分→1日)、Unyohub(10分→1時間)、障害情報(2分→1分)
- **次駅表示修正**: Direction非依存に修正(JS/Kotlin両方)
- **アンパンマン列車API**: 予讃線・土讃線のアンパンマン列車ごとにAPIエンドポイントを判定
### 16. その他
- **アプリアイコン53種追加**: 合計アイコン数が大幅増加
- **アイコンID統一**: 大文字小文字の統一により可読性向上
- **lodash依存の削除**: 内製の軽量変更検出ユーティリティに置き換え
- **TypeScriptエラー削減**: 143件 → 47件に削減
- **遅延による日付調整ロジック追加**
- **スクリーンショット共有機能**: TrainDataSourcesコンポーネントに追加
### 17. バージョン
- **v7.0** にメジャーバージョンアップ(iOS buildNumber: 50→63、Android versionCode: 28→30
+81
View File
@@ -0,0 +1,81 @@
# 064d81d4 -> a959cf39 更新ログ(機能説明フォーカス版)
## この差分で何が良くなったか
この更新では、大きな新機能追加よりも、既存機能を日常利用で安定して使えるようにする改善が中心です。
特に「外部連携の扱いやすさ」「画面遷移の安定性」「キーボード/アニメーション時の操作感」に効く変更がまとまって入っています。
---
## 1. 運用Hubelesite)連携の運用性向上
### 実装/更新された機能
- elesite連携の権限ハンドリングを簡素化し、設定分岐を整理。
- 列車ペアマッピングと貨物列車の車番処理を改善。
- elesiteロゴ追加など、情報ソース表示の視認性を改善。
- 投稿システム接続先を切り替えて検証できるデバッグ導線を追加。
### ユーザーにとっての効果
- 情報ソース連携時の挙動が安定し、設定後の迷いが減ります。
- 表示データの整合性が上がり、参照時の信頼感が向上します。
## 2. ナビゲーション基盤の強化
### 実装/更新された機能
- stackAwareNavigate導入で、遷移時のスタック管理を改善。
- ルート状態監視(onStateChange)を見直し、アクティブルート判定を精度向上。
- StatusBar表示制御をフォーカス状態ベースに改善。
- WebView内ナビゲーションの扱いを調整。
### ユーザーにとっての効果
- 画面遷移での予期しない戻り/重複遷移が起きにくくなります。
- 画面表示の一貫性が上がり、操作中の違和感が減ります。
## 3. キーボード回避とActionSheet操作感の改善
### 実装/更新された機能
- キーボード回避を Animated.timing / spring ベースに移行。
- SearchUnitBoxでanimatedOffsetを使った追従へ改善。
- EachTrainInfo ActionSheetのスプリング破綻を修正。
- 調整内容を技術ドキュメントとして記録。
### ユーザーにとっての効果
- 連続操作時でもUIの位置ずれが起きにくくなります。
- シート表示や検索入力時の「引っかかり」が減り、操作が滑らかになります。
## 4. 列車情報画面の表示品質改善
### 実装/更新された機能
- HeaderTextのtodayOperation判定を改善し、不要な表示混入を抑制。
- TrainDataViewの長押し条件を実データ基準へ調整。
- ExGridViewの不要ズーム設定を除去。
- ListViewItemの循環アニメーションを改善。
### ユーザーにとっての効果
- 列車情報の表示が実態に近づき、誤認しにくくなります。
- 長押しや一覧切替の体験が自然になり、ストレスが減ります。
## 5. 設定・内部運用の整備
### 実装/更新された機能
- 内部バージョン表記を7.0.2へ更新。
- 情報ソース設定まわりを拡張し、運用切替時の扱いを改善。
- タブバーアニメーション/キーボード非表示設定の整理を実施。
### ユーザーにとっての効果
- 設定変更後の挙動がより予測しやすくなります。
- 端末や利用状況の違いによる操作ブレが軽減されます。
## 6. ドキュメント整備(将来拡張と保守性)
### 実装/更新された機能
- チュートリアル機能の設計案を追加。
- プライバシーポリシー関連ドキュメントを整備。
- キーボード/ActionSheet調整の修正履歴を記録。
### ユーザーにとっての効果
- 直接見える機能ではありませんが、今後の改善や不具合修正が速くなり、結果として体験品質の向上につながります。
---
## まとめ
この更新は、外部連携・画面遷移・入力時アニメーションなど、日常操作で「気になりやすい部分」を重点的に磨いた差分です。
新機能を増やすよりも、既存機能を安定して快適に使えるようにすることに重きを置いた内容になっています。
## 参照
- コミット差分精査版: docs/changelog-064d81d-to-a959cf3.md
+125
View File
@@ -0,0 +1,125 @@
# コミット差分ログ(064d81d4 -> a959cf39
## 対象範囲
- From: `064d81d468f21db4318a55f8fad4c432745d2441`
- To: `a959cf3973eb9635c9e61133d81de5c1f9719c18`
- 集計メモ:
- レンジ内コミット(merge含む): 31
- 非mergeコミット: 22
- 変更ファイル数: 44
- 変更行数: +1566 / -305
## サマリー
このレンジでは、運用Hub(elesite/投稿系)連携の実運用改善、キーボード回避とActionSheetのアニメーション安定化、ナビゲーションのスタック制御改善、設定画面とWebView周辺の挙動調整が中心に進められました。
機能追加は大規模というより、既存機能の「誤動作しにくさ」「切替時の安定性」「運用しやすさ」を高める改善が多い構成です。
---
## 1. 運用Hub/外部連携(elesite・投稿系)の改善
- elesite連携の権限ハンドリングを簡素化し、設定/運用時の分岐を整理。
- 列車ペアマッピングや貨物列車の車番処理を改善し、データ整合性を向上。
- elesiteロゴ資産を追加し、情報ソース表示の視認性を改善。
- 投稿システム接続先のデバッグ機能を追加し、環境切替の検証性を向上。
主なコミット:
- `fac89f6` fix: simplify elesite permission handling and update version code to 7.0.2
- `94eb84b` fix: update train pair mapping in BusAndTrainDataProvider
- `ad5357c` 運用Hub情報の取得ロジックを改善し、貨物列車の車番処理を追加
- `1b2ba08` feat: 投稿システム接続先のデバッグ機能を追加
- `ff90841` Add new logo image for Elesite to relationLogo assets
## 2. 画面遷移・ナビゲーション基盤の安定化
- `stackAwareNavigate` を導入し、遷移時のスタック管理を強化。
- App状態遷移時のアクティブルート判定を改善し、ルーティング誤判定を抑制。
- StatusBar表示ロジックを見直し、フォーカス状態を踏まえた表示制御に更新。
- WebViewナビゲーション挙動を調整し、画面内遷移の扱いを改善。
主なコミット:
- `5914646` stackAwareNavigate関数を導入し、遷移時のナビゲーションロジックを改善
- `a54ef7c` スタック管理強化 + 設計メモ追加
- `6b46c71` AppContainerのonStateChangeロジックを改善
- `9e2abc9` StatusBarの表示ロジックを改善
- `3ecb301` fix: update WebView navigation
## 3. キーボード回避・ActionSheetアニメーション改善
- キーボード回避を `Animated.timing/spring` ベースへ移行し、切替時の位置ずれを緩和。
- SearchUnitBoxで `animatedOffset` を利用したスムーズな追従に変更。
- ActionSheetEachTrainInfo)で発生していたスプリング破綻を修正。
- 調整内容をドキュメント化し、再発時のトラブルシュート性を向上。
主なコミット:
- `4017f82` fix: キーボード回避をAnimated.timing/springに移行
- `36be780` fix(SearchUnitBox): use animatedOffset with Animated.View
- `8b42644` fix: EachTrainInfo ActionSheetのスプリングアニメーション破綻を修正
- `b87c6f8` docs: キーボードアニメーション調整に関するドキュメントを追加
- `4809426` docs: ActionSheetアニメーション破綻の修正記録を追加
## 4. 列車情報表示・操作の品質改善
- HeaderTextの `todayOperation` 判定を調整し、完了済み運用の混入を抑制。
- TrainDataViewのロングプレス判定条件を `onLine` 依存から実データ依存へ改善。
- ExGridViewの不要ズームスケール設定を除去し、スクロール挙動の安定性を向上。
- ListViewItemの循環アニメーションを改善し、表示切替の自然さを向上。
主なコミット:
- `5a1430d` fix(HeaderText): filter out completed operations
- `07399f4` fix(HeaderText) + fix(TrainIconStatus)
- `76a617c` fix(TrainDataView): onLongPress condition update
- `a09ba45` fix(ExGridView): remove zoom scale properties
- `94eb84b` fix: enhance ListViewItem cycling animation
## 5. 設定・内部運用の整備
- 設定画面側の内部バージョン更新(7.0.2)を反映。
- データソース設定画面を拡張し、運用切替時の扱いを改善。
- タブバーアニメーション/キーボード関連の設定を整理し、UI挙動を安定化。
主なコミット:
- `fac89f6` fix: update version code to 7.0.2
- `374901c` fix: タブバーアニメーションとキーボード非表示設定を削除
## 6. ドキュメント拡充(運用・設計)
- チュートリアル機能の設計案を追加。
- プライバシーポリシー文書(本番/将来案)を追加。
- アニメーション調整や修正履歴の技術ドキュメントを追加。
追加ドキュメント:
- `docs/tutorial-feature-plan.md`
- `docs/privacy-policy.md`
- `docs/privacy-policy-future.md`
- `docs/actionsheet-animation-fix.md`
- `docs/keyboard-animation-tuning-2026-04-08.md`
- `docs/keyboard-animation-tuning-2026-04-09.md`
---
## 変更リスクと確認ポイント
- 画面遷移系:
- stackAwareNavigate導入に伴い、既存deep link/戻る挙動との整合確認が必要。
- キーボード/アニメーション系:
- 端末ごとのキーボード表示速度差で再ズレが起きないか継続確認が必要。
- 外部連携系:
- elesite権限状態の境界ケース(未許可/再許可/切替直後)で表示崩れがないか要確認。
## 付録: 非mergeコミット一覧(22件)
- `fac89f6` fix: simplify elesite permission handling and update version code to 7.0.2
- `94eb84b` fix: enhance ListViewItem cycling animation and update train pair mapping in BusAndTrainDataProvider
- `3ecb301` fix: update WebView navigation and adjust interval timing in CurrentTrainProvider
- `5a1430d` fix(HeaderText): update todayOperation prop to filter out completed operations
- `ff90841` Add new logo image for Elesite to relationLogo assets
- `a09ba45` fix(ExGridView): remove zoom scale properties from Animated.ScrollView
- `76a617c` fix(TrainDataView): update onLongPress condition to check currentTrainData instead of onLine
- `07399f4` fix(HeaderText): update todayOperation to use allTodayOperation for accurate state filtering
- `1b2ba08` feat: 投稿システム接続先のデバッグ機能を追加し、環境設定を管理できるようにした
- `374901c` fix: タブバーのアニメーションとキーボード非表示設定を削除
- `36be780` fix(SearchUnitBox): use animatedOffset with Animated.View for smooth keyboard avoidance
- `b87c6f8` docs: キーボードアニメーション調整に関するドキュメントを追加
- `4017f82` fix: キーボード回避をAnimated.timing/springに移行し、高速切替時の位置ずれとアニメーション不動を解消
- `58ce5fa` feat: チュートリアル機能の設計案を追加し、ユーザーの初回体験を改善
- `4809426` docs: ActionSheetアニメーション破綻の修正記録を追加
- `8b42644` fix: EachTrainInfo ActionSheetのスプリングアニメーション破綻を修正
- `5914646` stackAwareNavigate関数を導入し、遷移時のナビゲーションロジックを改善
- `a54ef7c` ナビゲーションロジックを改善し、stackAwareNavigate関数を導入して遷移時のスタック管理を強化。プライバシーポリシーと設計メモを追加。
- `6b46c71` AppContainerのonStateChangeロジックを改善し、アクティブなルートの状態を正確にチェックするように修正
- `9e2abc9` StatusBarの表示ロジックを改善し、Appsコンポーネントにフォーカス状態を追加
- `ad5357c` 運用Hub情報の取得ロジックを改善し、貨物列車の車番処理を追加
- `045ed21` 噂機能のスタイル強化
+82
View File
@@ -0,0 +1,82 @@
# a959cf39 -> 0b349148 更新ログ(機能説明フォーカス版)
## この差分で何が良くなったか
この更新では、列車位置を「その場で見る」だけでなく、「記録してあとから再生する」ための機能が大きく強化されました。
同時に、mockデータを使った検証導線やWebView側の安定性も改善され、実データが不安定な状況でも挙動確認や表示調整を進めやすくなっています。
---
## 1. 列車位置の記録・再生機能を追加
### 実装/更新された機能
- 列車位置の記録・再生機能を追加。
- 再生タイムラインUIを実装し、再生・一時停止・シーク操作に対応。
- 経過時間を表示するステータスバーを追加。
- 再生中はkeep-awakeで画面が落ちにくいよう調整。
- 再生開始時クラッシュや複数録画時の扱いを修正。
- 録画一覧の保存・再生・削除を設定画面から扱えるようにした。
- 再生は記録時の時間差を反映して自動進行し、ループ末尾では待機を入れるようにした。
### ユーザーにとっての効果
- 運行状況の変化をあとから振り返りやすくなります。
- ある時点の列車位置を止めて確認できるため、検証や比較がしやすくなります。
- 以前の録画形式が残っていても、継続して参照しやすくなります。
## 2. mock列車位置データでの検証がしやすくなった
### 実装/更新された機能
- WebViewへmock列車位置データを注入する仕組みを追加。
- 設定画面に管理者向けのmock API切替を追加。
- mock切替時にWebViewを再読み込みするよう改善。
- app側の列車位置表示にもmockデータを反映するよう調整。
- XHRインターセプタの初期化タイミングと互換性を改善。
- 録画開始時はmockを自動でOFFにし、再生開始時は自動でONに切り替えるようにした。
- WebView側では二重注入を避ける保護を入れ、mock適用の安定性を改善。
### ユーザーにとっての効果
- 実データに依存せず、特定状況の表示確認や再現テストを行いやすくなります。
- 検証時に「一部だけ反映されない」状態が起きにくくなります。
## 3. WebViewの読み込み失敗時の扱いを改善
### 実装/更新された機能
- ローディング表示とエラー表示を追加。
- 再読み込み導線を追加。
- アプリのバックグラウンド復帰時のリマウント制御を調整。
- 注入JavaScript側のヘッダー配色を見直し、見やすさを改善。
### ユーザーにとっての効果
- 読み込み失敗時に状況が分かりやすくなり、復旧操作もしやすくなります。
- 復帰直後の表示崩れや古い状態の残留が起きにくくなります。
## 4. 列車情報の表示品質を改善
### 実装/更新された機能
- 編成情報表示用に、静的表示・フェード切替・アクティブ強調のチップUIを追加。
- 列車番号の空白、メモ接尾辞、貨物列車記号を考慮した正規化処理を導入。
- 番線表示用の `platformNum` をデータ構造へ追加。
- 表示文字列やスタイル適用のタイミングを見直し、ちらつきや欠落を修正。
### ユーザーにとっての効果
- 列車情報の見分けがしやすくなります。
- データ照合が安定し、表示漏れや誤一致が起きにくくなります。
## 5. 設定・内部運用の整備
### 実装/更新された機能
- 内部バージョン表記を7.0.3へ更新。
- 前バージョン差分ログをドキュメント化。
- 検証用の列車データを更新。
- 情報ソース設定画面を拡張し、外部データ連携の説明、録画管理、mock検証、投稿システム接続先の切替を一か所にまとめた。
- 投稿システム接続先は、本番 / 実験 と リリース / ベータの切替を整理した。
### ユーザーにとっての効果
- バージョン識別と運用確認がしやすくなります。
- 今後の検証や保守時に、変更履歴を追いやすくなります。
- 検証設定や接続先の切替が分かりやすくなり、試験時の操作ミスを減らしやすくなります。
---
## まとめ
この更新は、列車位置を記録・再生して振り返るための新しい土台を追加しつつ、mock検証とWebView表示の安定性を同時に底上げした差分です。
日常利用の見た目改善だけでなく、再現確認や運用検証のしやすさまで含めて、一段階機能が広がった更新になっています。
## 参照
- コミット差分精査版: docs/changelog-7.0.2-to-7.0.3.md
+142
View File
@@ -0,0 +1,142 @@
# コミット差分ログ(a959cf39 -> 0b349148
## 対象範囲
- From: `a959cf3973eb9635c9e61133d81de5c1f9719c18`
- To: `0b349148d37802b6e86a09b7f34b718d622e92ca`
- 集計メモ:
- レンジ内コミット(merge含む): 29
- 非mergeコミット: 27
- 変更ファイル数: 50
- 変更行数: +10785 / -185
## サマリー
このレンジでは、列車位置の記録・再生機能の追加が中心です。
それを支える形で、mock列車位置データをWebViewへ注入するための検証基盤が大きく拡張され、あわせてWebViewの読み込み安定性、列車番号マッチング精度、表示コンポーネントの見やすさが改善されています。
前レンジが既存機能の安定化中心だったのに対して、このレンジは「記録して再生する」「mockで再現する」という検証・観測機能が大きく前進したのが特徴です。実装レベルでは、設定画面に録画管理や接続先切替UIが追加され、旧録画形式からの移行処理も入っています。
---
## 1. 列車位置の記録・再生機能を追加
- 列車位置の記録と再生機能を新規追加。
- 再生タイムラインUIを実装し、再生・一時停止・シーク操作に対応。
- 経過時間表示とkeep-awakeを備えた `RecordingStatusBar` を追加。
- 再生開始時クラッシュの修正と、複数録画データの取り扱いに対応。
- 再生フレーム変更時にWebView上の列車表示が追従するよう改善。
- 録画一覧の保存・削除・再生開始を設定画面から操作できるようにした。
- 再生ループはスナップショット間の経過時間差を反映しつつ、通常時は最小3秒、ループ終端では15秒待機する制御を実装。
- 旧単一録画フォーマットから複数録画フォーマットへの自動マイグレーション処理を追加。
主なコミット:
- `37c08ad` feat: add train position record & playback feature
- `8144e8a` feat: add playback timeline UI with pause/resume/seek controls
- `3587f72` feat: add RecordingStatusBar with elapsed timer and keep-awake
- `4f4d3ca` fix: crash on playback start + support multiple recordings
- `d71cc37` fix: sync WebView train display when playback frame changes
## 2. mock列車位置データの注入基盤を強化
- WebViewに対するXHRインターセプタを導入し、mock列車位置データの差し替えに対応。
- 設定画面に管理者向けmock API切替を追加し、検証導線を整備。
- 地図画面側の個別MOCKスイッチを整理し、設定側トグルに集約。
- mock切替時にWebViewを再読み込みするよう改善し、反映漏れを抑制。
- インターセプタ初期化タイミングを `injectedJavaScriptBeforeContentLoaded` へ移し、XHRフックの安定性を改善。
- `setRequestHeader` と競合しないようXhr open処理を修正し、既存通信との互換性を改善。
- app側の `currentTrain` にもmock列車位置を適用し、WebView外の表示も揃うよう調整。
- 録画開始時はmockを自動OFF、録画再生時は自動ONに切り替える制御を追加。
- XHRインターセプタに二重注入ガードを追加し、多重パッチによる不安定化を防止。
主なコミット:
- `170fbf0` feat: add WebView XHR interceptor for mock train position injection
- `71e1ad8` feat: add admin mock API toggle in settings and map screen switch
- `4247318` fix: reload WebView on mock toggle and fix XHR callback timing bug
- `59821a4` fix: rewrite XHR interceptor using prototype patching
- `8321a47` fix: move XHR interceptor to injectedJavaScriptBeforeContentLoaded
- `24f0c82` fix: call _origOpen even when intercepting to allow setRequestHeader
- `a359568` refactor: remove map-screen MOCK switch; settings toggle controls mock directly
- `92f4b37` feat: apply mock data to currentTrain (app-side train positions)
## 3. WebViewまわりの安定性と操作性を改善
- `GeneralWebView` にローディング表示、エラー表示、再読み込み導線を追加。
- `setReload` をグローバルへ公開し、外部からの再読み込み制御を改善。
- `useWebViewRemount``backgroundThresholdMs` オプションを追加し、アプリ状態遷移時のリマウント制御を改善。
- 注入JavaScript側のヘッダー色を見直し、視認性を改善。
主なコミット:
- `4c1a315` fix: add loading and error handling in GeneralWebView component with reload functionality
- `a8c785b` fix: expose setReload function to the global window object for accessibility
- `b5eb830` fix: update useWebViewRemount to include backgroundThresholdMs option for better app state handling
- `3554233` fix: update header colors in injected JavaScript for better visibility
## 4. 列車データの表示品質とマッチング精度を改善
- 編成・車両情報の視認性向上のため、静的表示・フェード循環表示・アクティブ枠アニメ表示の3系統から成る `FormationChips` 系コンポーネントを追加。
- train number の前後空白、メモ接尾辞、貨物列車の `レ` 記号を考慮した正規化処理を導入し、並び替え・抽出・照合の精度を改善。
- `TrainDataSources``useUnyohub` で列車番号の比較ロジックを強化。
- 貨物列車向けに 30xx / 90xx / 2桁番号の相互比較を行う補正ロジックを追加。
- `platformNum` プロパティをデータ構造へ追加し、番線表示の精度を改善。
- 表示文字列適用タイミングを見直し、observer反映直後のちらつきを軽減。
- 非同期再描画やmock有効時に表示スタイルが欠落するケースを修正。
主なコミット:
- `3a4e083` feat: add FormationChips, FadingSubCycler, and ActiveFormationChipsCycler components for enhanced train data visualization
- `a9668e6` fix: trim train numbers in sorting and filtering logic for accurate matching
- `45cc68a` fix: normalize train numbers by stripping suffixes in TrainDataSources and useUnyohub
- `899c655` fix: add platformNum property to train data structures for improved display
- `a809d28` fix: eliminate UX flash by calling setStrings() immediately in observer
- `7566910` fix: re-apply Tokyo UX after async train re-renders
- `2cf6b67` fix: Tokyo UX stripped when mock is active
## 5. バージョン更新と関連整備
- 内部バージョン表記を7.0.3へ更新。
- 前レンジの差分ログをドキュメントとして追加し、変更履歴の参照性を向上。
- live disrupted data を含む `train.json` を更新し、検証データを最新化。
- 情報ソース設定画面を拡張し、外部データソースの説明カード化、mock検証、録画管理、投稿システム接続先の系統/ UIバージョン切替を集約。
- 接続先は 本番 / 実験 と リリース / ベータ の組み合わせで解決されるよう整理し、旧環境キーからの後方互換正規化も追加。
主なコミット:
- `6ce2cad` fix: update version code to 7.0.3
- `43f8095` feat: add changelog for version update from 064d81d4 to a959cf39, highlighting improvements in elesite integration, navigation stability, keyboard handling, and overall user experience
- `1dcc25d` chore: update train.json with live disrupted data (2026-05-01 19:42)
---
## 変更リスクと確認ポイント
- 記録/再生系:
- 長時間記録データでタイムライン操作やシーク時の追従遅延、終端ループ待機が意図どおり動くか確認が必要。
- mock注入系:
- 実通信とmock通信の切替直後にキャッシュや古いXHRフックが残らないか、録画開始時OFF / 再生開始時ONの自動切替も含めて継続確認が必要。
- WebView系:
- 読み込み失敗後の再試行導線が端末差や回線差で安定して動くか確認が必要。
- 表示整合性:
- 列車番号正規化により、例外的な命名規則や貨物列車番号が意図せず同一扱いされないか確認が必要。
- 設定/運用系:
- 投稿システム接続先の切替で、実験系と本番系の URL 解決や旧設定値の移行が破綻しないか確認が必要。
## 付録: 非mergeコミット一覧(27件)
- `6ce2cad` fix: update version code to 7.0.3
- `3554233` fix: update header colors in injected JavaScript for better visibility
- `d71cc37` fix: sync WebView train display when playback frame changes
- `3587f72` feat: add RecordingStatusBar with elapsed timer and keep-awake
- `4f4d3ca` fix: crash on playback start + support multiple recordings
- `8144e8a` feat: add playback timeline UI with pause/resume/seek controls
- `37c08ad` feat: add train position record & playback feature
- `b5eb830` fix: update useWebViewRemount to include backgroundThresholdMs option for better app state handling
- `92f4b37` feat: apply mock data to currentTrain (app-side train positions)
- `a359568` refactor: remove map-screen MOCK switch; settings toggle controls mock directly
- `a809d28` fix: eliminate UX flash by calling setStrings() immediately in observer
- `7566910` fix: re-apply Tokyo UX after async train re-renders
- `2cf6b67` fix: Tokyo UX stripped when mock is active
- `1dcc25d` chore: update train.json with live disrupted data (2026-05-01 19:42)
- `24f0c82` fix: call _origOpen even when intercepting to allow setRequestHeader
- `8321a47` fix: move XHR interceptor to injectedJavaScriptBeforeContentLoaded
- `59821a4` fix: rewrite XHR interceptor using prototype patching
- `4247318` fix: reload WebView on mock toggle and fix XHR callback timing bug
- `71e1ad8` feat: add admin mock API toggle in settings and map screen switch
- `170fbf0` feat: add WebView XHR interceptor for mock train position injection
- `899c655` fix: add platformNum property to train data structures for improved display
- `4c1a315` fix: add loading and error handling in GeneralWebView component with reload functionality
- `a8c785b` fix: expose setReload function to the global window object for accessibility
- `a9668e6` fix: trim train numbers in sorting and filtering logic for accurate matching
- `3a4e083` feat: add FormationChips, FadingSubCycler, and ActiveFormationChipsCycler components for enhanced train data visualization
- `45cc68a` fix: normalize train numbers by stripping suffixes in TrainDataSources and useUnyohub
- `43f8095` feat: add changelog for version update from 064d81d4 to a959cf39, highlighting improvements in elesite integration, navigation stability, keyboard handling, and overall user experience
+384
View File
@@ -0,0 +1,384 @@
# JR四国非公式アプリ データ取得経路マップ
作成日: 2026-07-07
## 目的
Sentryで観測している `endpoint=positions / operation_info / train_operation / timetable` が、実際にどのURL、hook、画面、バックエンド、外部取得元に対応するかを整理する。
この文書は調査結果のみ。既存コードの変更案は含めるが、実装変更は行っていない。
## 対象範囲
- React Native / Expo アプリ側の `fetch`, `observedFetchJson`, `observedFetchText`
- WebViewに注入しているJavaScript内の `fetch`
- WebView内の公式サイトXHR、およびmock XHR interceptor
- `lib/mockApi`
- `stateBox` 配下のfetch
- n8n webhook
- Google Apps Script fallback
- `jr-shikoku-backend-api-v1.haruk.in`
- `jr-shikoku-backend-mock-api-v1.haruk.in`
- `jr-shikoku-api-data-storage.haruk.in`
- JR四国公式走行位置 `https://train.jr-shikoku.co.jp/sp.html`
- JR四国公式運行情報 `https://www.jr-shikoku.co.jp/info/`
- 鉄道運用Hub / えれサイトの集約JSON
## Sentry観測対象の取得一覧
| 論理endpoint | 呼び出し元ファイル | 関数/hook | 実URL/host | path | root_tab/screen | 取得タイミング | 更新間隔 | timeout | retry | cache | fallback | Sentry tag endpoint | 備考 |
|---|---|---|---|---|---|---|---|---:|---|---|---|---|---|
| positions current mock | `lib/mockApi/positionMasters.ts` | `fetchMockTrainPositions` | `jr-shikoku-backend-mock-api-v1.haruk.in` | `/train-positions/current` | positions / startup provider | mock API有効時、`useCurrentTrain``useTrainMenu` から呼ばれる | 15秒 | 8000ms | 最大1回 | module memory last-good | 前回成功データを返す | `positions_current` / source=`mock_api` / user_visible=`true` / preload=`false` / fetch_priority=`high` | Sentryでtimeout確認済みの主対象。バックエンド側計測が必要。 |
| positions master mock | `lib/mockApi/positionMasters.ts` | `fetchPositionMasters` | `jr-shikoku-backend-mock-api-v1.haruk.in` | `/position-masters` | positions / settings-dependent | mock API有効化時、または起動時にmock有効なら取得 | 基本1回 | 8000ms | 最大1回 | module memory `_cache` | なし | `positions_master` / source=`mock_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | PosNum+LineからPos文字列を補完。頻度は低い。 |
| positions n8n | `stateBox/useCurrentTrain.tsx` | `getCurrentTrain` | `n8n.haruk.in` | `/webhook/c501550c-7d1b-4e50-927b-4429fe18931a` | positions | 起動時、mock切替時、interval | 15秒 | 8000ms | 最大1回 | なし | Google Apps Script fallback | `positions_current` / source=`n8n` / user_visible=`true` / preload=`false` / fetch_priority=`high` | mock API無効時の主経路。成功時は録画snapshotにも使う。 |
| positions GAS fallback | `stateBox/useCurrentTrain.tsx` | `getCurrentTrain` catch内 | `script.google.com` | `/macros/s/AKfyc.../exec` | positions | n8n走行位置取得失敗時のみ | n8n失敗時のみ | 8000ms | 最大1回 | 既存データとmerge | 前回表示データ維持 | `positions_gas_fallback` / source=`gas` / user_visible=`true` / preload=`false` / fetch_priority=`high` | Direction等が欠けるため既存データとmerge。 |
| timetable today | `stateBox/useAllTrainDiagram.tsx` | `getTrainDiagram` | `jr-shikoku-api-data-storage.haruk.in` | `/tmp/diagram-today.json` または `/tmp/diagram-today-beta.json` | provider全体 / timetable consumers | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | AsyncStorage `ALL_TRAIN_DIAGRAM` | cache失敗時はalert | `timetable_today` / source=`static_storage` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | ダイヤ・発車標・列車詳細で広く使用。 |
| train operation data | `stateBox/useAllTrainDiagram.tsx` | `getCustomTrainData` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/train-data` | provider全体 / train detail / diagram | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | なし | 失敗は握りつぶし | `train_operation_data` / source=`backend_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | 約1MB級。WebView内でも別途取得される。 |
| operation logs | `stateBox/useAllTrainDiagram.tsx` | `getTodayOperation` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/operation-logs` | provider全体 / topMenu / train detail | provider mount直後 | 30秒、background継続指定あり。ただしobservedFetch側はbackground開始を抑制 | 15000ms | 最大1回 | なし | 失敗は握りつぶし | `operation_logs` / source=`backend_api` / user_visible=`false` / preload=`true` / fetch_priority=`medium` | 列車別運行ログ。WebView内でも別途取得される。 |
| operation flag | `stateBox/useAreaInfo.tsx` | `getAreaData` | `n8n.haruk.in` | `/webhook/jr-shikoku-trainfo-flag` | topMenu / information / provider全体 | mount後、InteractionManager後に1200ms遅延 | 60秒 | 10000ms | 最大1回 | なし | 失敗は握りつぶし | `operation_info_flag` / source=`n8n` / user_visible=`true` / preload=`true` / fetch_priority=`medium` | topMenuのバッジ・対象エリア判定。 |
| operation text GAS | `stateBox/useAreaInfo.tsx` | `fetchAreaDescription` | `script.google.com` | `/macros/s/AKfycbz80.../exec` | topMenu / information | operation flagで対象駅ありの場合だけ800ms遅延 | flag結果依存 | 15000ms | なし | なし | 失敗は握りつぶし | `operation_info_text` / source=`gas` / user_visible=`true` / preload=`false` / fetch_priority=`medium` | 第1回観測でtimeout。現在は通常時の実行を抑制。 |
## WebView内の取得一覧
| 論理endpoint | 呼び出し元ファイル | 関数/hook | 実URL/host | path | root_tab/screen | 取得タイミング | 更新間隔 | timeout | retry | cache | fallback | Sentry tag endpoint | 備考 |
|---|---|---|---|---|---|---|---|---:|---|---|---|---|---|
| positions official page | `components/Apps/WebView.tsx` | `AppsWebView` | `train.jr-shikoku.co.jp` | `/sp.html` | positions | 走行位置root初回activate時 | WebView lifecycle依存 | WebView標準 | WebView標準 | WebView page cache / OS依存 | remount watchdog | 未計測 | RN SentryではWebView navigation breadcrumbのみ。 |
| positions official XHR live | JR四国公式ページ内 | 公式サイトXHR | `train.jr-shikoku.co.jp` | `/g?arg1=train&arg2=train` | positions WebView | 公式ページロード後、公式サイト側ポーリング | 公式サイト実装依存。概ね数秒単位 | 公式サイト/ブラウザ依存 | 不明 | 公式サイト/ブラウザ依存 | mock interceptor可能 | 未計測 / 候補 `positions_webview` | RN wrapperを通らない。現状Sentryで通信詳細が見えない。 |
| positions official XHR static | JR四国公式ページ内 | 公式サイトXHR | `train.jr-shikoku.co.jp` | `/g?arg1=lang...`, `/g?arg1=station...`, etc | positions WebView | 公式ページロード時 | 基本初回 | 公式サイト/ブラウザ依存 | 不明 | 公式サイト/ブラウザ依存 | mock static intercept optional | 未計測 | `lib/mockApi/webviewXhrInterceptor.ts` に一覧コメントあり。 |
| positions WebView injected train-data | `lib/webViewInjectjavascript.ts` | `startPolling/DatalistUpdate` | `jr-shikoku-backend-api-v1.haruk.in` | `/train-data` | positions WebView | Phase 2後、polling開始 | 30秒 | なし | なし | WebView localStorage 1日 | 失敗は無視 | 未計測 / RN側は `train_operation` | RN側Providerと重複取得。WebView内なのでRN Sentryにspanなし。 |
| positions WebView injected operation-logs | `lib/webViewInjectjavascript.ts` | `operationListUpdate` | `jr-shikoku-backend-api-v1.haruk.in` | `/operation-logs` | positions WebView | Phase 1、Phase 3 polling、visibility復帰 | 30秒 | なし | なし | WebView localStorage 1日 | 失敗は無視 | 未計測 / RN側は `operation_info` | RN側Providerと重複取得。 |
| positions WebView injected diagram | `lib/webViewInjectjavascript.ts` | `TrainDiagramData2Update` | `jr-shikoku-api-data-storage.haruk.in` | `/tmp/diagram-today.json` | positions WebView | Phase 2、polling | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗は無視 | 未計測 / RN側は `timetable` | RN側Providerと重複取得。環境切替URLが注入optionにあるが、現コード内fetch文字列は定数参照。 |
| positions WebView injected station-list | `lib/webViewInjectjavascript.ts` | Phase 1 | `n8n.haruk.in` | `/webhook/jr-shikoku-station-list` | positions WebView | WebView注入script起動時 | 初回中心 | なし | なし | WebView localStorage 1週間 | 失敗は無視 | 未計測 / 候補 `station_info` | 走行位置WebView表示の補助データ。 |
| positions WebView injected problems | `lib/webViewInjectjavascript.ts` | `getProblemsData` | `n8n.haruk.in` | `/webhook/jrshikoku-position-problems` | positions WebView | Phase 1、polling、visibility復帰 | 30秒 | なし | なし | WebView localStorage 1分 | 失敗は無視 | 未計測 / 候補 `positions_problem` | 位置情報問題データ。 |
| positions WebView unyohub | `lib/webViewInjectjavascript.ts` | `unyohubDataUpdate` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/unyohub-unyo.json` | positions WebView | 設定ON時、Phase 3 | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗はconsole | 未計測 / 候補 `thirdparty_unyohub` | 鉄道運用Hub由来の集約JSON。 |
| positions WebView elesite | `lib/webViewInjectjavascript.ts` | `elesiteDataUpdate` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/elesite-unyo.json` | positions WebView | 設定ON時、Phase 3 | 30秒 | なし | なし | WebView localStorage 1時間 | 失敗はconsole | 未計測 / 候補 `thirdparty_elesite` | えれサイト由来の集約JSON。 |
| operation official page | `ndView.tsx` | operation WebView | `www.jr-shikoku.co.jp` | `/info/` | information | 運行情報root初回activate時 | WebView lifecycle依存 | WebView標準 | WebView標準 | OS/WebView依存 | remount watchdog | 未計測 | injected scriptでページ内容を加工し、画像生成結果をpostMessage。 |
| operation hidden preload | `Apps.tsx` | `HiddenStartupPreloadWebViews` | `www.jr-shikoku.co.jp` | `/info/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | Sentry breadcrumb/contextのみ。 |
| positions hidden preload | `Apps.tsx` | `HiddenStartupPreloadWebViews` | `train.jr-shikoku.co.jp` | `/` | startup hidden preload | 起動時preload条件成立時 | 1回 | WebView標準 | なし | OS/WebView依存 | なし | 未計測 | 走行位置WebViewの事前ウォームアップ。 |
## Sentry endpoint対象外だが存在するRN fetch
| 用途 | 呼び出し元ファイル | 実URL/host | path | タイミング | 更新間隔 | cache | 備考 |
|---|---|---|---|---|---|---|---|
| update権限 / 外部データ利用権限 | `stateBox/useTrainMenu.tsx` | `jr-shikoku-backend-api-v1.haruk.in` または beta | `/check-permission?user_id=...` | push token取得後 | token/backend変更時 | なし | Sentry未計測。queryにuser_idあり。 |
| バス・列車データ | `stateBox/useBusAndTrainData.tsx` | `script.google.com` | GAS | mount時、AsyncStorage miss時 | 基本1回 | AsyncStorage `BUS_AND_TRAIN` | Sentry未計測。 |
| train pair data | `stateBox/useBusAndTrainData.tsx` | `script.google.com` | GAS | mount時 | 基本1回 | なし | Sentry未計測。 |
| 遅延速報EX | `stateBox/useTrainDelayData.tsx` | `script.google.com` | GAS | `loadingDelayData`変化時 | 手動/状態依存 | なし | Sentry未計測。Android widgetにも同URLあり。 |
| 特急列車情報 | `components/Menu/SpecialTrainInfoBox.tsx` | `n8n.haruk.in` | `/webhook/sptrainfo` | component mount | 1回 | なし | Sentry未計測。 |
| 位置ID補助情報 GET | `components/発車時刻表/LED_inside_Component/TrainPosition.tsx`, `components/ActionSheetComponents/EachTrainInfo/TrainDataView.tsx` | `n8n.haruk.in` | `/webhook/JR-shikoku-PosID-v3?PosId=...` | 列車詳細/LED表示時 | 表示対象変更ごと | なし | Sentry未計測。UI細部表示用。 |
| 位置ID補助情報 POST/DELETE | `TrainPositionDataPush.tsx`, `TrainPositionDataDelete.tsx` | `n8n.haruk.in` | `/webhook/JR-shikoku-PosID-v3` | ユーザー投稿/削除要求 | 操作時 | なし | Sentry未計測。mutating request。 |
| 通知設定登録 | `components/Settings/NotificationSettings.tsx` | `n8n.haruk.in` | `/webhook/jr-shikoku-notification-configurations` | 設定保存操作時 | 操作時 | AsyncStorageに設定保存 | Sentry未計測。tokenを送る。 |
| unyohub RN hook | `stateBox/useUnyohub.tsx` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/unyohub-unyo.json` | 設定ON時 | 10分 | AsyncStorage | WebView内でも別取得あり。 |
| elesite RN hook | `stateBox/useElesite.tsx` | `jr-shikoku-api-data-storage.haruk.in` | `/thirdparty/elesite-unyo.json` | 設定ON時 | 10分 | AsyncStorage | WebView内でも別取得あり。 |
| 駅住所LOD | `components/駅名表/AddressText.tsx` | station別 `jslodApi` | `.json` | 駅名標表示時 | 表示対象変更ごと | なし | 外部LOD。Sentry未計測。 |
| Anpanman列車状態 | `components/ActionSheetComponents/EachTrainInfoCore/trainIconStatus.tsx` | `n8n.haruk.in` | `/webhook/{anpanmanApiPath}?trainNum=...` | 対象列車詳細表示時 | 対象変更ごと | なし | Sentry未計測。 |
| Android operation widget | `components/AndroidWidget/InfoWidget.tsx` | `script.google.com` | operation info GAS | widget更新時 | widget runtime依存 | なし | RN画面外。 |
| Android delay widget | `components/AndroidWidget/TraInfoEXWidget.tsx` | `script.google.com` | delay GAS | widget更新時 | widget runtime依存 | なし | RN画面外。 |
| GeneralWebView recording import | `GeneralWebView.tsx` | arbitrary / blob / resolvedUri内 | download URL | 録画importリンク操作時 | 操作時 | なし | 任意URL由来。Sentry endpoint化すべきではない。 |
## endpoint別依存関係
### positions
```text
positions screen
├─ RN provider: useCurrentTrain
│ ├─ mock ON: jr-shikoku-backend-mock-api-v1 /train-positions/current
│ │ └─ fallback: module memory last-good cache
│ └─ mock OFF: n8n /webhook/c501550c...
│ └─ fallback: Google Apps Script positions fallback
│ └─ fallback: currentTrain previous state if present
├─ RN provider: useTrainMenu
│ ├─ mock ON: /position-masters
│ └─ mock ON: /train-positions/current, then inject to WebView mock XHR
└─ WebView: train.jr-shikoku.co.jp/sp.html
├─ official XHR /g?arg1=train&arg2=train
├─ official XHR static /g?... station/lang/etc
└─ injected enhancement fetches backend/storage/n8n auxiliary data
```
表示対象:
- 走行位置画面
- 列車詳細ActionSheet
- 発車標/LED系
- fixed position box
現Sentry tag:
- `endpoint=positions`
- WebView公式XHRは未計測。候補は `positions_webview`
### operation_info
```text
topMenu / information / train detail
├─ useAreaInfo
│ ├─ n8n /webhook/jr-shikoku-trainfo-flag
│ └─ if active area exists: Google Apps Script operation text
├─ useAllTrainDiagram
│ └─ jr-shikoku-backend-api-v1 /operation-logs
├─ information WebView
│ └─ www.jr-shikoku.co.jp/info/ official page
│ └─ injected script parses/captures page and postMessage to RN
└─ widgets / delay providers
├─ operation info GAS
└─ delay GAS
```
表示対象:
- topMenuバッジ・運行情報カード
- informationタブ
- 列車詳細内の運行ログ表示
- Android widgets
現Sentry tag:
- `endpoint=operation_info`
- WebView公式ページは未計測。
### train_operation
```text
train detail / diagram / WebView enhancement
├─ RN: useAllTrainDiagram -> backend /train-data
└─ WebView injected JS -> backend /train-data
└─ localStorage cache 1 day
```
表示対象:
- 列車詳細
- ダイヤ/発車標
- 走行位置WebViewの列車アイコン・編成/運用装飾
現Sentry tag:
- RN fetch: `endpoint=train_operation`
- WebView fetch: 未計測
### timetable
```text
timetable / station diagram / train detail
├─ RN: useAllTrainDiagram -> data-storage /tmp/diagram-today.json
│ └─ fallback: AsyncStorage ALL_TRAIN_DIAGRAM
└─ WebView injected JS -> data-storage /tmp/diagram-today.json
└─ localStorage cache 1 hour
```
表示対象:
- 時刻表
- 発車標
- 列車詳細停車駅/通過駅
- 走行位置WebView内の補助表示
現Sentry tag:
- RN fetch: `endpoint=timetable`
- WebView fetch: 未計測
## 画面別fetch一覧
### アプリ起動直後
Providerがmountされるため、現在の構成では表示タブに関係なく以下が走り得る。
| 取得 | 起動時挙動 | 備考 |
|---|---|---|
| `useCurrentTrain.getCurrentTrain` | 即時1回、その後15秒interval | positions。mock ON/OFFで経路が変わる。 |
| `useAllTrainDiagram.getTrainDiagram` | 即時1回、その後30秒interval | timetable。AsyncStorage fallbackあり。 |
| `useAllTrainDiagram.getCustomTrainData` | 即時1回、その後30秒interval | train_operation。 |
| `useAllTrainDiagram.getTodayOperation` | 即時1回、その後30秒interval | operation_info。 |
| `useAreaInfo.getAreaData` | InteractionManager後 + 1200ms遅延。その後60秒interval | operation_info flag。対象ありならGAS textも800ms遅延。 |
| `useTrainMenu.check-permission` | push token取得後 | Sentry未計測。 |
| hidden preload WebViews | preload条件成立時 | positions公式/operation公式を不可視WebViewでロード。 |
### topMenu表示中
| 取得 | 発生源 | 備考 |
|---|---|---|
| positions RN polling | `useCurrentTrain` provider | 表示タブに関係なく継続。ただしbackgroundではinterval停止。 |
| timetable / train_operation / operation_logs | `useAllTrainDiagram` provider | 表示タブに関係なく30秒ごと。 |
| operation flag / text | `useAreaInfo` | topMenu表示内容に直結。 |
| special train info | `SpecialTrainInfoBox` | 該当Sheet表示時のみ。 |
### positions表示中
| 取得 | 発生源 | 備考 |
|---|---|---|
| positions RN polling | `useCurrentTrain` | 15秒。画面表示に必須。 |
| train.jr-shikoku公式WebView | `AppsWebView` | 公式XHRはRN Sentry不可視。 |
| WebView injected train-data / operation-logs / diagram / station-list / problems | `webViewInjectjavascript` | RN providerと重複取得あり。WebView localStorage cacheあり。 |
| unyohub / elesite | WebView injected JS、またはRN hooks | 設定ON時のみ。 |
### information表示中
| 取得 | 発生源 | 備考 |
|---|---|---|
| JR四国公式運行情報WebView | `ndView.tsx` | `https://www.jr-shikoku.co.jp/info/`。RN Sentryではpage load breadcrumb中心。 |
| operation flag/text | `useAreaInfo` | バッジ/本文補助。 |
| operation logs | `useAllTrainDiagram` | 列車別運行ログ。 |
### バックグラウンド復帰時
| 取得 | 発生源 | 備考 |
|---|---|---|
| `useInterval` | AppState active復帰時に即時実行 | userStoppedでなければ即時fetch。observedFetch側はbackground開始を抑制。 |
| positions WebView | `useWebViewRemount` / WebView lifecycle | iOSはunfocused時watchdog pause設定あり。 |
| WebView injected `refreshAllData` | document visibilitychange/pageshow | WebView内で train-data / operation-logs / position-problems を再取得。RN Sentry不可視。 |
| GeneralWebView | background 10秒超でremount | 汎用WebViewのみ。 |
## 起動時/タブ切替時fetchタイムライン
```text
T+0ms app providers mount
├─ useCurrentTrain -> positions current fetch
├─ useAllTrainDiagram -> timetable fetch
├─ useAllTrainDiagram -> train_operation fetch
├─ useAllTrainDiagram -> operation_logs fetch
└─ useTrainMenu -> settings/env load, maybe check-permission after token
T+after interactions + 1200ms
└─ useAreaInfo -> operation_info_flag
└─ if active area exists: +800ms -> operation_info_text GAS
TopMenu first screen
└─ no page-specific heavy WebView, but provider fetches above continue
positions first activation
├─ AppsWebView loads train.jr-shikoku.co.jp/sp.html
├─ official site starts /g?... XHRs
├─ injected JS Phase 0: WebView localStorage cache read
├─ injected JS Phase 1: station-list, operation-logs, position-problems
├─ injected JS Phase 2: train-data, diagram-today
└─ injected JS Phase 3: 30s polling, optional unyohub/elesite
information first activation
├─ ndView WebView loads www.jr-shikoku.co.jp/info/
└─ injected script parses/captures page and postMessage to RN when user capture actions occur
Tab switch back to positions/information
├─ root component is kept after activation
├─ WebView retention depends on current navigation/root gating and OS WebView process
└─ provider-level RN fetches generally continue independent of active tab
```
## 危険度・制御方針
### High priority
| 取得 | 分類理由 | 推奨方針 |
|---|---|---|
| `/train-positions/current` | 表示中timeoutがユーザー体感に直結。Sentryで実測済み。 | server-side duration/upstream/cacheログ、backend last-known-good、app persistent stale cache。 |
| `useCurrentTrain` n8n positions | mock OFF時の主経路。走行位置画面に必須。 | stale cache導入、失敗時UI全体error回避、endpoint細分化。 |
| JR公式WebView `/g?arg1=train&arg2=train` | 走行位置WebViewの本体だがRN Sentry不可視。 | WebView postMessage計測、またはRN側 proxy/cache化を検討。 |
| `/operation-logs` | WebView/RNで重複、列車詳細表示に影響。 | backend span/cache hit/miss、WebView側計測。 |
| `/train-data` | 大きいレスポンス、RN/WebViewで重複。 | stale cache、conditional fetch、WebView/RN共有設計。 |
### Medium priority
| 取得 | 分類理由 | 推奨方針 |
|---|---|---|
| `diagram-today.json` | 広範囲で使うがAsyncStorage fallbackあり。 | stale cache明示、WebView側との重複削減。 |
| `operation_info_flag` | topMenuバッジ/運行情報表示に影響。 | endpoint細分化、失敗時は前回状態維持。 |
| `operation_info_text` GAS | 遅延/timeout履歴あり。必須ではない詳細本文。 | preloadではなく遅延維持、stale cache、cellular抑制候補。 |
| station-list / position-problems WebView | 表示補助。WebView内でのみ見えにくい。 | WebView breadcrumb/postMessage計測。 |
| unyohub / elesite | 設定ON時のみだがWebView内は30秒polling。 | cellular抑制、10分程度へ間引き、Sentry breadcrumb。 |
### Low priority
| 取得 | 分類理由 | 推奨方針 |
|---|---|---|
| bus/train GAS | 初回またはcache miss中心。 | observedFetch化は後回し。 |
| train pair GAS | 補助データ。 | cache導入候補。 |
| special train info | Sheet表示時のみ。 | 失敗UIだけ整備。 |
| notification config | ユーザー操作時のみ。 | mutationとして個別エラーハンドリング。 |
| station LOD address | 駅名標詳細のみ。 | 失敗しても無表示でよい。 |
| Anpanman status | 装飾表示。 | 失敗してもUI全体に影響させない。 |
## stale cacheを入れるべき取得
| 優先度 | 取得 | 理由 | 推奨cache |
|---|---|---|---|
| High | `/train-positions/current` | 走行位置表示が直接壊れる | backend last-known-good + app persistent cache。TTL 30-120秒。 |
| High | n8n positions | mock OFF時の主経路 | app persistent last-good。TTL 30-120秒。 |
| High | JR公式WebView train XHR | WebView表示本体 | 公式XHRをRN/proxy化できるならstale。難しければWebView localStorage/postMessage。 |
| Medium | `/operation-logs` | 列車詳細/運行情報補助 | backend/app cache。TTL 1-5分。 |
| Medium | `/train-data` | 大容量かつ重複取得 | app/WebView共有cache。TTL 1日でも許容しやすい。 |
| Medium | `diagram-today.json` | 既にAsyncStorage fallbackあり | 既存cacheをstale表示として明示。TTL 1日。 |
| Medium | `operation_info_flag` | バッジ/エリア判定 | 前回状態維持。TTL 1-5分。 |
| Medium | `operation_info_text` | GAS遅延に弱い | 前回本文保持。TTL 5-30分。 |
| Low | unyohub / elesite | 設定ON時の補助 | 既存AsyncStorage/WebView localStorageを活用。TTL 10-60分。 |
## バックエンド側に計測を入れるべきAPI
| API | host | 理由 | 必要な計測 |
|---|---|---|---|
| `/train-positions/current` | `jr-shikoku-backend-mock-api-v1.haruk.in` | Sentryでforeground timeout確認済み。 | handler duration, upstream duration, cache hit/miss, stale served, status, bytes, upstream error kind。 |
| `/position-masters` | `jr-shikoku-backend-mock-api-v1.haruk.in` | mock経路の基礎データ。頻度低いが失敗時mock表示品質に影響。 | handler duration, cache hit/miss, db/storage duration。 |
| `/train-data` | `jr-shikoku-backend-api-v1.haruk.in` | 大容量・RN/WebView重複取得。 | handler duration, data source duration, response bytes, compression, cache status。 |
| `/operation-logs` | `jr-shikoku-backend-api-v1.haruk.in` | operation_infoとしてRN/WebView重複取得。 | handler duration, query/upstream duration, cache status, filtered count。 |
| `/check-permission` | `jr-shikoku-backend-api-v1.haruk.in` | user_id queryあり、失敗しても表示は続くが権限UIに影響。 | duration, auth/lookup duration, no PII logging。 |
| `diagram-today.json` publish pipeline | `jr-shikoku-api-data-storage.haruk.in` | 静的配信だが生成元障害は広範囲影響。 | CDN/storage access log, generation timestamp, object age, size。 |
| `thirdparty/unyohub-unyo.json` | `jr-shikoku-api-data-storage.haruk.in` | 外部由来の集約結果。 | generation timestamp, upstream scrape duration, source error count。 |
| `thirdparty/elesite-unyo.json` | `jr-shikoku-api-data-storage.haruk.in` | 外部由来の集約結果。 | generation timestamp, upstream scrape duration, source error count。 |
## Sentry endpoint tag改善案
RN側observedFetchについては、4分類から以下の低カーディナリティな論理endpointへ細分化済み。`area=data_fetch`, `result`, `root_tab`, `status` は維持し、`source`, `user_visible`, `preload`, `fetch_priority` を追加した。
### 現在のRN側endpoint分類
低カーディナリティを維持するため、URL単位ではなく論理単位で増やす。
| 旧endpoint | 現endpoint | 対象 |
|---|---|---|
| `positions` | `positions_current` | 現在位置本体。n8n current / mock currentを含めるかは要検討。 |
| `positions` | `positions_master` | `/position-masters`。 |
| `positions` | `positions_gas_fallback` | Google Apps Script fallback。 |
| 未計測 | `positions_webview` | JR公式WebView XHR / postMessage計測。 |
| `operation_info` | `operation_info_flag` | n8n flag。 |
| `operation_info` | `operation_info_text` | Google Apps Script text。 |
| `operation_info` | `operation_logs` | backend `/operation-logs`。 |
| `train_operation` | `train_operation_data` | backend `/train-data`。 |
| `timetable` | `timetable_today` | `diagram-today.json`。 |
| 未計測 | `thirdparty_unyohub` | unyohub集約JSON。 |
| 未計測 | `thirdparty_elesite` | elesite集約JSON。 |
### 追加tag
- `source=rn_fetch|mock_api|n8n|gas|backend_api|static_storage|webview_fetch`
- `user_visible=true|false`
- `preload=true|false`
- `fetch_priority=high|medium|low`
### 注意
- `endpoint` tagにURL全文やID入りpathは入れない。
- `urlHost``urlPathTemplate` はcontextに維持する。
- `positions_current` をn8n/mock/GASで分けすぎるとissue数は追いやすいが、全体のpositions失敗率は見にくくなる。`source` tagまたはcontextで `mock_api / n8n / gas` を分ける案も有効。
## 変更案の優先順位
### High
1. `positions_current` のendpoint細分化、または `source=mock_api|n8n|gas` 追加。
2. WebView公式XHR `/g?arg1=train&arg2=train` のpostMessage計測。
3. backend mock API `/train-positions/current` にserver-side span/cache/upstream durationを追加。
4. positionsのpersistent stale cache。
### Medium
1. `operation_info``operation_info_flag / operation_info_text / operation_logs` に分離。
2. WebView injected JS fetchの軽量postMessage計測。
3. `/train-data``diagram-today.json` のRN/WebView重複取得を整理。
4. unyohub/elesiteのWebView polling間隔を30秒から設定/TTLベースへ見直し。
### Low
1. LED/駅詳細/通知/Widget系の直接fetchをobservedFetch化。
2. 任意URLを扱うGeneralWebView importはSentry endpoint化せず、操作エラーとして別分類。
3. station LOD address fetchの失敗をUI非表示に統一。
## 現時点の結論
- Sentryで見えている `positions` のtimeoutは、RN側のmock currentまたはn8n/GAS経路のどれかに紐づく。今回観測された `urlHost=jr-shikoku-backend-mock-api-v1.haruk.in`, `urlPathTemplate=/train-positions/current` はmock currentで確定。
- 走行位置画面ではRN provider fetchとWebView公式XHR、さらにWebView injected fetchが並行して走る。見えているSentry spanはRN provider側だけで、WebView内の公式取得は不可視。
- `operation_info` はflag、GAS text、backend operation logs、公式WebViewの4系統が混在しているため、細分化した方が原因追跡しやすい。
- `train_operation``timetable` はRNとWebViewで重複取得している。ユーザー体感の安定化には、stale cacheと重複削減が効きやすい。
- バックエンド側で最優先に計測すべきは `/train-positions/current`。次点で `/train-data``/operation-logs`
@@ -0,0 +1,277 @@
# 通信計測ロガー実装報告 2026-07-07
## 目的
走行位置・運行情報・時刻表まわりの RN 側データ取得について、Sentry 上で endpoint 別の成功/失敗、HTTP status、通信時間、レスポンスサイズ、JSON 期待時の HTML 返却、JSON parse 失敗、timeout を追えるようにした。
今回の実装は低リスクな第1段階として、WebView 内 fetch と stale cache の実装は対象外にした。既存のバックグラウンド WebView 保持・タブ保持挙動には触れていない。
## 実装内容
### 追加した共通実装
- `lib/observability/network/types.ts`
- `lib/observability/network/endpoints.ts`
- `lib/observability/network/networkError.ts`
- `lib/observability/network/sentryNetwork.ts`
- `lib/observability/network/observedFetch.ts`
主な API:
```ts
observedFetchJson<T>(url, options)
observedFetchText(url, options)
```
記録する分類:
- `success`
- `slow_success`
- `timeout`
- `network_error`
- `http_error`
- `non_json_response` / Sentry tag result は `non_json`
- `json_parse_error` / Sentry tag result は `parse_error`
- `empty_response`
- `unknown`
### Sentry 設定
`App.tsx``Sentry.init` に以下を追加した。
- `tracesSampleRate: __DEV__ ? 1.0 : 0.05`
- `beforeSend``contexts.data_fetch.responseHead` を最大300文字に制限
既存の Replay / Feedback / Logs 設定は維持した。
### 計測対象にした RN fetch
#### 走行位置
- `stateBox/useCurrentTrain.tsx`
- n8n の走行位置 webhook
- Google Apps Script fallback
- `lib/mockApi/positionMasters.ts`
- mock position masters
- mock train positions current
endpoint tag: `positions`
#### 運行情報
- `stateBox/useAllTrainDiagram.tsx`
- `/operation-logs`
- `stateBox/useAreaInfo.tsx`
- 運行情報本文 text
- `jr-shikoku-trainfo-flag`
endpoint tag: `operation_info`
#### 運用・時刻表系
- `stateBox/useAllTrainDiagram.tsx`
- `/train-data`
- `diagram-today.json`
endpoint tag:
- `/train-data`: `train_operation`
- `diagram-today.json`: `timetable`
## Sentry に入る情報
### tags
- `area=data_fetch`
- `endpoint=positions|operation_info|train_operation|timetable`
- `result=success|slow_success|timeout|network_error|http_error|non_json|parse_error|empty_response|unknown`
- `root_tab=positions|topMenu|information|unknown`
- `platform=android|ios`
- `source=rn_fetch`
- `status=200` など、HTTP response がある場合のみ
`root_tab` は呼び出し側で明示指定しない場合、`rootNavigationRef` の active root route から補完する。
### contexts.data_fetch
- `endpoint`
- `method`
- `status`
- `ok`
- `durationMs`
- `timeoutMs`
- `bytes`
- `contentType`
- `responseHead` 失敗時中心、最大300文字
- `retryCount`
- `urlHost`
- `urlPathTemplate`
- `appState`
URL 全文や query parameter は送らず、host と path template のみを送る方針にした。
## 挙動仕様
- timeout は wrapper 内の `AbortController` で実装。
- `retry: true` の GET 系取得のみ、最大1回 retry。
- retry 対象は `timeout`, `network_error`, HTTP `502/503/504`
- `non_json_response`, `json_parse_error`, `empty_response`, HTTP `4xx` は retry しない。
- 成功は Issue 化しない。breadcrumb と span にのみ記録。
- `slow_success` の captureMessage は第1段階では無効化した。breadcrumb/span には残る。
- JSON endpoint は HTTP 200 だけでは success とせず、JSON parse 成功後に success 記録する。
## 未実装・次段階
- WebView 内 fetch の計測 hook は未実装。
- stale cache fallback の共通化は未実装。
- connection type の取得は未実装。必要なら `@react-native-community/netinfo` 等の導入可否を確認する。
- Sentry metrics は未実装。今回は span / breadcrumb / warning/error event に限定。
- slow success の warning event 化は無効。Sentry ノイズを見てから sample rate を上げる。
## 実機テスト観点
### 通常系
1. アプリを起動する。
2. トップメニューを表示する。
3. 走行位置タブを開く。
4. 運行情報タブを開く。
5. しばらく放置して 30 秒 interval の再取得を待つ。
期待:
- UI 挙動が従来と変わらない。
- Sentry の event / span / breadcrumb に `area:data_fetch` が見える。
- `endpoint:positions`, `endpoint:operation_info`, `endpoint:train_operation`, `endpoint:timetable` で絞れる。
### 失敗系
1. 機内モード、または通信不安定状態で起動する。
2. 走行位置・運行情報を開く。
期待:
- `result:timeout` または `result:network_error` が Sentry に warning として出る。
- `contexts.data_fetch.durationMs`, `timeoutMs`, `endpoint`, `root_tab` が入る。
- 既存 UI の fallback / error 表示が大きく変わらない。
### non_json / parse_error
実機で一時的に endpoint を HTML 返却 URL に向ける場合のみ確認。通常の配信ビルドでは無理に発生させない。
期待:
- HTML 返却は `result:non_json` / `kind:non_json_response`
- JSON parse 失敗は `result:parse_error` / `kind:json_parse_error`
- `responseHead` は最大300文字。
## Sentry 確認クエリ
```text
area:data_fetch
area:data_fetch endpoint:positions
area:data_fetch endpoint:operation_info
area:data_fetch result:timeout
area:data_fetch result:network_error
area:data_fetch result:non_json
area:data_fetch result:parse_error
area:data_fetch platform:android
area:data_fetch platform:ios
area:data_fetch source:rn_fetch
```
Performance / spans 側では `op:http.client`、span name `fetch positions` などで確認する。
## 検証状況
- `git diff --check` は対象ファイルで通過。
- `npx tsc --noEmit` は失敗するが、今回追加した network observability 由来の TypeScript エラーは出ていない。
- 残っている主な既存エラー:
- `Apps.tsx``event.preventDefault`
- `expo-live-activity` 型解決
- `components/trainMenu.web.tsx` の web 側型エラー
- `lib/felicaStationMap.ts` の重複 object key
## 2026-07-07 第1回観測後の追加修正
第1回観測で `operation_info``script.google.com` が timeout したため、以下を追加修正した。
- timeout 時の Sentry primary error が `AbortError` に寄らないよう、timeout では `ObservedFetchError("Network request timed out")` を primary として `captureException` する。timeout 時は元の AbortError を `cause` に入れない。
- `root_tab``unknown` になりにくいよう、`rootNavigationRef` から現在 route を取れない場合は `lastObservedRootRouteRef` を fallback にする。
- `Apps.tsx` の root navigation state 更新時に、最後に観測した root route を `lastObservedRootRouteRef` に保存する。
- `operation_info` の Google Apps Script text 取得は通常時に必ず走らせず、n8n の flag 取得で対象エリアがある場合だけ遅延実行する。
- 起動直後や `topMenu` 表示中の負荷を避けるため、初回 `operation_info` flag 取得は `InteractionManager.runAfterInteractions``setTimeout` で遅延する。
- 自作 span は `name` / `description` ともに `fetch <endpoint>` に統一した。実 URL が出る自動 HTTP span は Sentry SDK 側の自動計測として許容する。
この変更により、通常時の `script.google.com` text 取得は発生しにくくなり、運行情報対象駅がある場合のみ補助テキストとして取得される。
## 注意
今回の変更は観測基盤の第1段階であり、通信失敗時の UI 改善や stale cache 表示は含まない。実機で `area:data_fetch` のイベント量とノイズを確認してから、WebView fetch 計測と stale fallback を次段階で検討する。
## 第2回観測対応: positions timeout / background noise / huge span
Sentry観測で、foreground / positions表示中 / online / wifi の状態で `endpoint=positions` が timeout していた。
対象は `jr-shikoku-backend-mock-api-v1.haruk.in/train-positions/current`
観測値は `durationMs ~= 8028ms`, `timeoutMs = 8000`, `retryCount = 1` で、これはユーザー体感に直結する取得失敗として扱う。
### 実装したアプリ側対策
- `observedFetch` のtimeoutを `AbortController` 依存だけでなく `Promise.race` に変更した。
- React Native側のfetch abortが即時rejectしない場合でも、自作Sentry spanはtimeout時点で終了する。
- `operation_info` の29分級spanは、この経路が主因候補。
- retry前にruntime状態を再確認するようにした。
- `AppState.currentState === "background"` ならretryしない。
- `navigator.onLine === false` が取れる環境ではoffline時もretryしない。
- background/offline由来の取得抑制は `aborted` breadcrumb に留め、Issue化しない。
- fetch失敗後にruntimeがbackground/offlineなら `captureException` しない。
- `positions` のmock API取得にアプリ内last-good cacheを追加した。
- `/train-positions/current` がtimeout/network errorになっても、前回成功データがあればそれを返す。
- stale cache利用時は `fetch:stale_cache` breadcrumb を残す。
- 走行位置画面側でも、前回表示データがある場合は取得失敗で `currentTrainLoading=error` に落とさず `success` を維持する。
- `operation_info` の遅延fetch timerをcleanupするようにした。
- `InteractionManager.runAfterInteractions` 後の `setTimeout` がProvider破棄後に残る経路を潰した。
- Google Apps Script詳細取得の遅延timerも多重起動時に前回timerをclearする。
### このリポジトリで実装できなかったバックエンド側項目
このリポジトリ内を検索したが、`/train-positions/current` のサーバー実装は含まれていない。
見つかったのはフロント側の呼び出し元 `lib/mockApi/positionMasters.ts` のみ。
そのため、以下はバックエンド側リポジトリで実装が必要。
- `/train-positions/current` handler全体のserver-side duration log
- Sentry transaction/span
- upstream JR四国取得duration
- cache hit/miss
- stale/last-known-good cache返却
- upstream timeout時に200 + stale payloadを返すか、少なくとも短時間で明示的なfallback responseを返す設計
### 外部APIの簡易計測
2026-07-07時点で手元から直接 `curl` 計測した結果:
```
http_code=200
time_total=1.449047
time_connect=0.358893
time_starttransfer=1.448716
size_download=4887
```
この1回の計測では8秒遅延は再現していない。
Sentry上のtimeoutは一過性の上流遅延、バックエンド側のcache miss、または該当時刻の外部取得詰まりを疑う。
### 次にSentryで見るポイント
- `area:data_fetch endpoint:positions result:timeout` がIssue化されるのは foreground / online のみになっているか。
- timeout後に `fetch:stale_cache` breadcrumb が付くか。
- `operation_info` のspan durationが15秒timeout近辺で止まり、29分級spanが再発しないか。
- background中の `network_error` がIssue化されずbreadcrumbに留まるか。
### 残課題
- offline判定は現時点では `navigator.onLine` が取れる環境に限定される。
React Nativeで確実に判定するなら `@react-native-community/netinfo` などの導入が必要。
- last-good cacheはアプリプロセス内メモリのみ。アプリ再起動後もstale表示したい場合は永続cacheが必要。
- 根本対策はバックエンド側で `/train-positions/current` のlast-known-good cacheを返すこと。
File diff suppressed because it is too large Load Diff
File diff suppressed because one or more lines are too long
@@ -0,0 +1,28 @@
# Keyboard Animation Tuning (2026-04-08)
## Scope
- Map search: `components/Menu/RailScope/SearchUnitBox.tsx`
- Train number search: `components/AllTrainDiagramView.tsx`
- Station diagram search: `components/StationDiagram/StationDiagramView.tsx`
## What Was Refactored
- Extracted repeated easing/duration values into local constants in each file.
- Extracted repeated `Animated.timing` options in map search into a small helper (`runTiming`).
- Consolidated repeated `LayoutAnimation.configureNext` payload in map search into `SEARCH_LAYOUT_ANIM`.
- Cleaned indentation/readability around map search input/header block.
## Behavioral Notes
- No intended behavior change in this refactor pass.
- Existing keyboard/search animation behavior remains as tuned earlier.
## Known Existing Type Warnings (pre-existing)
- `react-native-vector-icons/Ionicons` missing type declaration warning in `SearchUnitBox.tsx`.
- Index signature/implicit any warnings around line color key mapping in `SearchUnitBox.tsx`.
## If Tuning Again
- Primary knobs:
- `KEYBOARD_BOTTOM_DURATION`
- `SEARCH_MORPH_DURATION`
- `PADDING_ANIM_DURATION`
- `CLOSE_BUTTON_ANIM_DURATION`
- Current easing is unified to `Easing.inOut(Easing.ease)`.
@@ -0,0 +1,138 @@
# キーボード回避アニメーション改修 (2026-04-09)
## 概要
`useKeyboardAvoid` hook を `LayoutAnimation` ベースから `Animated.timing` / `Animated.spring` ベースに全面的に書き換え、以下の問題を解決した。
## 解決した問題
| 問題 | 根本原因 | 対策 |
|---|---|---|
| キーボードの開閉アニメーションが動かない | `LayoutAnimation.configureNext``measure()` 非同期コールバック内で呼んでいたが、New Architecture (Fabric) では `adjustResize` による commit に消費/無効化されていた | `Animated.timing` / `Animated.spring` に移行。非同期コールバック内からでも確実にアニメーションが発動する |
| 閉じ→すぐ開きで位置が壊れる | 150ms timer 発火後の `measure()` が飛行中の状態で hide イベントが来てもキャンセル不可能。古い座標のコールバックが混入していた | `measureGenRef` 世代カウンタで古い `measure()` コールバックを無効化 |
| Android で 150ms 後の `measure()` が中間座標を返す | `adjustResize` のウィンドウリサイズは非同期で 250-300ms かかるため、150ms では中間状態を拾うことがある | `retryTimerRef` による 500ms リトライで自動訂正 |
## 変更ファイル
### `lib/useKeyboardAvoid.ts`
#### LayoutAnimation → Animated.timing / Animated.spring
```typescript
// 旧: LayoutAnimation.configureNext → setState (measure() コールバック内で動作しない)
LayoutAnimation.configureNext(LAYOUT_ANIM_CONFIG);
setMeasuredOffset(offset);
// 新: Animated.timing / Animated.spring (どのコンテキストからでも動作する)
if (Platform.OS === "ios") {
Animated.spring(animatedOffset, {
toValue, damping: 500, stiffness: 1000, mass: 3,
useNativeDriver: false,
}).start();
} else {
Animated.timing(animatedOffset, {
toValue, duration: 250, easing: Easing.out(Easing.cubic),
useNativeDriver: false,
}).start();
}
```
- iOS: `Animated.spring` でキーボードの spring アニメーションに追従
- Android: `Animated.timing` + `Easing.out(Easing.cubic)` で自然な減速カーブ
#### measureGenRef (世代カウンタ)
```typescript
const measureGenRef = useRef(0);
// show イベント: 新しい世代を発行
const gen = ++measureGenRef.current;
doMeasure(kbInfo.screenY, kbInfo.height, gen);
// measure() コールバック内: 古い世代なら破棄
if (gen !== measureGenRef.current) return;
// hide イベント: 世代をインクリメントして飛行中コールバックを無効化
measureGenRef.current++;
```
#### retryTimerRef (Android リトライ)
```typescript
// 150ms: 初回 measure (adjustResize 途中の可能性あり)
showTimerRef.current = setTimeout(() => doMeasure(..., gen), 150);
// 500ms: リトライ (adjustResize 完了後の確定座標で自動訂正)
retryTimerRef.current = setTimeout(() => doMeasure(..., gen), 500);
```
#### currentAnimRef (アニメーションキャンセル)
```typescript
const currentAnimRef = useRef<Animated.CompositeAnimation | null>(null);
const animateTo = (toValue: number) => {
if (currentAnimRef.current) {
currentAnimRef.current.stop(); // 前のアニメをキャンセル
}
const anim = Animated.timing(animatedOffset, { ... });
currentAnimRef.current = anim;
anim.start(({ finished }) => {
if (finished) currentAnimRef.current = null;
});
};
```
#### hide debounce
| プラットフォーム | delay | 理由 |
|---|---|---|
| Android | 300ms | IME 切替時の hide→show 連続発火対策 |
| iOS | 50ms | rapid close→open で `animateTo(0)` が先走るのを防止 |
### `components/AllTrainDiagramView.tsx`
- `View``Animated.View` に変更
- `measuredOffset` (plain number) → `animatedOffset` (Animated.Value) に変更
### `components/StationDiagram/StationDiagramView.tsx`
- 同上
### `components/Menu/RailScope/SearchUnitBox.tsx`
- 変更なし(`measuredOffset` (plain number) を引き続き使用。`position: absolute``bottom` に Animated.Value は不要)
## hook の返却値
```typescript
interface UseKeyboardAvoidResult {
keyboardVisible: boolean; // キーボードが表示中か
keyboardHeight: number; // キーボードの生の高さ
animatedOffset: Animated.Value; // Animated.View の paddingBottom/bottom 用
measuredOffset: number; // plain number (SearchUnitBox 等向け後方互換)
}
```
## タイミングまとめ (Android)
```
t=0ms: keyboardDidShow
t=0ms: setKeyboardVisible(true), setKeyboardHeight(kbHeight)
t=0ms: gen = ++measureGenRef.current
t=150ms: doMeasure(gen) → measure() 開始 (adjustResize 途中の可能性)
t=~165ms: measure() callback → gen チェック → animateTo(offset)
t=500ms: doMeasure(gen) リトライ → measure() 開始 (adjustResize 完了済み)
t=~515ms: measure() callback → gen チェック → animateTo(確定offset)
```
## タイミングまとめ (iOS)
```
t=0ms: keyboardWillShow
t=0ms: setKeyboardVisible(true), setKeyboardHeight(kbHeight)
t=0ms: gen = ++measureGenRef.current
t=0ms: doMeasure(gen) → measure() 開始
t=~5ms: measure() callback → gen チェック → Animated.spring 開始
t=~250ms: spring アニメーション完了(キーボード出現と同期)
```
+84
View File
@@ -0,0 +1,84 @@
# プライバシーポリシー 将来追加予定セクション
このファイルは、今後の機能実装時にプライバシーポリシーへ追加する内容の設計メモです。
各機能のリリース時に privacy-policy.md へ統合してください。
---
## アカウント機能(OAuthログイン)リリース時に追加
### 1.1 アカウント情報(新規セクション)
本アプリでは、ソーシャルログイン(OAuth)によるアカウント機能を提供します。
- ログイン時に、利用する認証プロバイダ(Google、Apple等)から以下の情報を取得します。
- 認証プロバイダが発行するユーザー識別子(ID)
- 表示名(認証プロバイダが提供する場合)
- メールアドレス(認証プロバイダが提供する場合)
- これらの情報はアカウント管理および設定同期のために開発者のサーバーに保存します。
- パスワードを開発者側で保持することはありません。認証は各プロバイダに委任されます。
- アカウントの作成は任意であり、アカウントなしでも本アプリの基本機能は利用できます。
### 1.3 ユーザー設定の同期(新規セクション)
- アカウントにログインしている場合、以下の設定情報をサーバーに同期し、複数端末間で共有できます。
- お気に入り駅
- 表示設定(テーマ、レイアウト等)
- 通知設定
- カスタマイズ設定(アプリアイコン選択等)
- 同期はアカウントにログインしている場合にのみ行われます。未ログイン時はすべて端末内にのみ保存されます。
### 3.1 第三者サービス表に追加
| OAuth認証プロバイダ(Google、Apple等) | ソーシャルログイン | 各プロバイダ |
### 6. データの保存期間に追加
- **アカウント情報・同期設定:** アカウントが削除されるまで保持します。
- プッシュ通知トークンの記述に追記: 「またはアカウント削除時に削除されます。」
### 7. データの削除に追加
#### アカウントの削除
- アプリ内の設定画面からアカウントを削除できます。アカウント削除時に、サーバーに保存されたアカウント情報、同期設定データ、およびプッシュ通知トークンを削除します。
### 9. 児童のプライバシーに追記
- 13歳未満の児童がアカウント作成等を通じて個人情報を提供したことが判明した場合、速やかに当該アカウントおよび関連情報を削除します。
---
## FeliCa OSSデータベース機能リリース時に追加
### 1.4 ユーザーが任意で提供する情報(FeliCaオープンデータ)(新規セクション)
本アプリでは、交通系ICカードの駅コード・改札コードに関するオープンデータベースの構築に参加する機能を提供します。
#### 提供されるデータの範囲
- **提供対象:** 駅コード、改札コード、およびユーザーが入力する実績説明(駅名・改札名の対応情報等)
- **提供対象外:** IDm(製造ID)、残高、個人の乗降履歴、その他個人を特定しうる情報
#### データの公開
- 提供されたデータは、オープンソースのデータベースとして**一般に公開**されます。
- 公開されるデータには提供者個人を特定する情報は含まれません。
- 一度公開されたデータは、オープンソースとして第三者が複製・再配布する可能性があるため、**完全な削除を保証することはできません**。
#### 同意と任意性
- データの提供は完全に任意であり、提供しなくても本アプリの機能に制限はありません。
- データの提供時に、提供内容と公開範囲について個別に同意を求めます。
### 3.2 FeliCaオープンデータの公開(新規セクション)
ユーザーが任意で提供したFeliCa駅コード・改札コードのデータは、オープンソースデータベースとして一般に公開されます。公開データに個人を特定する情報は含まれません。
### 6. データの保存期間に追加
- **FeliCaオープンデータ:** オープンソースとして無期限に公開されます。
### 7. データの削除に追記
- アカウント削除後も、FeliCaオープンデータベースに提供済みのデータは、オープンソースデータとして公開が継続されます。
### 1.2 ICカード個人データの記述に追記
- ICカードのデータ提供機能(後述の1.4)とは明確に区別されます。
+151
View File
@@ -0,0 +1,151 @@
# 『JR四国非公式アプリ』プライバシーポリシー
最終更新日: 2026年4月3日
本プライバシーポリシー(以下「本ポリシー」)は、harukin(以下「開発者」)が提供する「JR四国非公式アプリ」(以下「本アプリ」)における、ユーザー情報の取り扱いについて定めるものです。
本アプリはJR四国(四国旅客鉄道株式会社)とは一切関係のない非公式アプリです。
---
## 1. 収集する情報
### 1.1 端末から取得し、端末内のみで利用する情報
以下の情報は端末内での処理にのみ使用し、外部サーバーへの送信は行いません。
#### 位置情報
- ユーザーの現在地を取得し、最寄り駅の情報表示や地図上での現在地表示に利用します。
- 位置情報はアプリがフォアグラウンドで動作している間のみ取得し、アプリを終了すると取得を停止します。
- 位置情報の利用は任意ですが、許可しない場合は最寄り駅の自動検出などの一部機能が制限されます。
#### ICカードデータ(NFC/FeliCa
- NFC対応端末において、交通系ICカードのIDm(製造ID)、残高、システムコード、利用履歴を読み取ります。
- 読み取った情報は端末内(AsyncStorage)にのみ保存され、ウィジェット等での残高表示に使用します。
- IDm、残高、個人の利用履歴等のデータは外部サーバーへ送信しません。
#### ユーザー設定・お気に入り
- お気に入り駅、表示設定(テーマ・レイアウト等)、通知設定、カスタマイズ設定などのユーザー設定は、すべて端末内(AsyncStorage)にのみ保存されます。
### 1.2 外部サーバーに送信する情報
以下の情報は、機能の提供に必要な範囲で開発者が運営するサーバーに送信されます。
#### プッシュ通知トークン(Expo Push Token
- プッシュ通知の配信および一部機能の権限確認のために、端末固有のプッシュ通知トークンを開発者のサーバーに送信します。
- このトークンはユーザーの識別にも使用されますが、氏名・メールアドレス等の個人を直接特定する情報とは紐づけません。
#### 通知設定情報
- プッシュ通知機能を利用する場合、通知カテゴリの設定内容(遅延情報EX、運行情報、怪レい列車BOTの各ON/OFF)をプッシュ通知トークンとともにサーバーに送信・保存します。
---
## 2. 情報の利用目的
収集した情報は以下の目的でのみ利用します。
- 最寄り駅の検出および地図上での現在地表示
- ICカード残高・利用履歴の端末内表示
- プッシュ通知の配信(列車遅延情報、運行情報等)
- 一部機能における権限確認
- アプリの機能改善および不具合対応
---
## 3. 第三者への情報提供
開発者は、収集した情報を第三者に販売または貸与することはありません。
ただし、本アプリは以下の第三者サービスを利用しており、各サービスの利用規約およびプライバシーポリシーに基づき、当該サービス提供者が情報を取得する場合があります。
| サービス | 用途 | 提供者 |
|---------|------|--------|
| Google Maps SDK | 地図表示・駅マーカー表示 | Google LLC |
| Firebase Cloud Messaging (FCM) | プッシュ通知基盤 | Google LLC |
| Expo Push Notifications | プッシュ通知配信 | Expo |
| Expo Updates | アプリのOTAアップデート配信 | Expo |
各サービスのプライバシーポリシーについては、以下をご参照ください。
- Google: https://policies.google.com/privacy
- Expo: https://expo.dev/privacy
---
## 4. WebViewを通じた第三者サイトへのアクセス
本アプリでは、以下のWebサイトをWebViewにより表示します。これらのサイトにおけるデータの取り扱いは各サイトのプライバシーポリシーに従います。
- JR四国 列車走行位置(https://train.jr-shikoku.co.jp/
- JR四国 運行情報(https://www.jr-shikoku.co.jp/info/
---
## 5. 第三者データソースの利用
本アプリでは、設定画面から任意で以下の第三者データソースとの連携を有効にできます。
- 鉄道運用Hubunyohub
- えれサイト(elesite
これらのデータソースから取得したデータは表示目的でのみ使用し、ユーザーの情報をこれらのサービスに送信することはありません。連携機能を利用する場合は、各サービスの利用規約に同意したものとみなします。
一部の第三者データソース(鉄道運用Hub等)は独自のログイン機能を提供しています。ユーザーがWebView内でログインした場合、そのセッション情報(Cookie等)はアプリ内のWebViewストレージに保持されます。開発者がこれらの認証情報にアクセスすることはありません。セッション情報の取り扱いは各サービスのプライバシーポリシーに従います。
---
## 6. データの保存期間
### サーバー側データ
- **プッシュ通知トークン・通知設定:** ユーザーが明示的に削除を要求するまで、またはサービス終了まで保持します。
### 端末内データ
- ユーザー設定、お気に入り、ICカードデータ、キャッシュデータ等は、ユーザーがアプリをアンインストールするか、端末内のデータを消去するまで保持されます。
---
## 7. データの削除
### 端末内データの削除
- アプリのアンインストール、または端末の設定からアプリのデータ消去を行うことで、端末内に保存されたすべてのデータを削除できます。
### サーバー側データの削除
- サーバーに保存されたプッシュ通知トークンおよび通知設定の削除を希望する場合は、後述の問い合わせ先までご連絡ください。
---
## 8. セキュリティ
開発者は、収集した情報の漏洩、紛失、改ざんを防止するために合理的な技術的措置を講じます。ただし、インターネットを通じたデータ送信の安全性を完全に保証するものではありません。
---
## 9. 児童のプライバシー
本アプリは特定の年齢層を対象としたものではなく、13歳未満の児童から意図的に個人情報を収集することはありません。
---
## 10. アナリティクス・広告
本アプリはアクセス解析ツール(アナリティクス)および広告配信サービスを使用していません。
---
## 11. 本ポリシーの変更
開発者は、本ポリシーを変更する場合があります。重要な変更を行う場合は、アプリ内のお知らせまたは開発者のWebサイトにて通知します。変更後も本アプリを継続利用した場合、変更後のポリシーに同意したものとみなします。
---
## 12. お問い合わせ先
本ポリシーに関するお問い合わせは、以下の窓口までご連絡ください。
- **開発者:** harukinXprocess
- **Twitter/X:** [@xprocess_main](https://twitter.com/xprocess_main)
- **マシュマロ(匿名質問箱):** https://marshmallow-qa.com/pag3sl0ju3g1jm7
- **Webサイト:** https://haruk.in
---
© 2022 - 2026 harukin
+58
View File
@@ -0,0 +1,58 @@
# 保存した運用録画のファイルインポート/エクスポート方針
## 目的
保存した走行位置録画を、クリップボードだけでなくファイルとして受け渡しできるようにする。
録画データを JSON として書き出し、同じ JSON を選択して取り込める構成にする。
## 導入済みの必須ライブラリ
- `expo-file-system`: 録画 JSON を一時ファイルまたは Documents 配下へ書き出し、インポート時に選択済みファイルを読み込む。
- `expo-document-picker`: ユーザーに録画 JSON ファイルを選ばせる。読み込み直後に扱いやすくするため、実装時は `copyToCacheDirectory: true` を使う。
既存の `expo-sharing` は単一ファイルの共有 fallback として残す。`react-native-share` は複数画像や複数ファイルの同時共有、共有先ごとの制御が必要なケースで使う。既存の `expo-clipboard` はコピペ export/import の fallback として残す。
## 実装済み
- 録画1件と全件の JSON envelope 書き出し。
- `react-native-share` によるネイティブ共有/保存。
- Web では `Blob``download` による JSON ダウンロード。
- `expo-document-picker``expo-file-system` による JSON 読み込み。
- 既存保存形式と同じ検証処理を通したインポート。
- 録画本体は `Paths.document/train-recordings` 配下の JSON ファイルへ保存し、AsyncStorage/SQLite には軽量な一覧メタだけ保存する。
- 旧SQLite保存の録画本体は、起動時/インポート時にファイルへ移行して旧キーを削除する。
## 想定する実装方針
### ネイティブ
1. 録画データを version 付き envelope JSON に変換する。
2. `expo-file-system``cacheDirectory``.json` ファイルを書き出す。
3. 単一 JSON は `expo-sharing` または `react-native-share` で共有する。
4. 複数画像や複数ファイルを同時共有する場合は `react-native-share``urls` を使う。
5. インポートは `expo-document-picker` で JSON を選択する。
6. 選択したファイルを `expo-file-system` で読み込み、既存の検証処理を通して保存する。
### Web
Web ではローカル file URI の `expo-sharing` が使えないため、`Blob``URL.createObjectURL` による download を別実装にする。
インポートはブラウザの file input 相当で読み込む方針にする。
## 正式採用する共有ライブラリ
- `react-native-share`: 複数画像や複数ファイルを同時共有するために正式採用する。Expo config plugin は options 付きで登録し、初期状態では `ios``android` の query 追加を空配列にしている。特定アプリ向けの `shareSingle``isPackageInstalled` が必要になった時点で、対象アプリの scheme/package を追加する。
- `enableBase64ShareAndroid`: 初期状態では `false`。ファイル URI 共有を基本にするため、Android の legacy storage permission を増やさない。base64 共有が必要になった場合だけ有効化する。
## 今回インストールしない候補
- `pako` または `fflate`: 録画 JSON が大きくなったときの gzip/deflate 圧縮候補。まずは plain JSON で運用し、共有できないサイズになった時点で入れる。
- `expo-file-system` config plugin options: iOS の Files アプリからアプリ Documents を直接見せたい場合に `enableFileSharing``supportsOpeningDocumentsInPlace` を検討する。現時点では共有シートと DocumentPicker で足りる。
- `expo-sharing` の共有受け取り設定: 他アプリから「このアプリで開く」導線を作る候補。まずはアプリ内のインポートボタンから選択する方式にする。
- `react-native-blob-util`: 大容量/バックグラウンド/Android Storage Access Framework の細かい制御が必要になった場合の候補。Expo managed workflow では導入コストが上がるため、現時点では使わない。
## 参考
- https://docs.expo.dev/versions/latest/sdk/filesystem/
- https://docs.expo.dev/versions/latest/sdk/document-picker/
- https://docs.expo.dev/versions/latest/sdk/sharing/
@@ -0,0 +1,372 @@
# Sentry Crash 調査履歴
## 目的
このメモは、現在追っているクラッシュ調査の経緯を ChatGPT 側で再考察しやすい形に整理したもの。
## 現象
- `top menu` を表示した後、タブ切り替えや回転をきっかけにクラッシュする。
- Android / iOS の両方で再現した。
- 既存の `lazy` / `detachInactiveScreens` は必須前提として扱う。
- 走行位置、運行情報、トップメニューの各画面で WebView / ネイティブ View のマウントが絡んでいる。
## 最初の主要シグナル
- Sentry の主エラーは `RetryableMountingLayerException: Unable to find viewState for tag ...`
- 典型的には Fabric / New Architecture のネイティブ UI ツリー更新失敗を示していた。
- つまり JavaScript の例外というより、View の mount / unmount タイミング競合を疑うべき状態だった。
## 試した方向性の履歴
### 1. 遷移タイミングをずらす
- `navigate` / `replace` / `reset` の直後に落ちるケースを疑い、遷移を一拍遅らせる案を検討した。
- ただし症状は完全には止まらず、遷移先だけの問題ではなかった。
### 2. ライフサイクル依存の自動解放を抑える
- iOS でブラウザや WebView がタブ切り替え時に開放される問題があり、`lazyload` を外す方向は破綻した。
- そのため、WebView を「常時破棄しない」方向を維持する必要があった。
### 3. Sentry の観測点を増やす
- 画面ごとに breadcrumb と context を追加した。
- 追加した主な観測点:
- `Apps.tsx`
- `Top.tsx`
- `components/Apps.tsx`
- `components/Apps/WebView.tsx`
- `MenuPage.tsx`
- `ndView.tsx`
- `lib/useWebViewRemount.ts`
- これで「どの画面がいつ active になったか」「WebView がいつ remount されたか」を追えるようにした。
## 重要な発見
### 発見 1: 走行位置画面の WebView が、非表示なのに active 扱いになる
- Sentry のイベントで、`root_tab = topMenu` なのに `positions_screen.activatedScreen = true``positions_webview.currentUrl = https://train.jr-shikoku.co.jp/sp.html` が見えていた。
- Replay 上はトップメニューだけに見えても、内部 state は走行位置側が先に活性化していた。
- これは「見た目では隠れているが、React Navigation / Fabric 的には既に mount されている」可能性を示した。
### 発見 2: `useIsFocused()` を初期 render の active 判定に使うのが危険
- `useIsFocused()` 由来の初期状態が、ナビゲータ初期化直後に想定外の `true` を返す経路があった。
- その結果、`positions` 側の root / stack / screen / webview が早すぎるタイミングで活性化した。
- これが Fabric の mount 競合を起こしている可能性が高い。
### 発見 3: `positions` の gate を 1 枚かませても足りなかった
- `DeferredPositionsRoot` を追加して root 側を遅延させたが、それでも crash は残った。
- つまり root だけではなく、stack / screen / WebView 側にも同じ「初期 focused 扱い」の問題が潜んでいた。
## 反復した修正
### 実施した instrumentation
- root navigation の state 変化を breadcrumb に残すようにした。
- `positions_stack` / `positions_screen` / `positions_webview` / `operation_screen` などの context を追加した。
- WebView remount の理由も記録するようにした。
### 実施した防御
- 画面遷移時の WebView remount を観測しやすくした。
- Android の回転時やバックグラウンド遷移時に何が起きるか追えるようにした。
## 現在の仮説
- 根本原因は React Navigation そのものではなく、初期 render 時の `focused` 判定を前提にした activation が早すぎること。
- その結果、非表示の `positions` ツリーが先に active になり、Fabric が mount state を失う。
- 画面遷移先の問題というより、「起動直後の数タイミングで走行位置を経由したタブ遷移が入ると壊れる」挙動に近い。
## upstream issue 調査メモ
- `software-mansion/react-native-reanimated#9785`
- `open`
- Android + Fabric + React Navigation stack animation 有効時に `SurfaceMountingManager` で crash。
- Android だけ stack animation を `none` にすると止まる、という回避策まで一致している。
- `software-mansion/react-native-reanimated#6908`
- `closed`
- Android Fabric で `entering/exiting animations` 使用時に大量 crash。
- 本文中に `RetryableMountingLayerException: Unable to find viewState for tag ... Surface stopped: false` があり、Sentry の症状にかなり近い。
- `software-mansion/react-native-reanimated#9636`
- `closed`
- `NativeProxy.preserveMountedTags` が Fabric の view preallocation 中に `Unable to find view for tag ... Surface stopped: false` で落ちる。
- 重い native view が同期イベントを投げると crash しやすいという話で、WebView / map 系にも連想が効く。
- `software-mansion/react-native-reanimated#8344`
- `open`
- multi-surface で `LayoutAnimationsProxy` が surface aware でない、という報告。
- 直接 Android の同一事象ではないが、「surface をまたぐ animation / mount transaction が壊れる」という意味で構造が近い。
## 現時点での対策方針
- Android の React Navigation stack animation は無効化する。
- Android の Reanimated `entering/exiting/layout` は、実際に使っている箇所から順に止めて再現率を下げる。
- 現在 repo 内で確認できた該当箇所は `components/Menu/Carousel/CarouselBox.tsx``FadeIn/FadeOut`
## 直近の修正
- `Apps.tsx`
- `DeferredPositionsRoot` の activation 初期値を `false` に変更。
- `Top.tsx`
- positions stack の activation 初期値を `false` に変更。
- `components/Apps.tsx`
- positions screen / WebView の activation 初期値を `false` に変更。
- `ndView.tsx`
- operation WebView の activation 初期値を `false` に変更。
## 直近の OTA
- branch: `gmarket`
- message: `initialize tab activation gates from false`
- update group ID: `82552333-cb5e-455f-ac58-65ada37f7acd`
- Android update ID: `019f2133-c3e6-7050-911c-49052b6b17a1`
- iOS update ID: `019f2133-c3e6-79b9-9776-88fd55ff72b8`
- commit: `cf3995fcbe34366b32b09ee1cfb6b27a7e42982c`
## いま ChatGPT に見てほしい論点
1. 初期 focused 判定を `false` にしても crash が続くなら、次はどの画面の mount ordering を疑うべきか。
2. `positions` ツリーのどこまでが本当に先に active になるのかを、Sentry context と replay でどう切り分けるか。
3. Fabric / New Architecture の mount race として扱うべきか、React Navigation の state propagation 問題として扱うべきか。
## 参考ファイル
- [Apps.tsx](/home/ubuntu/jrshikoku/Apps.tsx)
- [Top.tsx](/home/ubuntu/jrshikoku/Top.tsx)
- [components/Apps.tsx](/home/ubuntu/jrshikoku/components/Apps.tsx)
- [components/Apps/WebView.tsx](/home/ubuntu/jrshikoku/components/Apps/WebView.tsx)
- [ndView.tsx](/home/ubuntu/jrshikoku/ndView.tsx)
- [lib/useWebViewRemount.ts](/home/ubuntu/jrshikoku/lib/useWebViewRemount.ts)
## 現在の安定化パッチの意味
- 最新の `gmarket` 配信では、クラッシュ回避のために非表示タブの重い native view をかなり積極的に止めている。
- その結果、クラッシュは減った一方で、以下の UX 劣化が意図的に入っている。
- 走行位置 / 運行情報の WebView がタブ離脱後に再初期化されやすい
- タブ間で状態保持されず、開き直しに見える
- 動的ルーティングやバックグラウンド継続性が弱くなっている
- トップメニューの重い要素は遷移直後に即 mount されない
## 現在の安定基準点
- `gmarket` で配信済みの `topMenu` heavy mount 遅延パッチでは、少なくとも直近の再現テストでクラッシュ頻度は大きく下がっている。
- したがって、ここを一旦「クラッシュしにくい基準点」として扱い、以降は UX を壊している防御を一つずつ戻していく方針が妥当。
- この時点で重要なのは「全部戻す」ことではなく、「どの防御を戻すとクラッシュが再発するか」を Sentry で切り分けること。
## UX 劣化の主因になっている防御
- `components/Apps.tsx`
- `positions` の WebView を blur 時に teardown / deactivate している。
- `Apps.tsx` / `Top.tsx` / `lib/rootNavigation.ts`
- `positions` タブ離脱時に root / stack 側も落とす寄りの制御を入れている。
- `ndView.tsx`
- `information` の WebView も blur 中は deactivate している。
- `menu.tsx`
- `topMenu``MapView` / `CarouselBox` / `LED_vision` を Android で遅延 mount している。
## ここから戻す優先順位
1. `positions` の blur 時 WebView teardown を部分的に戻す
- 対象: `components/Apps.tsx`
- 理由: 走行位置の開き直し、状態喪失、バックグラウンド継続切断に最も効いているため。
2. `positions` の tab blur 時 stack / root teardown を緩める
- 対象: `Apps.tsx`, `Top.tsx`, `lib/rootNavigation.ts`
- 理由: タブ間データ共有、動的ルーティング、navigation state 維持の破綻に直結しているため。
3. `information` の blur 時 WebView teardown を戻す
- 対象: `ndView.tsx`
- 理由: 運行情報の開き直しや iOS 側の裏ブラウザ保持に関係するため。
4. `topMenu` の heavy mount 遅延を必要最小限まで縮める
- 対象: `menu.tsx`
- 理由: 初回表示のもたつき改善には効くが、状態維持より優先度は下。
5. 最終的に teardown をやめて pause ベースに置き換える
- 対象: `components/Apps.tsx`, `ndView.tsx` など
- 理由: 安定性を維持しながら UX を戻す本命だが、切り分け後にやるべき段階。
## 次の実施方針
- まず `positions` を中心に戻す。
- 1 回の変更で戻す範囲は狭くし、再現したらその差分だけを Sentry で見る。
- つまり今後の作業は「クラッシュ回避策の全撤廃」ではなく、「UX を壊している要素を優先順に切り戻し、再発ラインを特定する」ことが目的。
## 2026-07-03 時点の基準点
### 現在の評価
- 起動直後の `positions -> topMenu` / `positions -> information` で高頻度に落ちていた主クラッシュは、現時点ではかなり再現率が下がっている。
- 少なくとも直近の観測では、以前の `RetryableMountingLayerException` 系の大崩れは前面には出ていない。
- そのため、この時点は「主問題をかなり抑え込めた基準点」として保存する価値がある。
### この時点で残っている別系統の問題
- `JR-SHIKOKU-UNOFFICIAL-APPS-S`
- `Error: Unsupported top level event type "topUserLocationChange" dispatched`
- `root_tab = positions`
- `level = fatal`
- `ReactFabric-prod` 起点
- これは、今回の `topMenu / information / hidden prewarm` 系の問題とは別系統とみなす。
- つまり、この時点での主クラッシュ対策の評価をする際は、この issue は切り離して考える。
### この時点での差分の分類
#### A. 主クラッシュ抑止のコア差分
以下は、現時点の安定化に本質的に効いている可能性が高く、安易に戻さない。
- `Apps.tsx`
- `DeferredPositionsRoot`
- `hasVisitedPositions`
- startup delayed prewarm
- `detachInactiveScreens={false}`
- root tab の `positionsLifecycleRef` 連携
- `Top.tsx`
- `positions stack` の background prewarm
- stack activation gate
- `components/Apps.tsx`
- `positions screen` / `WebView` の activation gate
- `hasStableWebViewSession`
- blur 時 preserve
- unstable session 中の tab exit guard
- `components/Apps/WebView.tsx`
- WebView 初回安定化通知
- message / load / process termination の監視強化
- `lib/rootNavigation.ts`
- `positionsLifecycleRef`
- `lib/useWebViewRemount.ts`
- remount reason 付きの共通制御
#### B. 補助的な安定化差分
以下は、主因とは言い切れないが、再現率低下に寄与している可能性がある。
- `menu.tsx`
- top menu heavy content の delayed activation
- Android で `MapView` や重い要素をすぐ mount しない制御
- `ndView.tsx`
- operation WebView の delayed activation
- operation 側 remount / parse / settings load の観測
- `lib/stackOption.ts`
- Android で push animation を切る変更
- `components/Settings/settings.tsx`
- settings stack へ共通 animation option を適用
#### C. 観測専用で後から削れる差分
以下は、挙動改善というより Sentry で成功/失敗比較を行うための記録であり、安定化の評価が済めば削減可能。
- `Apps.tsx``nav.root` / `nav.tab` / `positions.root` breadcrumb と context の大半
- `Top.tsx``positions.stack` breadcrumb / context
- `components/Apps.tsx``positions.screen` breadcrumb / context
- `components/Apps/WebView.tsx``positions.webview` breadcrumb / context
- `MenuPage.tsx``topMenu.screen` breadcrumb / context
- `menu.tsx``top_menu_runtime` breadcrumb / context
- `ndView.tsx``operation.screen` / `operation.settings` / `operation.webview` breadcrumb / context
- `components/Menu/Carousel/CarouselBox.tsx``menu.carousel` breadcrumb
### この時点での重要な判断
- 今の差分は広いが、全部が同じ重みではない。
- まず守るべきなのは `positions` の activation / preserve / unstable guard 周辺であり、ここが今回の基礎。
- 一方、Sentry breadcrumb の大半は観測用なので、安定化判断後にかなり整理できる。
- `CarouselBox.tsx` は特に「Android fallback ロジック」と「breadcrumb」の両方が混ざっているため、後で整理する際は分離して扱う。
### 今後の戻し方の基準
- 戻すときは、まず観測用差分からではなく、`UX を壊しているがコアではない差分` を対象にする。
- つまり順番としては:
1. 観測を維持したまま補助差分を少し戻す
2. 再発しないことを確認する
3. 最後に breadcrumb / context を掃除する
- コア差分を先に剥がすと、再び「どこで壊れたか分からない状態」に戻るので避ける。
### この時点の結論
- `positions` の hidden / delayed activation と lifecycle guard 群は、今回の主クラッシュ対策の核として維持する。
- `topMenu` / `operation` / `carousel` 周辺の差分は、主因ではなく補助あるいは観測の可能性があるため、今後の整理対象にできる。
- 新たに見つかった `topUserLocationChange` は別問題であり、この基準点の評価には混ぜない。
## 2026-07-04 iOS WebView 保持問題の解決メモ
### 解決した現象
- iOS で `トップメニュー -> 走行位置 -> トップメニュー -> 走行位置` と遷移すると、走行位置 WebView が毎回開き直る問題。
- iOS で `トップメニュー -> 運行情報 -> トップメニュー -> 運行情報` と遷移すると、運行情報 WebView が毎回開き直る問題。
- ユーザー確認により、最新 `gmarket` OTA で iOS の上記挙動は完全に解消した。
### 解決に効いた差分
- `App.tsx`
- iOS のみ `react-native-screens` の最適化を起動時に無効化した。
- `enableFreeze(false)`
- `enableScreens(false)`
- この結果、タブ切替時に native screen 最適化層が WebView を実質的に破棄・再生成する経路を避けられた。
- したがって、iOS の WebView 保持問題については、現時点では `react-native-screens` / freeze / native screen detach 周辺が主因だったと扱う。
### 解決時点の OTA
- branch: `gmarket`
- message: `gmarket ios screens webview retention test`
- update group ID: `8aec430f-eb6d-4017-af74-c2ae7dee7fd4`
- Android update ID: `019f2dfb-69f6-72ab-8cdd-a3d1b9f4df8d`
- iOS update ID: `019f2dfb-69f6-75dd-956b-435a73519a54`
- commit: `6cea1ed4e86e30f356a7a1b5fc289f9fe1f9b5f5`
### この解決の扱い
- iOS では `react-native-screens` を無効化したため、画面保持は戻った。
- 一方で、screen 最適化を切ったことによる副作用は今後確認が必要。
- iOS の画面遷移アニメーションが重くならないか。
- トップメニューや設定画面のスクロールが再び重くならないか。
- スタック画面が増えた状態でメモリ使用量が過剰にならないか。
- ただし、今回のユーザー確認では iOS の走行位置・運行情報 WebView 保持は解決済みとして扱う。
## 2026-07-04 以降に残る検証項目
### 優先度 高: Android のタブ切替クラッシュが再発していないか
- iOS 向けの `enableScreens(false)``Platform.OS === "ios"` 限定なので、Android の挙動は直接変えていない。
- これまで Android では、走行位置の読み込み中にトップメニュー・運行情報へタブ切替すると `RetryableMountingLayerException` 系のクラッシュが再発していた。
- 確認手順:
- Android で起動直後に走行位置を開く。
- ページ読み込みが終わる前にトップメニューへ切り替える。
- 同じく、ページ読み込みが終わる前に運行情報へ切り替える。
- それぞれ複数回繰り返し、Sentry に新規 fatal が出るかを見る。
- 判定:
- fatal が出なければ、Android の現行 guard は維持。
- fatal が出るなら、Sentry breadcrumb で `positions.root` / `positions.stack` / `positions.screen` / `positions.webview` のどこまで進んだかを確認する。
### 優先度 高: iOS の解決差分の副作用確認
- iOS で screen 最適化を無効化したため、UX とメモリの確認が必要。
- 確認手順:
- 走行位置、トップメニュー、運行情報、設定画面を長時間行き来する。
- 設定画面やトップメニューの縦スクロールが重くならないか確認する。
- 運行情報の横画面表示、縦横切替、戻しを複数回試す。
- バックグラウンド復帰後に走行位置・運行情報が保持されるか確認する。
- 判定:
- 体感劣化が軽微なら、iOS の `enableScreens(false)` は維持。
- 明確な重さが出るなら、iOS だけ root tab 周辺を JS view ベースにしつつ、stack 側の screen 最適化だけ戻す案を検討する。
### 優先度 中: Android で `react-native-reanimated-carousel` を戻せるか
- 過去に Android のカクつき・クラッシュ対策として carousel 周辺を弱めた。
- ユーザー要望として、Android では `reanimated-carousel` をできれば使いたい。
- 確認手順:
- 現行の Android 安定性を先に確認する。
- その後、carousel を段階的に戻す。
- 戻す場合も Reanimated の `entering` / `exiting` / `layout` アニメーションとは分離して検証する。
- 判定:
- carousel 復帰で crash が出なければ採用。
- `RetryableMountingLayerException` が戻るなら、Android では carousel 本体または Reanimated layout animation を避ける。
### 優先度 中: Android / iOS の Sentry ノイズ整理
- 現在は切り分けのため breadcrumb / context が多い。
- 安定化判断後は、Sentry に残すものと削るものを分ける。
- 残す候補:
- root tab 遷移
- WebView remount reason
- process termination
- fatal 直前の active tab / orientation
- 削る候補:
- 通常 focus / blur の過剰な breadcrumb
- loadStart / loadEnd の頻繁な記録
- 一時的な activation gate の詳細 context
### 優先度 中: `topUserLocationChange` 系 fatal の別問題調査
- 以前確認した `JR-SHIKOKU-UNOFFICIAL-APPS-S` は今回の WebView 保持問題とは別系統として扱う。
- `Unsupported top level event type "topUserLocationChange" dispatched`
- `root_tab = positions`
- ReactFabric 起点。
- 確認手順:
- 位置情報取得中、走行位置表示中、タブ切替中のどのタイミングで出るか Sentry で再確認する。
- `useUserPosition` / MapView / location watcher 周辺を優先して見る。
### 優先度 低: トップメニュー指定起動時のバックグラウンド preload
- 過去の課題として、トップメニュー指定で起動した場合に走行位置・運行情報がバックグラウンドで読み込まれない問題が残っている。
- iOS の WebView 保持が戻ったため、再度評価する価値がある。
- 確認手順:
- `topMenu` 初期起動。
- 走行位置を開く前に、走行位置 WebView の prewarm が開始しているか Sentry context で見る。
- 運行情報も同様に hidden prewarm されるか見る。
- ただし、Android の起動直後クラッシュ再発リスクがあるため、優先度は低め。
### 優先度 低: 過去の回避策の整理
- iOS WebView 保持は `enableScreens(false)` で解決したため、それ以前に入れた一部の回避策は不要になった可能性がある。
- ただし Android のクラッシュ抑止にはまだ必要な可能性があるため、削除は Android 安定確認後に行う。
- 整理候補:
- `components/Apps/WebView.tsx` の iOS ping watchdog の必要性。
- `lib/useWebViewRemount.ts` の blur grace window の必要性。
- `Apps.tsx``PositionsTabScreen` 固定化の必要性。
- `ndView.tsx` の operation WebView delayed activation の必要性。
### 次の実務順
1. Android で走行位置読み込み中のタブ切替クラッシュが残っているか確認する。
2. iOS で `enableScreens(false)` の副作用を確認する。
3. Android が安定している場合のみ、`reanimated-carousel` 復帰検証に進む。
4. 安定確認後、Sentry breadcrumb と暫定回避策を整理する。
@@ -0,0 +1,525 @@
# Sentry観測ログ整理メモ
作成日: 2026-07-07
対象配信:
- EAS channel: `gmarket`
- Update group ID: `8fd66cda-2f4a-40e2-937f-7de8e62c72ec`
- Runtime version: `exposdk:55.0.0`
- Message: `gmarket app lifecycle crash sentinel`
## 目的
このメモは、現在のアプリがSentryへ送るはずのログ情報を、機能別に整理する。
今後、データ通信エラー、WebView白化、Sentryにfatal crashとして出ない体感クラッシュが発生したときに、どのSentry情報を見ればよいかを明確にする。
## 全体像
現在Sentryで観測できる主な系統は次の5つ。
| 系統 | 主な機能 | Sentry上の見え方 | 目的 |
|---|---|---|---|
| 通信計測 | RN側fetch wrapper | `area=data_fetch` のIssue、breadcrumb、span | endpoint別の失敗、timeout、HTTP status、JSON parse失敗を追う |
| アプリライフサイクル | crash sentinel | `app.previous_session_unexpected_exit` | fatal crashとして出ない突然終了を次回起動で検知する |
| root navigation | タブ遷移、root状態 | `root_navigation` context、`nav.root`/`nav.tab` breadcrumb | どのタブ、どの遷移直後に問題が起きたかを追う |
| WebView状態 | 走行位置、運行情報、hidden preload | `active_webviews` context、各WebView breadcrumb | クラッシュ/白化直前にどのWebViewが生きていたかを見る |
| 画面ゲート/プリロード | positions/information lazy activation | `positions_root_gate` / `operation_root_gate` context、root breadcrumb | preloadや初回activationのタイミング問題を追う |
## 1. 通信計測 data_fetch
実装場所:
- `lib/observability/network/observedFetch.ts`
- `lib/observability/network/sentryNetwork.ts`
- `lib/observability/network/endpoints.ts`
- `lib/observability/network/types.ts`
### 何をしているか
RN側の通信を `observedFetchJson` / `observedFetchText` 経由にして、endpointごとに次を記録する。
- fetch開始 breadcrumb
- 成功/失敗 breadcrumb
- `http.client` span
- 失敗時の `captureException`
- timeout時の primary error: `ObservedFetchError("Network request timed out")`
- JSON期待時のHTML返却検知
- JSON parse失敗検知
- retry回数
- appState / online状態
- URL全文ではなく host + path template
### Sentry tags
通信系Issueには主に次のtagが付く。
| tag | 値 | 用途 |
|---|---|---|
| `area` | `data_fetch` | 通信計測イベントの絞り込み |
| `endpoint` | 低カーディナリティendpoint名 | どの論理取得か |
| `result` | `timeout`, `network_error`, `http_error`, `non_json`, `parse_error`, `empty_response`, `success`, `slow_success`, `aborted` | 結果分類 |
| `root_tab` | `positions`, `topMenu`, `information`, `unknown` | 発生時のroot tab |
| `platform` | `ios`, `android` | OS差分 |
| `source` | `rn_fetch`, `mock_api`, `n8n`, `gas`, `backend_api`, `static_storage`, `webview_fetch` | 取得元分類 |
| `user_visible` | `true` / `false` | ユーザー体感に直結する取得か |
| `preload` | `true` / `false` | preload扱いか |
| `fetch_priority` | `high`, `medium`, `low` | 優先度 |
| `status` | HTTP status文字列 | HTTP系の絞り込み |
### Sentry context: `data_fetch`
| context field | 内容 |
|---|---|
| `kind` | 失敗分類。captureException時に付く |
| `endpoint` | 論理endpoint |
| `method` | 現状ほぼ `GET` |
| `status` | HTTP status |
| `ok` | response.ok |
| `durationMs` | 通信 + body read時間 |
| `timeoutMs` | endpointごとのtimeout |
| `bytes` | response body推定サイズ |
| `contentType` | response content-type |
| `responseHead` | 失敗時のみ最大300文字 |
| `retryCount` | `0` または `1` |
| `urlHost` | hostのみ |
| `urlPathTemplate` | path templateのみ |
| `appState` | `active`, `background`, `inactive` など |
| `online` | `navigator.onLine` が取れた場合のオンライン状態 |
### breadcrumb
| category | message | level | 意味 |
|---|---|---|---|
| `data_fetch` | `fetch:start` | `info` | fetch開始 |
| `data_fetch` | `fetch:success` | `info` | 成功 |
| `data_fetch` | `fetch:slow_success` | `warning` | slowMs超過。ただしIssue化は現在無効 |
| `data_fetch` | `fetch:timeout` | `error` | timeout |
| `data_fetch` | `fetch:network_error` | `error` | fetch reject |
| `data_fetch` | `fetch:http_error` | `error` | HTTP 4xx/5xx |
| `data_fetch` | `fetch:non_json_response` | `error` | JSON期待だがJSONに見えない |
| `data_fetch` | `fetch:json_parse_error` | `error` | JSON.parse失敗 |
| `data_fetch` | `fetch:empty_response` | `error` | 空レスポンス |
| `data_fetch` | `fetch:aborted` | `info` | background/offlineで開始抑制。Issue化しない |
### span
`Sentry.startSpan` が使える環境では次のspanを出す。
| field | 値 |
|---|---|
| `op` | `http.client` |
| `name` | `fetch <endpoint>` |
| `description` | `fetch <endpoint>` |
| `app.area` | `data_fetch` |
| `app.endpoint` | endpoint名 |
| `app.root_tab` | root tab |
| `http.method` | method |
| `server.address` | URL host |
注意:
- 自作span名には実URLを入れない。
- Sentry SDKの自動HTTP spanに実URLが出る可能性はある。
### Issue化ルール
| 結果 | Issue化 | level | 備考 |
|---|---|---|---|
| success | しない | breadcrumb/spanのみ | 正常系ノイズ防止 |
| slow_success | 現在しない | warning予定 | `SLOW_SUCCESS_SAMPLE_RATE = 0` |
| timeout | する | warning | `ObservedFetchError("Network request timed out")` |
| network_error | active/onlineならする | warning | background/offline由来は抑制 |
| http_error | する | warning | status付き |
| non_json_response | する | warning | HTML返却調査用にresponseHead |
| json_parse_error | する | error | 実装/データ形式不一致の疑い |
| empty_response | する | warning | 空body |
| aborted | しない | breadcrumbのみ | background/offline開始抑制 |
## 2. 現在の通信endpoint一覧
| endpoint | source | 機能 | 画面/利用箇所 | URL host/path | timeout | retry | user_visible | preload | priority |
|---|---|---|---|---|---:|---|---|---|---|
| `positions_current` | `n8n` | 現在走行位置 | 走行位置、列車詳細、発車標系 | `n8n.haruk.in` `/webhook/c501550c-7d1b-4e50-927b-4429fe18931a` | 8000ms | 最大1回 | true | false | high |
| `positions_current` | `mock_api` | mock現在走行位置 | mock有効時の走行位置 | `jr-shikoku-backend-mock-api-v1.haruk.in` `/train-positions/current` | 8000ms | 最大1回 | true | false | high |
| `positions_master` | `mock_api` | mock位置マスタ | mock有効時の補完データ | `jr-shikoku-backend-mock-api-v1.haruk.in` `/position-masters` | 8000ms | 最大1回 | false | true | medium |
| `positions_gas_fallback` | `gas` | 走行位置fallback | n8n失敗時のみ | `script.google.com` GAS `/exec` | 8000ms | 最大1回 | true | false | high |
| `operation_info_flag` | `n8n` | 運行情報フラグ/対象エリア | topMenu、information、badge | `n8n.haruk.in` `/webhook/jr-shikoku-trainfo-flag` | 10000ms | 最大1回 | true | true | medium |
| `operation_info_text` | `gas` | 運行情報本文補助 | operation flagで対象ありの場合のみ | `script.google.com` GAS `/exec` | 15000ms | なし | true | false | medium |
| `operation_logs` | `backend_api` | 列車別運行ログ | provider全体、列車詳細、WebView補助と重複 | `jr-shikoku-backend-api-v1.haruk.in` `/operation-logs` | 15000ms | 最大1回 | false | true | medium |
| `train_operation_data` | `backend_api` | 編成/運用系データ | provider全体、列車詳細、走行位置WebView補助と重複 | `jr-shikoku-backend-api-v1.haruk.in` `/train-data` | 15000ms | 最大1回 | false | true | medium |
| `timetable_today` | `static_storage` | 当日ダイヤJSON | provider全体、時刻表/発車標/列車詳細 | `jr-shikoku-api-data-storage.haruk.in` `/tmp/diagram-today.json` | 15000ms | 最大1回 | false | true | medium |
## 3. アプリライフサイクル crash sentinel
実装場所:
- `lib/observability/appLifecycleCrashSentinel.ts`
- `App.tsx`
- `Apps.tsx`
- `components/Apps/WebView.tsx`
- `ndView.tsx`
### 何をしているか
Sentryに `fatal`, `handled:false`, `event.type:crash` として出ない「ユーザー体感上の完全クラッシュ」を、次回起動時に検出する。
AsyncStorageに以下を保存する。
- `app_session_active=true`
- `normal_background`
- `sessionStartedAt`
- `lastHeartbeatAt`
- `lastKnownAppState`
- `lastRootTab`
- `previousRootNavigation`
- `activeWebViews`
- `nativeScreensMode`
稼働中は7秒ごとに `lastHeartbeatAt` を更新する。
AppStateが `background` になったら `normal_background=true` を記録する。
次回起動時、前回sessionが次を満たす場合だけ `app.previous_session_unexpected_exit` を送る。
- `app_session_active=true`
- `normal_background=false`
- `lastHeartbeatAt` が直近120秒以内
- sessionが5秒以上続いていた
- 同じ `sessionStartedAt` で未報告
### Sentry event
| event | level | 目的 |
|---|---|---|
| `app.previous_session_unexpected_exit` | warning | fatal crashとして出ない突然終了を補足 |
### tags
| tag | 内容 |
|---|---|
| `area=app_lifecycle` | lifecycle系の絞り込み |
| `result=unexpected_exit` | 前回session異常終了 |
| `platform` | `ios` / `android` |
| `root_tab` | 起動時に把握できる現在root tab |
| `last_root_tab` | 前回session最後のroot tab |
| `native_screens_mode` | `screens-disabled` or `default` |
### context: `app_lifecycle`
| field | 内容 |
|---|---|
| `lastHeartbeatAt` | 前回session最後のheartbeat |
| `sessionStartedAt` | 前回session開始時刻 |
| `previousRootNavigation` | 前回最後に観測したroot/nested navigation |
| `lastKnownAppState` | 前回最後のAppState |
| `activeWebViews` | 前回最後にactive扱いだったWebView名 |
| `memory` | `performance.memory` があればJS heap情報。RNではnullの可能性あり |
| `expoUpdate` | `update_id`, `updateId`, `channel`, `runtimeVersion`, `createdAt`, `isEmbeddedLaunch` |
| `normalBackground` | 前回正常background遷移したか |
| `heartbeatAgeMs` | 現在から見た前回heartbeatの古さ |
### 関連context
| context | 内容 |
|---|---|
| `app_lifecycle_sentinel` | 現sessionの開始、heartbeat、appState、activeWebViews、expoUpdate |
| `active_webviews` | 現在active扱いのWebView名一覧 |
### activeWebViews名
| name | 意味 |
|---|---|
| `positions_main` | 走行位置タブ本体のWebView |
| `operation_info_main` | 運行情報タブ本体のWebView |
| `startup_hidden_positions` | 起動時hidden preloadの走行位置WebView |
| `startup_hidden_operation` | 起動時hidden preloadの運行情報WebView |
## 4. root navigation / tab操作ログ
実装場所:
- `Apps.tsx`
- `lib/rootNavigation.ts`
### Sentry context: `root_navigation`
| field | 内容 |
|---|---|
| `rootIndex` | root tab index |
| `rootRoute` | `positions`, `topMenu`, `information` |
| `nestedIndex` | nested stack index |
| `nestedRoute` | nested route名 |
| `hasExtra` | 追加画面/stackが開いているか |
| `source` | `ready` or `change` |
| `operationOrientation` | `landscape` or `portrait` |
| `hasVisitedPositions` | positions rootをactivate済みか |
| `hasVisitedInformation` | information rootをactivate済みか |
### tags
| tag | 内容 |
|---|---|
| `root_tab` | 最後に観測したroot tab |
| `native_screens_mode` | iOSは `screens-disabled`、Androidは `default` |
### breadcrumb
| category | message | 意味 |
|---|---|---|
| `runtime.flags` | `runtime navigation flags applied` | native screens/freeze設定 |
| `nav.root` | `navigation ready` | NavigationContainer ready |
| `nav.root` | `root state initialized` | 初期root state |
| `nav.root` | `root state change` | root state変化 |
| `nav.tab` | `root tabPress` | タブ押下 |
| `nav.tab` | `root tabPress intercepted for unstable positions` | 不安定な走行位置離脱を検知 |
| `nav.tab` | `root tabPress blocked while positions unstable` | 不安定な走行位置離脱をブロック |
| `nav.tab` | `root tab focus` | root tab focus |
| `nav.tab` | `root tab blur` | root tab blur |
| `nav.tab` | `root tab state` | tab state event |
## 5. root gate / preloadログ
実装場所:
- `Apps.tsx`
### context
| context | 内容 |
|---|---|
| `positions_root_gate` | `focused`, `activated`, `shouldActivate` |
| `operation_root_gate` | `focused`, `activated`, `shouldActivate` |
| `startup_hidden_preload` | hidden preloadのshould/loaded状態 |
### breadcrumb
| category | message | 意味 |
|---|---|---|
| `positions.root` | `positions startup prewarm scheduled` | topMenu起動中にpositions prewarm予約 |
| `positions.root` | `positions startup prewarm activated` | positions prewarm実行 |
| `positions.root` | `positions root activation` | positions root初回activate |
| `positions.root` | `positions root tabPress activation` | positionsタブ押下でactivate |
| `positions.root` | `positions root focus activation` | positions focusでactivate |
| `positions.root` | `positions root blur preserved` | blur後も保持 |
| `positions.root` | `positions root exit blocked while session unstable` | 不安定セッションからの離脱ブロック |
| `operation.root` | `operation startup prewarm scheduled` | information prewarm予約 |
| `operation.root` | `operation startup prewarm activated` | information prewarm実行 |
| `operation.root` | `operation root activation` | operation root初回activate |
| `operation.root` | `operation root tabPress activation` | informationタブ押下でactivate |
| `operation.root` | `operation root focus activation` | information focusでactivate |
| `operation.root` | `operation root blur preserved` | blur後も保持 |
| `startup.preload` | `positions hidden preload loadStart/loadEnd/error` | hidden positions WebViewのロード状態 |
| `startup.preload` | `operation hidden preload loadStart/loadEnd/error` | hidden operation WebViewのロード状態 |
## 6. 走行位置WebViewログ
実装場所:
- `components/Apps/WebView.tsx`
- `lib/useWebViewRemount.ts`
### context: `positions_webview`
| field | 内容 |
|---|---|
| `focused` | screen focus状態 |
| `landscape` | 横画面か |
| `mockApi` | mock API有効か |
| `remountKey` | WebView remount key |
| `currentUrl` | WebView現在URL。取れていない場合null |
### breadcrumb category: `positions.webview`
| message | 意味 |
|---|---|
| `webview focused` / `webview blurred` | focus変化 |
| `webview pending initial inject resumed on focus` | focus復帰時に保留injectを実行 |
| `webview remount key` | remount key変化 |
| `webview remount requested` | WebView watchdog等でremount要求 |
| `webview cleanup start` | unmount/cleanup開始 |
| `webview unmounted` | unmount完了 |
注意:
- 公式サイト内XHR `/g?arg1=train&arg2=train` はRN側fetch wrapperを通らないため、現状 `data_fetch` には出ない。
- WebView内部の公式通信や injected JS fetch は、現時点ではbreadcrumb/postMessage中心で、通信詳細は未計測。
## 7. 運行情報WebViewログ
実装場所:
- `ndView.tsx`
- `lib/useWebViewRemount.ts`
### context: `operation_screen`
| field | 内容 |
|---|---|
| `focused` | information画面focus |
| `orientation` | `portrait` / `landscape` |
| `landscapeMode` | 運行情報横画面モード有効か |
| `captureEnabled` | 画像切り出し有効か |
| `activatedWebView` | WebView activate済みか |
| `layoutWidth` / `layoutHeight` | root layout |
| `contentTop` | safe area反映後top |
| `signageSafeLeft` / `signageSafeRight` | 横画面safe area |
| `remountKey` | WebView remount key |
### breadcrumb
| category | message | 意味 |
|---|---|---|
| `operation.screen` | `operation screen mounted/unmounted` | 画面mount状態 |
| `operation.screen` | `operation focused/blurred` | focus変化 |
| `operation.screen` | `operation layout` | layout/orientation変化 |
| `operation.screen` | `operation webview activation scheduled` | WebView activate予約 |
| `operation.screen` | `operation webview activated` | WebView activate実行 |
| `operation.screen` | `operation appState` | AppState変化 |
| `operation.settings` | `operation settings load scheduled/success/failed` | 運行情報設定の読込 |
| `operation.webview` | `operation remount requested` | watchdog等のremount要求 |
| `operation.webview` | `operation remount after background resume` | background復帰後remount |
| `operation.webview` | `operation resume preserved existing webview` | background復帰後WebView保持 |
| `operation.webview` | `operation script scheduled/injected` | injected script投入 |
| `operation.webview` | `operation render process gone` | Android WebView render process gone |
| `operation.webview` | `operation content process terminated` | iOS WebView content process termination |
| `operation.webview` | `operation webview loadStart/loadEnd/error` | WebView load状態 |
| `operation.webview` | `operation capture message received` | WebViewから画像切り出しmessage |
| `operation.webview` | `operation message parse failed` | postMessage parse失敗 |
## 8. 調査時の優先クエリ
### 通信エラー全体
```text
area:data_fetch
```
### 走行位置の体感不具合
```text
area:data_fetch endpoint:positions_current
```
見る項目:
- `source`
- `result`
- `durationMs`
- `timeoutMs`
- `retryCount`
- `root_tab`
- `appState`
- `online`
- `urlHost`
- `urlPathTemplate`
### GAS運行情報timeout
```text
area:data_fetch endpoint:operation_info_text result:timeout
```
見る項目:
- `durationMs`
- `retryCount`
- `root_tab`
- `user_visible`
- `preload`
- `appState`
### background/offlineノイズ
```text
area:data_fetch result:aborted
```
原則Issue化されない。breadcrumbで確認する。
### JSONではなくHTMLが返ったケース
```text
area:data_fetch result:non_json
```
見る項目:
- `contentType`
- `responseHead`
- `status`
- `urlHost`
- `urlPathTemplate`
### Sentryにfatal crashが出ない体感クラッシュ
```text
area:app_lifecycle result:unexpected_exit
```
見る項目:
- `last_root_tab`
- `app_lifecycle.lastHeartbeatAt`
- `app_lifecycle.previousRootNavigation`
- `app_lifecycle.lastKnownAppState`
- `app_lifecycle.activeWebViews`
- `app_lifecycle.expoUpdate.update_id`
- `native_screens_mode`
### WebView白化/終了疑い
```text
operation.webview
positions.webview
```
breadcrumbで見る。
関連context:
- `positions_webview`
- `operation_screen`
- `active_webviews`
- `app_lifecycle`
## 9. 機能別に何が分かるか
| 機能 | Sentryで分かること | まだ分からないこと |
|---|---|---|
| RN走行位置取得 | n8n/mock/GASのどれが失敗したか、timeout/HTTP/parse、duration、retry | 上流サーバー内部の処理時間。backend側計測が必要 |
| RN運行情報取得 | flag/GAS text/operation logsのどれが失敗したか | 公式WebViewページ内部の通信詳細 |
| ダイヤ/運用取得 | static storage/backend APIの失敗、遅延、サイズ | WebView内の重複fetch詳細 |
| WebView保持 | focus/blur、remount、process gone、content termination、activeWebViews | WebView内fetchのstatus/durationは未計測 |
| タブ切替 | tabPress/focus/blur、root state、root_tab | native側のFabric mount詳細 |
| 体感クラッシュ | 次回起動時に前回sessionの突然終了をwarning化 | OS killとnative crashの厳密な区別 |
## 10. 現時点の注意点
- 成功通信はIssue化しない。breadcrumb/spanで見る。
- `slow_success` のIssue送信は現在 `SLOW_SUCCESS_SAMPLE_RATE = 0` のため無効。
- background/offline由来の通信失敗はIssue化を抑制している。
- URL全文はtagに入れない。host/path templateのみcontextに入る。
- response body全文は送らない。`responseHead` は最大300文字。
- WebView内の公式XHRや injected JS fetch は、RN `observedFetch` を通らないため通信詳細が見えない。
- `app.previous_session_unexpected_exit` は「前回sessionが直近heartbeatのまま正常backgroundなしで消えた」ことを示す。Sentry native crashそのものではない。
## 11. 次に増やすとよい計測
優先度 high:
- WebView内fetchのpostMessage計測。
- 特に `positions_webview` と公式XHR `/g?arg1=train&arg2=train`
- backend mock API `/train-positions/current` のサーバー側duration、cache hit/miss、upstream duration。
優先度 medium:
- `useBusAndTrainData`, `useTrainDelayData`, `SpecialTrainInfoBox` など未計測RN fetchのobservedFetch化。
- `operation.webview` のpostMessageにページ状態 summary を追加。
- `activeWebViews``GeneralWebView` も必要に応じて追加。
優先度 low:
- slow success samplingを `0.01` から再開し、十分落ち着いたら調整。
- cellular/connection type取得。現状は `navigator.onLine` 程度。
+194
View File
@@ -0,0 +1,194 @@
# チュートリアル機能 設計案
## 概要
JR四国非公式アプリにチュートリアル機能を追加し、ユーザーの初回体験を改善する。
---
## 案1: 初回起動ウォークスルー(オンボーディング)
初回起動時に 3〜5 画面のスワイプ式ウォークスルーを表示。
| 画面 | 内容 |
|------|------|
| 1 | 「JR四国の列車位置をリアルタイムで確認できます」(マップ画面のスクリーンショット) |
| 2 | 「よく使う駅をお気に入り登録しよう」(お気に入り機能の紹介) |
| 3 | 「駅名標・発車時刻表も見られます」(駅ダイヤグラム紹介) |
| 4 | 「通知設定で遅延情報を受け取れます」(通知設定への誘導) |
**表示制御**: `AsyncStorage``TUTORIAL_COMPLETED` キーで管理。
---
## 案2: コーチマーク(ツールチップ型ガイド)
各画面で初めてアクセスした際に、UIパーツをハイライト+吹き出しで説明。
- **マップ画面**: 「ピンをタップすると列車情報が見られます」「このボタンで現在地に戻れます」
- **メニュー画面**: 「横スクロールで路線を切り替えられます」「★で駅をお気に入りに追加」
- **設定画面**: 「アプリアイコンを変更できます」
**表示制御**: `COACH_MARK_{SCREEN}_SHOWN` フラグで画面ごとに制御。
---
## 案3: コンテキスト依存ヒント
特定の操作タイミングで自動表示。
| トリガー | ヒント内容 |
|----------|-----------|
| お気に入り 0 件でメニュー表示 | 「駅をお気に入りに追加すると、ここからすぐアクセスできます」 |
| FeliCa 対応端末で初回起動 | 「交通系ICカードの履歴を読み取れます」 |
| 列車遅延発生時 | 「通知設定で遅延情報を自動受信できます → 設定へ」 |
| 長押し操作が可能な箇所 | 「長押しで詳細メニューが開きます」 |
---
## 案4: 「使い方」セクション刷新
現在の `howto.tsx`(WebView)を、アプリ内ネイティブ画面に置き換え。
- カテゴリ別に整理(「基本操作」「お気に入り」「ウィジェット」「FeliCa」など)
- GIF/Lottie アニメーションで操作手順を視覚的に表示(Lottie は導入済み)
- 設定画面からいつでもアクセス可能 + チュートリアルリセットボタン
---
## 案5: 段階的機能開放(プログレッシブ・ディスクロージャー)
使い込むにつれて高度な機能を提案。
```
初回起動 → 基本操作ガイド
3回目起動 → 「お気に入り登録してみませんか?」
1週間後 → 「ウィジェットを設定すると便利です」
```
---
## 実装の優先順位
1. **初回ウォークスルー** — 最もインパクト大、実装も比較的シンプル
2. **コンテキスト依存ヒント** — お気に入り 0 件ヒントなど簡単なものから
3. **コーチマーク** — マップ画面の操作説明に効果的
4. **使い方セクション刷新** — 既存の howto.tsx を段階的に改善
5. **段階的機能開放** — 長期的な改善施策
---
## 実装パターン比較: OTA vs ライブラリ追加
以下で詳細に比較する。
### パターンA: OTA配信可能(既存依存のみ)
既にインストール済みのライブラリのみで実装。`expo-updates` 経由の OTA で即座にユーザーへ配信可能。
#### 利用可能な既存ライブラリ
- `react-native-reanimated` (v4.2.1) — アニメーション全般
- `react-native-reanimated-carousel` (v4.0.3) — スワイプ式カルーセル(ウォークスルーに最適)
- `react-native-gesture-handler` (v2.30.0) — ジェスチャー制御
- `@gorhom/bottom-sheet` (v5) — ボトムシート型UI
- `react-native-actions-sheet` (v10.1.2) — アクションシート
- `lottie-react-native` (v7.3.1) — アニメーション素材再生
- `react-native-svg` (v15.15.3) — SVG描画
- `@react-native-async-storage/async-storage` — 表示状態の永続化
- `expo-haptics` — 触覚フィードバック
#### 設計方針
| 機能 | 実装方法 |
|------|----------|
| ウォークスルー | `react-native-reanimated-carousel` でページスワイプ + `reanimated` でフェードアニメーション |
| コーチマーク | 自前実装: `react-native-svg` で穴あきオーバーレイ + `View.measure()` でターゲット位置取得 |
| ヒント表示 | `@gorhom/bottom-sheet` or `react-native-actions-sheet` でスナックバー風表示 |
| アニメーション | `lottie-react-native` で手順説明アニメ |
| 状態管理 | `AsyncStorage` でフラグ管理 |
#### 難易度
| 機能 | 難易度 | 工数目安 | 備考 |
|------|--------|----------|------|
| ウォークスルー | ★★☆☆☆ | 小 | carousel がそのまま使える |
| コンテキストヒント | ★★☆☆☆ | 小 | BottomSheet/ActionSheet で簡単 |
| コーチマーク | ★★★★☆ | 大 | 穴あきオーバーレイの自前実装が必要。ターゲット要素の位置計測、スクロール追従、画面回転対応など |
| 使い方画面刷新 | ★★☆☆☆ | 中 | 通常の画面実装 |
| 段階的開放 | ★★☆☆☆ | 小 | AsyncStorage カウンタ + 条件分岐 |
#### メリット・デメリット
- ✅ OTA即時配信可能(ストア審査不要)
- ✅ 追加依存なし、バンドルサイズ増加なし
- ❌ コーチマーク(穴あきオーバーレイ)の自前実装コストが高い
- ❌ コーチマークのエッジケース対応(ScrollView内要素、モーダル上など)が大変
---
### パターンB: ライブラリ追加(ネイティブビルド必要)
チュートリアル専用ライブラリを導入。次回のストアビルド&審査が必要。
#### 追加候補ライブラリ
| ライブラリ | 用途 | ネイティブモジュール |
|-----------|------|---------------------|
| `react-native-copilot` | コーチマーク(ステップガイド) | なし(JS のみ) |
| `react-native-spotlight-tour` | スポットライト型ガイド | なし(JS のみ) |
| `@nickcarraway/react-native-tooltip-walkthrough` | ツールチップウォークスルー | なし(JS のみ) |
> **重要**: 上記候補はいずれも **Pure JS ライブラリ**(ネイティブモジュールなし)のため、実際には OTA 配信可能。ただし `node_modules` の変更を含むため EAS Build が推奨される場合がある。
#### 設計方針
| 機能 | 実装方法 |
|------|----------|
| ウォークスルー | パターンA と同じ(既存 carousel で十分) |
| コーチマーク | `react-native-copilot``CopilotProvider` + `walkthroughable()` HOC |
| ヒント表示 | パターンA と同じ |
| アニメーション | パターンA と同じ |
#### 難易度
| 機能 | 難易度 | 工数目安 | 備考 |
|------|--------|----------|------|
| ウォークスルー | ★★☆☆☆ | 小 | パターンAと同じ |
| コンテキストヒント | ★★☆☆☆ | 小 | パターンAと同じ |
| コーチマーク | ★★☆☆☆ | 小 | ライブラリが位置計測・オーバーレイを処理 |
| 使い方画面刷新 | ★★☆☆☆ | 中 | パターンAと同じ |
| 段階的開放 | ★★☆☆☆ | 小 | パターンAと同じ |
#### メリット・デメリット
- ✅ コーチマーク実装が大幅に楽(★★★★☆ → ★★☆☆☆)
- ✅ エッジケース(位置計測、スクロール追従)をライブラリが処理
- ❌ 新規依存追加(バンドルサイズ微増)
- ❌ ライブラリのメンテナンス状況・Expo SDK互換性リスク
- ⚠️ Pure JS ライブラリなら実質 OTA 可能だが、検証が必要
---
## 結論・推奨アプローチ
### フェーズ1(OTA配信)— すぐ着手可能
1. **ウォークスルー**: `react-native-reanimated-carousel` で実装 → OTA配信
2. **コンテキストヒント**: `BottomSheet` / `ActionSheet` で実装 → OTA配信
3. **段階的開放ロジック**: `AsyncStorage` カウンタ → OTA配信
### フェーズ2(次回ビルド時)— コーチマークの判断
- **コーチマークが必須なら**: `react-native-copilot`(Pure JS)を追加し、次回ビルドに含める
- **コーチマーク不要 or 後回しなら**: フェーズ1だけで十分な体験改善が可能
### 差分まとめ
| 観点 | パターンA(OTA) | パターンB(ライブラリ追加) |
|------|------------------|---------------------------|
| 配信速度 | 即座 | 次回ビルド待ち |
| コーチマーク難易度 | ★★★★☆ | ★★☆☆☆ |
| それ以外の難易度 | 同等 | 同等 |
| 依存リスク | なし | 低(Pure JS |
| 推奨 | フェーズ1はこちら | コーチマーク実装時に検討 |
@@ -0,0 +1,153 @@
# X投稿向け運行情報画像セット:実装指示書
## 依頼
`ndView.tsx` に、X(旧Twitter)へ複数画像で投稿することを前提とした運行情報画像セット生成機能を実装してください。
既存の「この項目を切り出す」「この小見出しを切り出す」「表示中の運行情報をすべて切り出す」「項目ごとに複数画像で共有」は残し、新たに「X投稿向け画像を作成」を追加します。
実装まで行い、型チェックと生成処理の確認をしてください。調査・提案だけで終了しないでください。
## ユーザー体験
新しいボタンを押すと、次の順番で最大4枚のPNGを生成し、既存の複数ファイル共有処理へ渡します。
1. 1枚目:運行状況の表紙(路線図+短い要約)
2. 2枚目以降:表示中の運行情報の詳細
Xで1枚目と2枚目が横並びに表示されたときに自然につながって見える構成にします。ただし、各画像は単体表示されても意味が通じる必要があります。画像の境界をまたいで文章や必須情報を配置しないでください。
## 出力仕様
- 各画像は `1080 x 1440 px`3:4)の固定サイズ
- PNG
- 画像数は1〜4枚。運行情報が存在する通常ケースでは表紙+詳細で2〜4枚
- ファイル名は投稿順を明確にする
- `operation-info-x-01-map-<timestamp>.png`
- `operation-info-x-02-detail-<timestamp>.png`
- 以下同様
- ネイティブ側へ送る `batchIndex` はゼロ始まりの表示順と一致させる
- 既存の `Share.open({ urls })` に渡る配列順を維持する
- 画像生成途中で1枚でも失敗した場合、不完全なセットを共有せず既存形式のエラーを返す
## 1枚目:表紙
必須要素:
- `JR四国 列車運行情報` のタイトル
- 現在表示中の状態を示す短い要約
- 四国の路線図
- 更新日時(取得できる最新のもの)
- 詳細ページがある場合は `詳細は次の画像へ →`
- 非公式アプリによる生成画像であり、最新情報はJR四国公式を確認する旨
- ページ番号(例:`1/4`
路線図は既存の `buildMapImage()``img/map/*` を再利用します。現在ページ上で有効になっている路線レイヤーを反映してください。既存挙動を壊さない範囲で、影響路線を目立たせ、影響のない部分を弱められるなら実装してください。状態と路線の対応を正確に判定できない場合は、誤った色分けを推測せず、現在表示されているレイヤー表現を使用してください。
表紙は路線図を主役にし、要約文が長い場合も地図を極端に小さくしないでください。要約は路線名・状態を中心に短く整形し、詳細本文を表紙へ詰め込みません。
## 2枚目以降:詳細ページ
必須要素:
- 路線・項目タイトル
- サブタイトル/状態
- 更新日時
- 小見出しと本文
- ページ番号
- 非公式画像の注意書き
既存の `getOperationItems()``parseDetailBlocks()``splitCaptureBlocksIntoSections()` のデータ構造を再利用します。
詳細は「1項目=必ず1画像」ではなく、固定高のページへ順に組版してください。
- 1件が短ければ、同じページに次の項目も配置してよい
- 1件が長ければ、ブロックまたは小見出し単位で次ページへ送る
- 小見出しだけをページ末尾に残さない
- 本文を文字単位で不自然に分断するより、小見出し単位の改ページを優先する
- 4枚を超える場合は、詳細3ページへ読みやすく収める
- それでも収まらない場合は、文字を極端に縮小したり内容を黙って欠落させたりしない
4枚で収まらないケースについては、実装上安全な扱いを選び、コードコメントと完了報告で明示してください。推奨は、最小可読サイズを守ったうえで4枚では収まらないことをユーザーへ知らせ、既存の項目別共有を案内することです。
## レイアウト規則
- 背景は白を基本とし、既存のJR四国風配色(`#0099CB` など)を継承
- 左右80px程度をX側のクロップに備えた安全領域とする
- 必須情報は端へ寄せない
- 全ページでヘッダー、ページ番号、フッターの位置を統一
- 本文の可読性を優先し、既存画像と同程度の文字サイズを維持
- 1枚目右側の矢印などで2枚目への視線誘導を作ってよいが、その装飾が切れても意味が失われないようにする
- 角丸や画像間の隙間はX側が付けるため、画像自体には外周の角丸を焼き込まない
## 既存コードの入口
主な対象は `ndView.tsx` です。
- `buildOperationPageScript()`WebViewへ注入する生成処理
- `buildMapImage()`:路線図Canvas合成
- `appendCaptureItemLayout()` / `drawCaptureLayout()`:既存組版・描画
- `getOperationItems()`:表示中の運行情報抽出
- `installPageCaptureButton()`:共有ボタン追加
- `renderAllItemsToSeparateImages()`:既存バッチ送信の参考
- `saveOperationCaptureBatchItem()`:ネイティブ側で順番どおりにファイルを集約
- `shareOperationCaptureFiles()`:単一/複数ファイル共有
必要ならX投稿向けのレイアウト計算・描画関数を独立させてください。既存の可変幅・可変高画像生成ロジックへ多数の条件分岐を足すより、データ抽出と基本描画だけ共有する構成を優先します。
## 実装上の制約
- 既存の切り出し/共有機能を壊さない
- 横画面サイネージモードでは、従来どおりキャプチャボタンを表示しない
- iOS・Android双方で動く既存のWebViewメッセージ方式を維持
- 新しい依存パッケージは原則追加しない
- WebView内で利用できない新しいJavaScript構文を安易に導入しない
- 生成中の二重タップで複数バッチが競合しないよう考慮する
- ユーザーの既存変更があるファイルを勝手に巻き戻さない
- `scripts/generate-operation-info-userscript.mjs``ndView.tsx` の注入コードを元に生成物を作るため、必要なら生成物も更新する。ただし生成物を直接手編集しない
## UI文言
新規ボタン:
`X投稿向け画像を作成`
生成・共有に失敗した場合は、既存の「切り出し失敗」系ダイアログとトーンをそろえます。4枚へ収まらない場合は、理由と代替手段が分かる日本語を表示してください。
## 完了条件
- 新規ボタンから表紙+詳細画像が正しい順番で共有される
- 全画像が厳密に1080×1440
- 1件、2件、3件、4件以上、長文1件のレイアウトを確認している
- 長文がCanvas外へはみ出さない
- 空のタイトル、サブタイトル、更新日時でも壊れない
- 地図画像のロード失敗時も白紙画像を共有せず、明確に失敗扱いまたは安全なフォールバックになる
- 既存4種類の切り出し操作が維持される
- TypeScript型チェックが通る
- `git diff --check` が通る
## 検証
最低限、次を実行してください。
```bash
npx tsc --noEmit
git diff --check
```
注入スクリプトを変更した場合は、リポジトリ既存の生成手順も確認します。
```bash
node scripts/generate-operation-info-userscript.mjs
```
可能なら、Canvas描画を開発用に呼び出せる小さなテスト入口または純粋なページ分割関数を用意し、上記の件数・長文パターンを検証してください。本番UIへデバッグ操作を残さないでください。
## 完了報告に含めるもの
- 変更したファイル
- 表紙・詳細ページの分割ルール
- 4枚で収まらない場合の扱い
- 実行した検証と結果
- 端末で確認が必要な残件
+6
View File
@@ -47,6 +47,12 @@
},
"production7.0": {
"channel": "familymart"
},
"beta7.1": {
"channel": "gmarket"
},
"production7.1": {
"channel": "geekbuying"
}
},
"submit": {
+6
View File
@@ -45,6 +45,7 @@ export type CustomTrainData = {
type: trainTypeID;
train_name: string;
train_info_img: string;
train_info_img_hub?: string | null;
train_info_url: string;
infogram: string;
via_data: string;
@@ -88,6 +89,9 @@ export type CustomTrainData = {
lng: number;
isSpot?: boolean;
};
export type OriginalStationList = Record<string, StationProps[]>;
export type OperationLogs = {
id: number;
operation_id?: string;
@@ -96,6 +100,8 @@ export type OperationLogs = {
unit_ids?: string[];
vehicle_img: string;
vehicle_img_right: string;
vehicle_img_hub?: string | null;
vehicle_img_right_hub?: string | null;
vehicle_info_url: string;
related_train_ids?: string[];
state: number | null;
+277
View File
@@ -0,0 +1,277 @@
import dosan from "@/assets/originData/dosan";
import dosan2 from "@/assets/originData/dosan2";
import koutoku from "@/assets/originData/koutoku";
import naruto from "@/assets/originData/naruto";
import seto from "@/assets/originData/seto";
import tokushima from "@/assets/originData/tokushima";
import trainList from "@/assets/originData/trainList";
import uwajima from "@/assets/originData/uwajima";
import uwajima2 from "@/assets/originData/uwajima2";
import yosan from "@/assets/originData/yosan";
import type { ElesiteData } from "@/types/unyohub";
type HeadingDirection = "left" | "right";
export type ElesiteLineGroup = {
key: string;
lineCode: string | null;
lineLabel: string | null;
leftStation: string | null;
rightStation: string | null;
timetableUrl: string | null;
lastReportedAt: string | null;
entries: ElesiteData[];
formations: string[];
formationText: string | null;
hasFormations: boolean;
};
// マリンライナー(3xxxM)用: JR西日本区間の駅は originData にないのでここで定義
const MARINE_STATION_SEQUENCE = [
"岡山", "大元", "備前西市", "妹尾", "早島", "茶屋町", "植松", "木見", "上の町",
"児島", "坂出", "鴨川", "国分", "端岡", "鬼無", "高松",
];
const STATION_SEQUENCES: string[][] = [
...[yosan, uwajima, uwajima2, dosan, dosan2, koutoku, tokushima, naruto, seto].map(
(stations) => stations.map((station) => station.Station_JP),
),
MARINE_STATION_SEQUENCE,
];
const uniqueNonEmpty = (values: Array<string | null | undefined>): string[] => {
const seen = new Set<string>();
return values.filter((value): value is string => {
const normalized = value?.trim();
if (!normalized || seen.has(normalized)) return false;
seen.add(normalized);
return true;
});
};
const getElesiteQueryParam = (url: string, key: string): string | null => {
if (!url) return null;
const escapedKey = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const match = url.match(new RegExp(`[?&]${escapedKey}=([^&#]+)`));
if (!match) return null;
try {
return decodeURIComponent(match[1].replace(/\+/g, " "));
} catch {
return match[1];
}
};
const getElesiteMatchedTrain = (
entry: ElesiteData,
trainNumber: string,
) =>
entry.trains?.find(
(train) => train.train_number.trim() === trainNumber.trim(),
);
const getElesiteLineMeta = (
entry: ElesiteData,
trainNumber: string,
): Omit<ElesiteLineGroup, "entries" | "formations" | "formationText" | "hasFormations" | "lastReportedAt"> => {
const matchedTrain = getElesiteMatchedTrain(entry, trainNumber);
const timetableUrl = matchedTrain?.timetable_url?.trim() || null;
const lineCode = timetableUrl
? getElesiteQueryParam(timetableUrl, "rosen_code")
: null;
const lineLabel = timetableUrl
? getElesiteQueryParam(timetableUrl, "rosen_name")
: null;
const leftStation = entry.formation_config?.left_station?.trim() || null;
const rightStation = entry.formation_config?.right_station?.trim() || null;
const fallbackKey = [lineLabel, leftStation, rightStation]
.filter(Boolean)
.join("::") || "unknown";
return {
key: lineCode || fallbackKey,
lineCode,
lineLabel,
leftStation,
rightStation,
timetableUrl,
};
};
const getElesiteEntryFormations = (entry: ElesiteData): string[] =>
uniqueNonEmpty(entry.formation_config?.units?.map((unit) => unit.formation) ?? []);
const getRouteEndpoints = (
trainNumber: string,
): { firstStation: string; lastStation: string } | null => {
const diagram = trainList[trainNumber.trim()];
if (!diagram) return null;
const stations = diagram
.split("#")
.map((stop) => stop.split(",")[0]?.trim())
.filter((station): station is string => !!station);
if (stations.length < 2) return null;
return {
firstStation: stations[0],
lastStation: stations[stations.length - 1],
};
};
const getMatchingStationSequence = (
leftStation: string,
rightStation: string,
firstStation: string,
lastStation: string,
): string[] | null =>
STATION_SEQUENCES.find(
(sequence) =>
sequence.includes(leftStation) &&
sequence.includes(rightStation) &&
sequence.includes(firstStation) &&
sequence.includes(lastStation),
) ??
STATION_SEQUENCES.find(
(sequence) =>
sequence.includes(leftStation) && sequence.includes(rightStation),
) ??
null;
export const inferElesiteHeadingDirection = (
entry: ElesiteData,
trainNumber: string,
): HeadingDirection | null => {
const matchedTrain = entry.trains?.find(
(train) => train.train_number.trim() === trainNumber.trim(),
);
const headingTo = matchedTrain?.nav?.heading_to;
if (headingTo === "left" || headingTo === "right") {
return headingTo;
}
const route = getRouteEndpoints(trainNumber);
if (!route) return null;
const leftStation = entry.formation_config?.left_station;
const rightStation = entry.formation_config?.right_station;
if (!leftStation || !rightStation) return null;
const sequence = getMatchingStationSequence(
leftStation,
rightStation,
route.firstStation,
route.lastStation,
);
if (!sequence) return null;
const firstIndex = sequence.indexOf(route.firstStation);
const lastIndex = sequence.indexOf(route.lastStation);
const leftIndex = sequence.indexOf(leftStation);
const rightIndex = sequence.indexOf(rightStation);
if ([firstIndex, lastIndex, leftIndex, rightIndex].some((index) => index < 0)) {
return null;
}
const trainDelta = lastIndex - firstIndex;
const formationDelta = rightIndex - leftIndex;
if (trainDelta === 0 || formationDelta === 0) return null;
return Math.sign(trainDelta) === Math.sign(formationDelta)
? "right"
: "left";
};
export const getElesiteLeftSideRank = (
entry: ElesiteData,
trainNumber: string,
): number => {
const matchedTrain = entry.trains?.find(
(train) => train.train_number.trim() === trainNumber.trim(),
);
if (!matchedTrain?.nav) return 2;
const headingDirection = inferElesiteHeadingDirection(entry, trainNumber);
if (!headingDirection) {
const isLeftSide =
(matchedTrain.nav.heading_to === "left") ===
(matchedTrain.nav.is_leading === true);
return isLeftSide ? 0 : 1;
}
const isLeftSide =
(headingDirection === "left") === (matchedTrain.nav.is_leading === true);
return isLeftSide ? 0 : 1;
};
export const sortElesiteEntriesByTrainNumber = (
entries: ElesiteData[],
trainNumber: string,
): ElesiteData[] =>
[...entries].sort(
(a, b) =>
getElesiteLeftSideRank(a, trainNumber) -
getElesiteLeftSideRank(b, trainNumber),
);
export const buildElesiteLineGroups = (
entries: ElesiteData[],
trainNumber: string,
): ElesiteLineGroup[] => {
const groups = new Map<string, ElesiteData[]>();
const lineOrder: string[] = [];
for (const entry of entries) {
const key = getElesiteLineMeta(entry, trainNumber).key;
if (!groups.has(key)) {
groups.set(key, []);
lineOrder.push(key);
}
groups.get(key)?.push(entry);
}
return lineOrder.map((key) => {
const groupedEntries = groups.get(key) ?? [];
const sortedEntries = sortElesiteEntriesByTrainNumber(groupedEntries, trainNumber);
const lineMetas = sortedEntries.map((entry) => getElesiteLineMeta(entry, trainNumber));
const formations = uniqueNonEmpty(
sortedEntries.flatMap((entry) => getElesiteEntryFormations(entry)),
);
return {
key,
lineCode: lineMetas.map((meta) => meta.lineCode).find(Boolean) ?? null,
lineLabel: lineMetas.map((meta) => meta.lineLabel).find(Boolean) ?? null,
leftStation: lineMetas.map((meta) => meta.leftStation).find(Boolean) ?? null,
rightStation: lineMetas.map((meta) => meta.rightStation).find(Boolean) ?? null,
timetableUrl:
lineMetas.map((meta) => meta.timetableUrl).find(Boolean) ?? null,
lastReportedAt:
sortedEntries
.map((entry) => entry.report_info?.last_reported_at)
.filter((value): value is string => !!value)
.sort()
.at(-1) ?? null,
entries: sortedEntries,
formations,
formationText: formations.length > 0 ? formations.join("+") : null,
hasFormations: formations.length > 0,
};
});
};
export const getElesiteSummaryByTrainNumber = (
entries: ElesiteData[],
trainNumber: string,
): string | null => {
const lineGroups = buildElesiteLineGroups(entries, trainNumber).filter(
(group) => group.hasFormations,
);
return lineGroups[0]?.formationText ?? null;
};

Some files were not shown because too many files have changed in this diff Show More