72 Commits

Author SHA1 Message Date
harukin-expo-dev-env
c1b4adc310 Add Windows port documentation for JR四国アプリ using React Native for Windows 2026-05-02 11:47:31 +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
105 changed files with 13137 additions and 509 deletions

24
App.tsx
View File

@@ -25,7 +25,7 @@ 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 { rootNavigationRef, stackAwareNavigate } from "./lib/rootNavigation";
import { AppThemeProvider } from "./lib/theme";
import StatusbarDetect from "./StatusbarDetect";
@@ -54,12 +54,12 @@ export default function App() {
return;
}
rootNavigationRef.navigate("topMenu", {
stackAwareNavigate("topMenu", {
screen: "setting",
params: {
screen: "FelicaHistoryPage",
},
} as any);
});
};
const navigateWhenReady = (
@@ -87,26 +87,30 @@ export default function App() {
navigateWhenReady(() => openFelicaPage(), url, retryCount);
} else if (normalized.includes("open/traininfo")) {
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);
stackAwareNavigate("information");
}, url, retryCount);
} else if (normalized.includes("open/settings")) {
navigateWhenReady(() => {
rootNavigationRef.navigate("topMenu", {
stackAwareNavigate("topMenu", {
screen: "setting",
} as any);
});
}, url, retryCount);
} else if (normalized.includes("open/topmenu")) {
navigateWhenReady(() => {
rootNavigationRef.navigate("topMenu", {
stackAwareNavigate("topMenu", {
screen: "menu",
} as any);
});
}, url, retryCount);
} else if (normalized.includes("positions/apps")) {
navigateWhenReady(() => {
stackAwareNavigate("positions");
}, url, retryCount);
}
};
@@ -131,10 +135,10 @@ export default function App() {
StationListProvider,
FavoriteStationProvider,
TrainDelayDataProvider,
TrainMenuProvider, // CurrentTrainProvider より先に置くことで useTrainMenu が使える
CurrentTrainProvider,
AreaInfoProvider,
BusAndTrainDataProvider,
TrainMenuProvider,
SheetProvider,
]);
return (

View File

@@ -1,7 +1,7 @@
import React from "react";
import { NavigationContainer, DarkTheme, DefaultTheme } 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 } from "react-native";
import { useNavigationState } from "@react-navigation/native";
import { useFonts } from "expo-font";
import { LinearGradient } from "expo-linear-gradient";
@@ -68,9 +68,7 @@ export function AppContainer() {
config: {
screens: {
positions: {
screens: {
Apps: "positions/apps",
},
screens: {},
},
topMenu: {
screens: {
@@ -124,11 +122,11 @@ export function AppContainer() {
linking={linking}
theme={isDark ? DarkTheme : DefaultTheme}
onStateChange={(state) => {
const hasExtra = state?.routes?.some((r) => (r.state?.index ?? 0) > 0) ?? false;
const activeRoute = state?.routes?.[state?.index ?? 0];
const hasExtra = (activeRoute?.state?.index ?? 0) > 0;
setIsExtraWindowOpen(hasExtra);
}}
>
{/* @ts-expect-error - Tab.Navigator type definition issue */}
<Tab.Navigator
initialRouteName="topMenu"
screenOptions={({ route }) => {
@@ -138,8 +136,6 @@ export function AppContainer() {
const defaultInactive = isDark ? "#8e8e93" : "#8e8e93";
return {
lazy: false,
tabBarHideOnKeyboard: Platform.OS === "android",
animation: Platform.OS === "ios" ? "none" : "shift",
sceneContainerStyle: { backgroundColor: defaultBg },
tabBarActiveTintColor: (showGradient || isExtraWindowOpen) ? "white" : defaultActive,
tabBarInactiveTintColor: (showGradient || isExtraWindowOpen) ? "rgba(255,255,255,0.75)" : defaultInactive,

View File

@@ -1,9 +1,18 @@
import React, { CSSProperties } from "react";
import { Alert, BackHandler, View, ViewProps } from "react-native";
import React from "react";
import { Alert, ActivityIndicator, AppState, AppStateStatus, BackHandler, StyleSheet, Text, TouchableOpacity, View } from "react-native";
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 {
DEFAULT_JR_DATA_SYSTEM_ENV,
normalizeJrDataSystemEnvironment,
rewriteJrDataSystemUrl,
} from "@/lib/jrDataSystemEnvironment";
export default ({ route }) => {
if (!route.params) {
return null;
@@ -13,6 +22,88 @@ export default ({ route }) => {
const { fixed } = useThemeColors();
const webViewRef = React.useRef<WebView>(null);
const [canGoBack, setCanGoBack] = React.useState(false);
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);
// バックグラウンド移行時刻の記録
const backgroundedAt = React.useRef<number | null>(null);
// 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);
setSelectedEnvironment(nextEnvironment);
setResolvedUri(
rewriteJrDataSystemUrl(
typeof uri === "string" ? uri : "",
nextEnvironment,
),
);
setIsEnvironmentReady(true);
};
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
.then(applyEnvironment)
.catch(() => applyEnvironment(DEFAULT_JR_DATA_SYSTEM_ENV));
return () => {
isMounted = false;
};
}, [uri]);
const handleReload = () => {
lastPongAt.current = Date.now();
setHasError(false);
hasErrorRef.current = false;
setIsLoading(true);
isLoadingRef.current = true;
setWebViewKey((k) => k + 1);
};
// AppState監視: バックグラウンド10秒超で復帰したらWebViewを再マウント
React.useEffect(() => {
const onAppStateChange = (nextState: AppStateStatus) => {
if (nextState.match(/inactive|background/)) {
backgroundedAt.current = Date.now();
} else if (nextState === "active" && backgroundedAt.current !== null) {
const elapsed = Date.now() - backgroundedAt.current;
backgroundedAt.current = null;
if (elapsed > 10_000) {
remount();
}
}
};
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, [remount]);
useFocusEffect(
React.useCallback(() => {
@@ -31,28 +122,171 @@ export default ({ route }) => {
);
return (
<View style={{ height: "100%", backgroundColor: fixed.primary }}>
<WebView
source={{ uri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
ref={webViewRef}
onNavigationStateChange={(navState) => {
{isEnvironmentReady && (
<WebView
key={webViewKey}
source={{ uri: resolvedUri }}
contentMode="mobile"
allowsBackForwardNavigationGestures
ref={webViewRef}
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;
}
const rewrittenUrl = rewriteJrDataSystemUrl(
request.url,
selectedEnvironment,
);
if (rewrittenUrl !== request.url) {
setResolvedUri(rewrittenUrl);
return false;
}
return true;
}}
onNavigationStateChange={(navState) => {
setCanGoBack(navState.canGoBack);
// SPA内遷移中は白画面誤検知を防ぐためblankCountをリセット
if (navState.loading) blankCountRef.current = 0;
if (navState.url === "https://unyohub.2pd.jp/integration/succeeded.php") {
goBack();
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
{ text: "完了" },
]);
webViewRef.current?.goBack();
if (!hasAlerted.current) {
hasAlerted.current = true;
Alert.alert("鉄道運用HUBへの投稿完了", "運用HUBからのこのアプリへのデータ反映には暫く時間がかかりますので、しばらくお待ちください。", [
{ text: "完了" },
]);
}
}
}}
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="閉じる" />}
onMessage={(event) => {
const { data } = event.nativeEvent;
const parsed = JSON.parse(data);
const { type } = parsed;
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 === "back") return webViewRef.current?.goBack();
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,
},
});

View File

@@ -84,7 +84,14 @@ export function MenuPage() {
setMapFullHeight(MapFullHeight);
}, [height, tabBarHeight, width]);
useEffect(() => {
const unsubscribe = addListener("tabPress", (e) => {
const unsubscribe = addListener("tabPress", (e: any) => {
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 +113,16 @@ export function MenuPage() {
return unsubscribe;
}, [navigation]);
const stackNavRef = useRef<any>(null);
return (
<Stack.Navigator
id={null}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {};
}}
>
<Stack.Screen
name="menu"

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;

19
Top.tsx
View File

@@ -16,6 +16,7 @@ import { news } from "./config/newsUpdate";
import { Linking, Platform } from "react-native";
import GeneralWebView from "./GeneralWebView";
import { StationDiagramView } from "@/components/StationDiagram/StationDiagramView";
import { positionsStackNavRef } from "./lib/rootNavigation";
const Stack = createStackNavigator();
export const Top = () => {
const { webview } = useCurrentTrain();
@@ -38,14 +39,22 @@ export const Top = () => {
return unsubscribe;
}, []);
const goToTrainMenu = useCallback(() => {
const stackNavRef = positionsStackNavRef;
const goToTrainMenu = useCallback((e: any) => {
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 (!isFocused()) return;
const stackNav = stackNavRef.current;
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;
@@ -60,6 +69,10 @@ export const Top = () => {
<Stack.Navigator
id={null}
screenOptions={{ cardStyle: { backgroundColor: bgColor } }}
screenListeners={({ navigation: stackNav }) => {
stackNavRef.current = stackNav;
return {};
}}
>
<Stack.Screen
name="Apps"

160
WINDOWS_PORT.md Normal file
View File

@@ -0,0 +1,160 @@
# JR四国アプリ Windows版 (React Native for Windows) 引き継ぎ資料
## プロジェクト概要
JR四国の列車走行位置・運行情報アプリ。Expo Managed Workflow (React Native) で構築。
Android/iOS向けに運用中。このタスクは **Windows PC版の追加** を目的とする。
- **リポジトリ**: `/home/ubuntu/jrshikoku` (Ubuntu開発サーバー) → Windows環境に clone/コピーして作業
- **ブランチ**: `experiment/pc-version`mainブランチ: `master`
- **React Native**: 0.83.2 / **Expo**: ^55.0.8
---
## これまでの経緯
### 試みたこと: Electron + Expo webビルド
`npx expo export -p web` でWebビルドし、ElectronでラップしてCORSをメインプロセスで回避する方法を試みた。
**結果**: 動作はしたが根本的な問題が多く断念。
| 問題 | 原因 |
|------|------|
| `injectJavaScript` が動かない | Electron `<webview>` はクロスオリジンにJS注入できない |
| 列車描画が壊れる | `window.ReactNativeWebView` が未定義でスクリプトがクラッシュ |
| 操作感が悪い | WebViewがiframe/Electron webviewタグになり、ネイティブのスクロール感が失われる |
| `usage.htm`(利用規約)が毎回出る | `injectJavascript` が動かないため `goBack()` 処理が機能しない |
**結論**: `react-native-webview` をネイティブとして動かせないWeb系アプローチには限界がある。
---
## 推奨アプローチ: React Native for Windows (RNW)
### なぜRNWが良いか
- `react-native-webview`**WebView2 (Chromium)** として動く → `injectJavaScript` がモバイルと同じ挙動
- ネイティブアプリなので CORS 制限がない
- スクロール・タッチ感がネイティブ
- このアプリの主要機能走行位置WebView、API通信がそのまま動く可能性が高い
### バージョン対応
RN 0.83.2 に対応する RNW は **0.83.x**
```
yarn add react-native-windows@0.83.x
```
---
## セットアップ手順 (Windows)
### 1. 前提環境のインストール
[Microsoft公式ドキュメント](https://microsoft.github.io/react-native-windows/docs/rnw-dependencies) に従う。
必要なもの:
- **Visual Studio 2022** (Community可)
- ワークロード: 「C++ によるデスクトップ開発」「ユニバーサル Windows プラットフォーム開発」
- 個別コンポーネント: 「Windows 11 SDK (10.0.22621.0 以上)」「C++ CMake ツール」
- **Node.js** LTS
- **Git**
```powershell
# winget で一括インストール (任意)
winget install Microsoft.VisualStudio.2022.Community
winget install OpenJS.NodeJS.LTS
winget install Git.Git
```
### 2. リポジトリをWindowsにクローン
```powershell
git clone <リポジトリURL>
cd jrshikoku
git checkout experiment/pc-version
yarn install
```
### 3. RNW の初期化
```powershell
yarn add react-native-windows@0.83.x
npx react-native-windows-init --overwrite
```
`windows/` ディレクトリが生成される。
### 4. ビルド & 実行
```powershell
npx react-native run-windows
```
---
## 予想される問題と対処
### Expoモジュールの非対応
Expo Managed Workflow のモジュールはネイティブコードを自動生成するが、RNW は公式サポート外。
以下のモジュールはWindowsで動かない可能性が高いクラッシュしたらスタブに差し替える:
| モジュール | 状況 | 対処 |
|-----------|------|------|
| `expo-notifications` | Windowsプッシュ通知は別実装が必要 | Platform.OS === 'windows' でスキップ |
| `expo-felica-reader` | ハードウェア依存、Windows非対応 | スタブに差し替え |
| `expo-live-activity` | iOS専用 | すでにPlatform.OS === 'web' ガード済み |
| `expo-linear-gradient` | RNW向けは要確認 | react-native-linear-gradient に差し替えも |
| `react-native-maps` | Windows未対応 | metro.config.js でスタブを当てる |
### Platform.OS の値
RNW では `Platform.OS === 'windows'``'web'` でも `'ios'` でもない)。
既存の `Platform.OS === 'android'` ガードがある箇所は意図通り動くWindowsはスキップされる
### `react-native-webview` のCORS
ネイティブアプリなので基本的にCORSはない。ただし JR四国の `X-Frame-Options`
iframeではなく WebView2 で直接開くため無関係。
---
## アプリ構成の重要ポイント
### 走行位置タブ (`components/Apps/WebView.tsx`)
最重要コンポーネント。`react-native-webview` の WebView に `train.jr-shikoku.co.jp/sp.html` を読み込み、大量の JavaScript (`injectJavascript`) を注入して列車データを描画している。
- `injectJavascript`: `lib/webViewInjectjavascript.ts` で生成。ユーザー設定・ダークモード等を考慮した巨大スクリプト
- `injectJavascriptBeforeContentLoaded`: モックAPI使用時のみ、XHRインターセプター
- `window.ReactNativeWebView.postMessage` でアプリ側に列車クリック等のイベントを送る
### 状態管理
Context API (stateBox/) で全状態を管理。Redux等は使用していない。
### API
バックエンド: `jr-shikoku-backend-api-v1.haruk.in`(外部サーバー)
列車走行位置: JR四国公式 `train.jr-shikoku.co.jp`WebView内で直接アクセス
---
## Claudeへの指示テンプレート
```
このプロジェクトはExpo React NativeのJR四国アプリ(RN 0.83.2)。
experiment/pc-versionブランチで作業中。
React Native for Windows (RNW 0.83.x) を使ってWindows PC版を作りたい。
まず package.json と windows/ ディレクトリの存在を確認し、
RNW の初期化が完了しているか確認してから作業を開始してほしい。
主要な懸念点:
1. expo-notifications など Expo 系モジュールが Windows ビルドを壊す可能性
2. react-native-maps は Windows 非対応metro.config.js でスタブを当てる予定)
3. Platform.OS === 'windows' の分岐が必要になる箇所を確認すること
```

View File

@@ -24,7 +24,7 @@
"**/*"
],
"ios": {
"buildNumber": "61",
"buildNumber": "63",
"supportsTablet": true,
"bundleIdentifier": "jrshikokuinfo.xprocess.hrkn",
"appleTeamId": "54CRDT797G",
@@ -144,7 +144,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 +154,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 +164,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 +174,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"
}

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

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

View File

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

View File

@@ -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");
}}
>

View File

@@ -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

View File

@@ -59,6 +59,7 @@ export const HeaderText: FC<Props> = ({
getUnyohubByTrainNumber,
getUnyohubEntriesByTrainNumber,
useUnyohub: unyohubEnabled,
unyohubData,
} = useUnyohub();
const { getElesiteEntriesByTrainNumber, useElesite: elesiteEnabled } =
useElesite();
@@ -160,9 +161,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 +171,34 @@ 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情報あり」と判定
// 車番(formations) がある場合のみ「運用Hub情報あり」と判定
const hasUnyohubFormation = unyohubEntries.some(
(e) => !!e.formations && e.formations.trim() !== "",
);
@@ -288,7 +311,7 @@ export const HeaderText: FC<Props> = ({
(SheetManager.show as any)("TrainDataSources", {
payload: {
trainNum,
unyohubTrainNum: unyohubLookupNum,
unyohubTrainNum: unyohubTrainNumForSourceScreen,
unyohubEntries,
elesiteEntries,
todayOperation,

View File

@@ -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]);
};

View File

@@ -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;

View File

@@ -2,107 +2,114 @@ import { useState, useEffect } from 'react';
import { lineListPair, stationIDPair } from '@/lib/getStationList';
import { useStationList } from '@/stateBox/useStationList';
export const useThroughStations = (trainData) => {
const { originalStationList, stationList } = useStationList();
const [trainDataWithThrough, setTrainDataWithThrough] = useState([]);
const [haveThrough, setHaveThrough] = useState(false);
const computeThroughStations = (
trainData: string[],
stationList: any[][],
originalStationList: Record<string, any[]>
): { trainDataWithThrough: string[]; haveThrough: boolean } => {
if (!trainData.length) return { trainDataWithThrough: [], haveThrough: false };
useEffect(() => {
if (!trainData.length) {
setTrainDataWithThrough([]);
return;
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);
}
const isCancel = [];
const stopStationList = trainData.map((item, index, array) => {
const [station, se] = item.split(',');
const [, nextSe] = array[index + 1]?.split(',') || [];
if (se === '通編') haveThrough = true;
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);
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 (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 [];
if (!betweenStationLine) return [];
const secondItem = array[index + 1];
let betweenStationLine = '';
let baseStationNumberFirst = '';
let baseStationNumberSecond = '';
const allThroughStation: string[] = [];
let reverse = false;
Object.keys(stationIDPair).forEach((lineName, lineIndex) => {
if (!lineName) return;
const haveFirst = firstItem[lineIndex];
const haveSecond = secondItem[lineIndex];
originalStationList[lineListPair[stationIDPair[betweenStationLine]]]?.forEach((station) => {
const throughStatus = isCancel[index] ? '通休編' : '通過';
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;
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;
}
});
let mainArray = [...trainData];
let offset = 0;
if (reverse) allThroughStation.reverse();
return allThroughStation;
});
trainData.forEach((_, index) => {
offset += 1;
const throughStations = allThroughStationList[index];
if (!throughStations?.length) return;
let mainArray = [...trainData];
let offset = 0;
mainArray.splice(offset, 0, ...throughStations);
offset += throughStations.length;
});
trainData.forEach((_, index) => {
offset += 1;
const throughStations = allThroughStationList[index];
if (!throughStations?.length) return;
mainArray.splice(offset, 0, ...throughStations);
offset += throughStations.length;
});
setTrainDataWithThrough(mainArray);
return { trainDataWithThrough: mainArray, haveThrough };
};
export const useThroughStations = (trainData) => {
const { originalStationList, stationList } = useStationList();
// 初回レンダリング時に同期的に計算することでActionSheetのアニメーション中の高さ変化を防ぐ
const [state, setState] = useState(() =>
computeThroughStations(trainData, stationList, originalStationList)
);
useEffect(() => {
setState(computeThroughStations(trainData, stationList, originalStationList));
}, [trainData, stationList, originalStationList]);
return { trainDataWithThrough, haveThrough };
return { trainDataWithThrough: state.trainDataWithThrough, haveThrough: state.haveThrough };
};

View File

@@ -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) => {

View File

@@ -132,7 +132,7 @@ 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) => {

View File

@@ -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();

View File

@@ -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();
}}

View File

@@ -51,7 +51,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 +83,152 @@ 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>
);
};
export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
payload,
}) => {
@@ -128,7 +274,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 が明示されていればそれを使う
@@ -226,7 +410,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
</View>
);
// 鉄道運用Hub: 車番(formations)が空でないエントリのみ抽出して判定
// 鉄道運用Hub: 車番(formations) が空でないエントリのみ表示対象にする
const hasNonEmptyFormations = unyohubEntries.some(
(e) => !!e.formations && e.formations.trim() !== "",
);
@@ -242,6 +426,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 +459,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>
@@ -351,8 +545,8 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
.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 aNav = a.trains?.find((t) => t.train_number.trim() === trainNum)?.nav;
const bNav = b.trains?.find((t) => t.train_number.trim() === trainNum)?.nav;
const aIsLeft =
(aNav?.heading_to === "left") === (aNav?.is_leading === true);
const bIsLeft =
@@ -483,9 +677,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 +692,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,
)
}
@@ -527,7 +724,7 @@ export const TrainDataSources: FC<{ payload?: TrainDataSourcesPayload }> = ({
const matchedEntry =
elesiteNonEmptyFormationEntries[0] ?? elesiteEntries[0];
const matchedTrain = matchedEntry?.trains?.find(
(t) => t.train_number === trainNum,
(t) => t.train_number.trim() === trainNum,
);
const url =
matchedTrain?.timetable_url || "https://www.elesite-next.com/";
@@ -942,15 +1139,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 +1188,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 +1240,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 +1409,9 @@ const styles = StyleSheet.create({
subText: {
fontSize: 12,
},
subNodeWrap: {
marginTop: 2,
},
subTextDisabled: {
color: "#bbb",
},
@@ -1302,15 +1519,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,

View File

@@ -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"
/>

View File

@@ -1,5 +1,6 @@
import React, { useState, useRef, FC } from "react";
import {
Animated,
View,
Text,
TouchableOpacity,
@@ -38,7 +39,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,
@@ -197,7 +198,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 +359,6 @@ export const AllTrainDiagramView: FC = () => {
string="閉じる"
style={{ display: keyBoardVisible ? "none" : "flex" }}
/>
</View>
</Animated.View>
);
};

View File

@@ -16,7 +16,7 @@ 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";
@@ -24,6 +24,8 @@ import { MapsButton } from "./Apps/MapsButton";
import { ReloadButton } from "./Apps/ReloadButton";
import { useStationList } from "../stateBox/useStationList";
import { FixedPositionBox } from "./Apps/FixedPositionBox";
import { PlaybackTimeline } from "./Apps/PlaybackTimeline";
import { RecordingStatusBar } from "./Apps/RecordingStatusBar";
export default function Apps() {
const { webview, fixedPosition, setFixedPosition } = useCurrentTrain();
@@ -35,6 +37,7 @@ export default function Apps() {
const { originalStationList } = useStationList();
const { mapSwitch, trainInfo, setTrainInfo, selectedLine } = useTrainMenu();
const isDark = useColorScheme() === "dark";
const isFocused = useIsFocused();
const lineColor = selectedLine && stationIDPair[selectedLine]
? lineColorList[stationIDPair[selectedLine]]
@@ -83,7 +86,7 @@ export default function Apps() {
const bgColor = isDark ? "#1c1c1e" : "#ffffff";
return (
<View style={{ flex: 1, backgroundColor: bgColor }}>
{lineColor && lineColorDark && (
{isFocused && mapSwitch === "true" && lineColor && lineColorDark && (
<LinearGradient
colors={[lineColorDark, lineColor]}
start={{ x: 0, y: 0 }}
@@ -91,10 +94,11 @@ export default function Apps() {
style={{ position: "absolute", top: 0, left: 0, right: 0, height: top }}
/>
)}
{lineColor && (
<StatusBar
barStyle="light-content"
/>
{isFocused && mapSwitch !== "true" && (
<View style={{ position: "absolute", top: 0, left: 0, right: 0, height: top, backgroundColor: "#0099CC" }} />
)}
{isFocused && (
<StatusBar barStyle="light-content" />
)}
<View
style={{
@@ -117,12 +121,16 @@ export default function Apps() {
/>
{fixedPosition.type && <FixedPositionBox />}
<PlaybackTimeline />
<RecordingStatusBar />
{mapSwitch == "true" ? (
<ReloadButton
onPress={() => Updates.reloadAsync()}
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
/>
<>
<ReloadButton
onPress={() => Updates.reloadAsync()}
right={isLandscape && trainInfo.trainNum ? (width / 100) * 40 : 0}
/>
</>
) : (
<NewMenu />
)}

View File

@@ -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) {

View File

@@ -471,9 +471,10 @@ export const FixedTrain: FC<props> = ({ trainID }) => {
}, [train, nextStationData, liveNotifyId, stationStops, nextStationIndex, currentStationIndex]);
// バナー表示と同時に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) {

View File

@@ -0,0 +1,244 @@
import React, { FC, useRef, useCallback } 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 trackWidthRef = useRef(1);
// PanResponder のハンドラ内で最新値を参照するために ref を使う
const seekRef = useRef(seekToSnapshot);
seekRef.current = seekToSnapshot;
const totalRef = useRef(0);
const panResponder = useRef(
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onMoveShouldSetPanResponder: () => true,
onPanResponderGrant: (evt) => {
const x = evt.nativeEvent.locationX;
const idx = Math.round((x / trackWidthRef.current) * (totalRef.current - 1));
seekRef.current(Math.max(0, Math.min(idx, totalRef.current - 1)));
},
onPanResponderMove: (evt) => {
const x = evt.nativeEvent.locationX;
const idx = Math.round((x / trackWidthRef.current) * (totalRef.current - 1));
seekRef.current(Math.max(0, Math.min(idx, totalRef.current - 1)));
},
})
).current;
const onTrackLayout = useCallback((e: LayoutChangeEvent) => {
trackWidthRef.current = e.nativeEvent.layout.width;
}, []);
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 snap = activeRecording.snapshots[playbackIndex];
// スナップショット時刻 = 録画開始時刻 + 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 ? playbackIndex / (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"] }}>
{playbackIndex + 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
style={{ marginTop: 6 }}
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,
}),
};

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

View File

@@ -1,4 +1,4 @@
import React from "react";
import React, { useEffect, useRef } from "react";
import { Platform } from "react-native";
import { WebView } from "react-native-webview";
@@ -19,6 +19,8 @@ import { useNavigation } from "@react-navigation/native";
import { useTrainMenu } from "../../stateBox/useTrainMenu";
import { useStationList } from "../../stateBox/useStationList";
import { useThemeColors } from "@/lib/theme";
import { useWebViewRemount } from "@/lib/useWebViewRemount";
import { generateMockUpdateScript } from "../../lib/mockApi/webviewXhrInterceptor";
export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
const { webview, currentTrain } = useCurrentTrain();
const { navigate } = useNavigation<any>();
@@ -34,7 +36,24 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
setLoadError,
setTrainInfo,
injectJavascript,
injectJavascriptBeforeContentLoaded,
mockApiFeatureEnabled,
mockTrainPositions,
} = useTrainMenu();
const { remountKey, remount, processHandlers } = useWebViewRemount();
// コマが変化したとき(再生・シーク)に WebView 内の _MOCK_TRAIN を差し替えて再描画
const mountedRef = useRef(false);
useEffect(() => {
// 初回マウント時はスキップbeforeContentLoaded で既に正しいデータが入っている)
if (!mountedRef.current) {
mountedRef.current = true;
return;
}
if (!mockApiFeatureEnabled || !mockTrainPositions) return;
const script = generateMockUpdateScript(mockTrainPositions);
webview?.current?.injectJavaScript(script);
}, [mockTrainPositions]);
var urlcache = "";
let once = false;
@@ -151,7 +170,7 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
return (
<WebView
key={isDark ? 'dark' : 'light'}
key={(isDark ? 'dark' : 'light') + (mockApiFeatureEnabled ? '-mock' : '-live') + '-' + remountKey}
ref={webview}
source={{ uri: "https://train.jr-shikoku.co.jp/sp.html" }}
originWhitelist={[
@@ -165,6 +184,8 @@ export const AppsWebView = ({ openStationACFromEachTrainInfo }) => {
contentMode="mobile"
style={{ backgroundColor: bgColor }}
{...{ onMessage, onNavigationStateChange, onLoadEnd }}
{...processHandlers}
injectedJavaScriptBeforeContentLoaded={injectJavascriptBeforeContentLoaded}
injectedJavaScript={injectJavascript}
/>
);

View File

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

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>

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,39 @@ const ELESITE_FEATURES: Feature[] = [
/* ------------------------------------------------------------------ */
export const DataSourceSettings = () => {
const navigation = useNavigation();
const { dataSourcePermission, updatePermission } = useTrainMenu();
const {
updatePermission,
mockApiFeatureEnabled,
setMockApiFeatureEnabled,
recorderState,
recordingSnapshotCount,
recordingList,
startRecording,
stopRecording,
startPlayback,
stopPlayback,
deleteRecording,
} = 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 +317,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 +339,444 @@ 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 }]}>
デバッグ: モックAPI検証
</Text>
<Text
style={[
styles.debugDescription,
{ color: colors.textSecondary },
]}
>
</Text>
<View
style={{
flexDirection: "row",
alignItems: "center",
justifyContent: "space-between",
marginTop: 8,
}}
>
<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={{ flexDirection: "row", alignItems: "center", marginTop: 8, gap: 8 }}>
<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={{ flexDirection: "row", gap: 8, marginTop: 10, flexWrap: "wrap" }}>
{recorderState === 'idle' && (
<TouchableOpacity
onPress={startRecording}
style={{
backgroundColor: '#e53935', borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 8,
}}
>
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 13 }}> </Text>
</TouchableOpacity>
)}
{recorderState === 'recording' && (
<TouchableOpacity
onPress={stopRecording}
style={{
backgroundColor: colors.borderSecondary, borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 8,
}}
>
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', fontSize: 13 }}> </Text>
</TouchableOpacity>
)}
{recorderState === 'playing' && (
<TouchableOpacity
onPress={stopPlayback}
style={{
backgroundColor: colors.borderSecondary, borderRadius: 8,
paddingHorizontal: 14, paddingVertical: 8,
}}
>
<Text style={{ color: colors.textPrimary, fontWeight: 'bold', fontSize: 13 }}> </Text>
</TouchableOpacity>
)}
</View>
{/* 録画一覧 */}
{recordingList.length > 0 && recorderState !== 'recording' && (
<View style={{ marginTop: 10, gap: 6 }}>
{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',
});
return (
<View
key={rec.id}
style={{
flexDirection: 'row', alignItems: 'center',
backgroundColor: colors.backgroundSecondary,
borderRadius: 8, padding: 10, gap: 8,
}}
>
<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>
{!isPlaying && (
<TouchableOpacity
onPress={() => startPlayback(rec.id)}
style={{
backgroundColor: '#43a047', borderRadius: 6,
paddingHorizontal: 10, paddingVertical: 6,
}}
>
<Text style={{ color: '#fff', fontWeight: 'bold', fontSize: 12 }}></Text>
</TouchableOpacity>
)}
{!isPlaying && (
<TouchableOpacity
onPress={() => deleteRecording(rec.id)}
style={{
borderColor: '#e53935', borderWidth: 1, borderRadius: 6,
paddingHorizontal: 10, paddingVertical: 6,
}}
>
<Text style={{ color: '#e53935', fontSize: 12 }}></Text>
</TouchableOpacity>
)}
</View>
);
})}
</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 },
]}
>
/
</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 +943,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,
},
});

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}

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}

View File

@@ -17,7 +17,7 @@ import { useNotification } from "../../stateBox/useNotifications";
import { SheetHeaderItem } from "@/components/atom/SheetHeaderItem";
import { useThemeColors, type ColorThemePref } from "@/lib/theme/useThemeColors";
const versionCode = "7.0"; // Update this version code as needed
const versionCode = "7.0.3"; // Update this version code as needed
export const SettingTopPage = ({
testNFC,

View File

@@ -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": [],

View File

@@ -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,

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) : []

View File

@@ -11,6 +11,7 @@ type hoge = {
name: string;
timeType: string;
time: string;
platformNum: string | null;
};
export const ListView: FC<{
data: hoge[];

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;
@@ -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;
@@ -376,6 +386,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>

View File

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

View File

@@ -1,4 +1,5 @@
import React, { FC } from "react";
import { Image, StyleSheet, View } 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,
},
});

View File

@@ -1 +1 @@
export const news = "2026-02-01";
export const news = "2026-04-01";

View File

@@ -91,6 +91,9 @@ export const STORAGE_KEYS = {
/** えれサイト使用設定 */
USE_ELESITE: 'useElesite',
/** 投稿システム接続先 */
JR_DATA_SYSTEM_ENV: 'jrDataSystemEnv',
/** えれサイトデータ */
ELESITE_DATA: 'elesiteData',
@@ -108,6 +111,18 @@ export const STORAGE_KEYS = {
/** カラーテーマ設定 ("light" | "system" | "dark") */
COLOR_THEME: 'colorTheme',
/** モックAPI検証機能の有効化スイッチadmin専用 */
MOCK_API_FEATURE_ENABLED: 'mockApiFeatureEnabled',
/** 走行位置録画インデックスadmin専用 / 複数録画のメタ情報一覧) */
MOCK_RECORDINGS_INDEX: 'mockRecordingsIndex',
/** 走行位置録画データプレフィックスadmin専用 / + id でキーを構成) */
MOCK_RECORDING_DATA_PREFIX: 'mockRecordingData_',
/** 走行位置録画データ(旧フォーマット / マイグレーション用) */
MOCK_RECORDING: 'mockRecording',
} as const;
/**

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` フラグでゲートする。

View File

@@ -0,0 +1,51 @@
## `6.2.1` → `6.2.1.1` の更新内容
### 1. 鉄道運用HubUnyohub連携機能の新規追加・大幅強化
- **連携の基盤実装**: 列車運用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** にバージョンアップ

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日、Unyohub10分→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

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

View File

@@ -0,0 +1,125 @@
# コミット差分ログ064d81d4 -> a959cf39
## 対象範囲
- From: `064d81d468f21db4318a55f8fad4c432745d2441`
- To: `a959cf3973eb9635c9e61133d81de5c1f9719c18`
- 集計メモ:
- レンジ内コミットmerge含む: 31
- 非mergeコミット: 22
- 変更ファイル数: 44
- 変更行数: +1566 / -305
## サマリー
このレンジでは、運用Hubelesite/投稿系連携の実運用改善、キーボード回避と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` 噂機能のスタイル強化

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

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

View File

@@ -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)`.

View File

@@ -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 アニメーション完了(キーボード出現と同期)
```

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
docs/privacy-policy.md Normal file
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

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 可能だが、検証が必要
---
## 結論・推奨アプローチ
### フェーズ1OTA配信— すぐ着手可能
1. **ウォークスルー**: `react-native-reanimated-carousel` で実装 → OTA配信
2. **コンテキストヒント**: `BottomSheet` / `ActionSheet` で実装 → OTA配信
3. **段階的開放ロジック**: `AsyncStorage` カウンタ → OTA配信
### フェーズ2次回ビルド時— コーチマークの判断
- **コーチマークが必須なら**: `react-native-copilot`Pure JSを追加し、次回ビルドに含める
- **コーチマーク不要 or 後回しなら**: フェーズ1だけで十分な体験改善が可能
### 差分まとめ
| 観点 | パターンAOTA | パターンBライブラリ追加 |
|------|------------------|---------------------------|
| 配信速度 | 即座 | 次回ビルド待ち |
| コーチマーク難易度 | ★★★★☆ | ★★☆☆☆ |
| それ以外の難易度 | 同等 | 同等 |
| 依存リスク | なし | 低Pure JS |
| 推奨 | フェーズ1はこちら | コーチマーク実装時に検討 |

View File

@@ -0,0 +1,129 @@
export const BACKEND_API_BASE_URLS = {
production: "https://jr-shikoku-backend-api-v1.haruk.in",
experimental: "https://jr-shikoku-backend-api-v1-beta.haruk.in",
} as const;
export const JR_DATA_SYSTEM_ENVS = {
production_release: {
label: "本番 / リリース",
caption: "一般公開向け運用",
baseUrl: "https://shikoku-railinfo.haruk.in",
track: "production",
uiVariant: "release",
backendApiBaseUrl: BACKEND_API_BASE_URLS.production,
},
production_beta: {
label: "本番 / ベータ",
caption: "UI検証向け運用",
baseUrl: "https://nightly.shikoku-railinfo.haruk.in",
track: "production",
uiVariant: "beta",
backendApiBaseUrl: BACKEND_API_BASE_URLS.production,
},
experimental: {
label: "実験 / 実験場",
caption: "毎日リセットされる実験環境",
baseUrl: "https://experimental.shikoku-railinfo.haruk.in",
track: "experimental",
uiVariant: "release",
backendApiBaseUrl: BACKEND_API_BASE_URLS.experimental,
},
} as const;
export type JrDataSystemEnvironmentKey = keyof typeof JR_DATA_SYSTEM_ENVS;
export const DEFAULT_JR_DATA_SYSTEM_ENV: JrDataSystemEnvironmentKey =
"production_release";
export type JrDataSystemTrack = "production" | "experimental";
export type JrDataSystemUiVariant = "release" | "beta";
export const JR_DATA_SYSTEM_ENV_OPTIONS = (
Object.entries(JR_DATA_SYSTEM_ENVS) as [
JrDataSystemEnvironmentKey,
(typeof JR_DATA_SYSTEM_ENVS)[JrDataSystemEnvironmentKey],
][]
).map(([key, value]) => ({
key,
...value,
}));
export const normalizeJrDataSystemEnvironment = (
value: unknown,
): JrDataSystemEnvironmentKey => {
// Backward compatibility for legacy keys.
if (value === "production") return "production_release";
if (value === "chatgpt" || value === "claude") return "production_beta";
if (typeof value === "string" && value in JR_DATA_SYSTEM_ENVS) {
return value as JrDataSystemEnvironmentKey;
}
return DEFAULT_JR_DATA_SYSTEM_ENV;
};
export const resolveJrDataSystemEnvironment = (
track: JrDataSystemTrack,
uiVariant: JrDataSystemUiVariant,
): JrDataSystemEnvironmentKey => {
if (track === "experimental") {
return "experimental";
}
return uiVariant === "beta" ? "production_beta" : "production_release";
};
export const getJrDataSystemTrack = (
environment: unknown,
): JrDataSystemTrack => {
const envKey = normalizeJrDataSystemEnvironment(environment);
return JR_DATA_SYSTEM_ENVS[envKey].track;
};
export const getJrDataSystemUiVariant = (
environment: unknown,
): JrDataSystemUiVariant => {
const envKey = normalizeJrDataSystemEnvironment(environment);
return JR_DATA_SYSTEM_ENVS[envKey].uiVariant;
};
export const getBackendApiBaseUrl = (environment: unknown): string => {
const envKey = normalizeJrDataSystemEnvironment(environment);
return JR_DATA_SYSTEM_ENVS[envKey].backendApiBaseUrl;
};
export const rewriteBackendApiUrl = (url: string, environment: unknown): string => {
if (typeof url !== "string" || url.length === 0) return url;
const target = getBackendApiBaseUrl(environment);
for (const base of Object.values(BACKEND_API_BASE_URLS)) {
if (url.startsWith(base)) {
return url.replace(base, target);
}
}
return url;
};
export const rewriteJrDataSystemUrl = (
uri: string,
environment: unknown,
): string => {
if (typeof uri !== "string" || uri.length === 0) {
return uri;
}
const envKey = normalizeJrDataSystemEnvironment(environment);
const targetBaseUrl = JR_DATA_SYSTEM_ENVS[envKey].baseUrl;
const knownBaseUrls = [
"https://jr-shikoku-data-system.pages.dev",
JR_DATA_SYSTEM_ENVS.production_release.baseUrl,
JR_DATA_SYSTEM_ENVS.production_beta.baseUrl,
JR_DATA_SYSTEM_ENVS.experimental.baseUrl,
];
for (const baseUrl of knownBaseUrls) {
if (uri.startsWith(baseUrl)) {
return uri.replace(baseUrl, targetBaseUrl);
}
}
return uri;
};

23
lib/mockApi/index.ts Normal file
View File

@@ -0,0 +1,23 @@
/**
* Mock API index
*
* Re-exports the XHR interceptor generator and pre-loaded sample data
* captured from the official JR Shikoku train position site.
*
* Usage example:
* import { MOCK_TRAIN_POSITIONS } from '@/lib/mockApi';
* const { setMockApiEnabled, setMockTrainPositions } = useTrainMenu();
*
* // Enable mock mode with sample data
* setMockTrainPositions(MOCK_TRAIN_POSITIONS);
* setMockApiEnabled(true);
*/
export { generateXhrInterceptorJs, MockApiConfig, TrainEntry } from './webviewXhrInterceptor';
// Pre-captured sample train position data from the official site
import trainJson from './mockData/train.json';
export const MOCK_TRAIN_POSITIONS = trainJson.filter(
(entry): entry is import('./webviewXhrInterceptor').TrainEntry =>
'TrainNum' in entry,
);

View File

@@ -0,0 +1,592 @@
[
{
"BetweenStation": "高松~鬼無",
"Datas": [
{
"StationName": "香西",
"StationNumber": "Y01",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kozai.pdf"
}
]
},
{
"BetweenStation": "端岡~鴨川",
"Datas": [
{
"StationName": "国分",
"StationNumber": "Y04",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kokubu.pdf"
},
{
"StationName": "讃岐府中",
"StationNumber": "Y05",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-fuchu.pdf"
}
]
},
{
"BetweenStation": "鴨川~坂出",
"Datas": [
{
"StationName": "八十場",
"StationNumber": "Y07",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yasoba.pdf"
}
]
},
{
"BetweenStation": "丸亀~多度津",
"Datas": [
{
"StationName": "讃岐塩屋",
"StationNumber": "Y11",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-shioya.pdf"
}
]
},
{
"BetweenStation": "詫間~高瀬",
"Datas": [
{
"StationName": "みの",
"StationNumber": "Y15",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/mino.pdf"
}
]
},
{
"BetweenStation": "高瀬~本山",
"Datas": [
{
"StationName": "比地大",
"StationNumber": "Y17",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hijidai.pdf"
}
]
},
{
"BetweenStation": "伊予寒川~伊予土居",
"Datas": [
{
"StationName": "赤星",
"StationNumber": "Y25",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/akaboshi.pdf"
}
]
},
{
"BetweenStation": "石鎚山~伊予小松",
"Datas": [
{
"StationName": "伊予氷見",
"StationNumber": "Y33",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-himi.pdf"
}
]
},
{
"BetweenStation": "伊予小松~壬生川",
"Datas": [
{
"StationName": "玉之江",
"StationNumber": "Y35",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tamanoe.pdf"
}
]
},
{
"BetweenStation": "伊予北条~粟井",
"Datas": [
{
"StationName": "柳原",
"StationNumber": "Y49",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yanagihara.pdf"
}
]
},
{
"BetweenStation": "粟井~堀江",
"Datas": [
{
"StationName": "光洋台",
"StationNumber": "Y51",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/koyodai.pdf"
}
]
},
{
"BetweenStation": "高松~栗林",
"Datas": [
{
"StationName": "昭和町",
"StationNumber": "T27",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/showacho.pdf"
},
{
"StationName": "栗林公園北口",
"StationNumber": "T26",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ritsurinkoen-kitaguchi.pdf"
}
]
},
{
"BetweenStation": "栗林~屋島",
"Datas": [
{
"StationName": "木太町",
"StationNumber": "T24",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kitacho.pdf"
}
]
},
{
"BetweenStation": "屋島~八栗口",
"Datas": [
{
"StationName": "古高松南",
"StationNumber": "T22",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/furutakamatsu-minami.pdf"
}
]
},
{
"BetweenStation": "八栗口~志度",
"Datas": [
{
"StationName": "讃岐牟礼",
"StationNumber": "T20",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-mure.pdf"
}
]
},
{
"BetweenStation": "造田~讃岐津田",
"Datas": [
{
"StationName": "神前",
"StationNumber": "T16",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kanzaki.pdf"
}
]
},
{
"BetweenStation": "板野~板東",
"Datas": [
{
"StationName": "阿波川端",
"StationNumber": "T06",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-kawabata.pdf"
}
]
},
{
"BetweenStation": "北伊予~伊予市",
"Datas": [
{
"StationName": "南伊予",
"StationNumber": "U02-1",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/minami-iyo.pdf"
},
{
"StationName": "伊予横田",
"StationNumber": "U03",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-yokota.pdf"
},
{
"StationName": "鳥ノ木",
"StationNumber": "U04",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/torinoki.pdf"
}
]
},
{
"BetweenStation": "向井原~伊予中山",
"Datas": [
{
"StationName": "伊予大平",
"StationNumber": "U07",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-ohira.pdf"
}
]
},
{
"BetweenStation": "内子~新谷",
"Datas": [
{
"StationName": "五十崎",
"StationNumber": "U11",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ikazaki.pdf"
},
{
"StationName": "喜多山",
"StationNumber": "U12",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kitayama.pdf"
}
]
},
{
"BetweenStation": "伊予大洲~伊予平野",
"Datas": [
{
"StationName": "西大洲",
"StationNumber": "U15",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nishi-ozu.pdf"
}
]
},
{
"BetweenStation": "伊予石城~卯之町",
"Datas": [
{
"StationName": "上宇和",
"StationNumber": "U21",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kami-uwa.pdf"
}
]
},
{
"BetweenStation": "伊予吉田~北宇和島",
"Datas": [
{
"StationName": "高光",
"StationNumber": "U26",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takamitsu.pdf"
}
]
},
{
"BetweenStation": "向井原~伊予上灘",
"Datas": [
{
"StationName": "高野川",
"StationNumber": "S07",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/konokawa.pdf"
}
]
},
{
"BetweenStation": "伊予上灘~伊予長浜",
"Datas": [
{
"StationName": "下灘",
"StationNumber": "S09",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shimonada.pdf"
},
{
"StationName": "串",
"StationNumber": "S10",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kushi.pdf"
},
{
"StationName": "喜多灘",
"StationNumber": "S11",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kitanada.pdf"
}
]
},
{
"BetweenStation": "伊予長浜~伊予白滝",
"Datas": [
{
"StationName": "伊予出石",
"StationNumber": "S13",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-izushi.pdf"
}
]
},
{
"BetweenStation": "伊予白滝~伊予大洲",
"Datas": [
{
"StationName": "八多喜",
"StationNumber": "S15",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hataki.pdf"
},
{
"StationName": "春賀",
"StationNumber": "S16",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/haruka.pdf"
},
{
"StationName": "五郎",
"StationNumber": "S17",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/goro.pdf"
}
]
},
{
"BetweenStation": "塩入~讃岐財田",
"Datas": [
{
"StationName": "黒川",
"StationNumber": "D17",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kurokawa.pdf"
}
]
},
{
"BetweenStation": "三縄~阿波川口",
"Datas": [
{
"StationName": "祖谷口",
"StationNumber": "D24",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyaguchi.pdf"
}
]
},
{
"BetweenStation": "大田口~大杉",
"Datas": [
{
"StationName": "土佐穴内",
"StationNumber": "D31",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-ananai.pdf"
}
]
},
{
"BetweenStation": "土佐北川~繁藤",
"Datas": [
{
"StationName": "角茂谷",
"StationNumber": "D34",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kakumodani.pdf"
}
]
},
{
"BetweenStation": "土佐山田~後免",
"Datas": [
{
"StationName": "山田西町",
"StationNumber": "D38",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yamadanishimachi.pdf"
},
{
"StationName": "土佐長岡",
"StationNumber": "D39",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-nagaoka.pdf"
}
]
},
{
"BetweenStation": "土佐大津~土佐一宮",
"Datas": [
{
"StationName": "布師田",
"StationNumber": "D42",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nunoshida.pdf"
}
]
},
{
"BetweenStation": "高知~旭",
"Datas": [
{
"StationName": "入明",
"StationNumber": "K01",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iriake.pdf"
},
{
"StationName": "円行寺口",
"StationNumber": "K02",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/engyojiguchi.pdf"
}
]
},
{
"BetweenStation": "旭~朝倉",
"Datas": [
{
"StationName": "高知商業前",
"StationNumber": "K04",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kochi-shogyomae.pdf"
}
]
},
{
"BetweenStation": "朝倉~伊野",
"Datas": [
{
"StationName": "枝川",
"StationNumber": "K06",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/edagawa.pdf"
}
]
},
{
"BetweenStation": "伊野~日下",
"Datas": [
{
"StationName": "波川",
"StationNumber": "K08",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hakawa.pdf"
},
{
"StationName": "小村神社前",
"StationNumber": "K08-1",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/omurajinjamae.pdf"
}
]
},
{
"BetweenStation": "日下~土佐加茂",
"Datas": [
{
"StationName": "岡花",
"StationNumber": "K10",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/okabana.pdf"
}
]
},
{
"BetweenStation": "佐川~斗賀野",
"Datas": [
{
"StationName": "襟野々",
"StationNumber": "K14",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/erinono.pdf"
}
]
},
{
"BetweenStation": "多ノ郷~須崎",
"Datas": [
{
"StationName": "大間",
"StationNumber": "K18",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oma.pdf"
}
]
},
{
"BetweenStation": "須崎~土佐久礼",
"Datas": [
{
"StationName": "土佐新荘",
"StationNumber": "K20",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-shinjo.pdf"
},
{
"StationName": "安和",
"StationNumber": "K21",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa.pdf"
}
]
},
{
"BetweenStation": "影野~窪川",
"Datas": [
{
"StationName": "六反地",
"StationNumber": "K24",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/rokutanji.pdf"
},
{
"StationName": "仁井田",
"StationNumber": "K25",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/niida.pdf"
}
]
},
{
"BetweenStation": "蔵本~府中",
"Datas": [
{
"StationName": "鮎喰",
"StationNumber": "B03",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/akui.pdf"
}
]
},
{
"BetweenStation": "石井~牛島",
"Datas": [
{
"StationName": "下浦",
"StationNumber": "B06",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shimoura.pdf"
}
]
},
{
"BetweenStation": "牛島~鴨島",
"Datas": [
{
"StationName": "麻植塚",
"StationNumber": "B08",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oezuka.pdf"
}
]
},
{
"BetweenStation": "鴨島~阿波川島",
"Datas": [
{
"StationName": "西麻植",
"StationNumber": "B10",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nishi-oe.pdf"
}
]
},
{
"BetweenStation": "山瀬~川田",
"Datas": [
{
"StationName": "阿波山川",
"StationNumber": "B14",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-yamakawa.pdf"
}
]
},
{
"BetweenStation": "貞光~江口",
"Datas": [
{
"StationName": "阿波半田",
"StationNumber": "B19",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-handa.pdf"
}
]
},
{
"BetweenStation": "江口~阿波加茂",
"Datas": [
{
"StationName": "三加茂",
"StationNumber": "B21",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/mikamo.pdf"
}
]
},
{
"BetweenStation": "鳴門~池谷",
"Datas": [
{
"StationName": "撫養",
"StationNumber": "N09",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/muya.pdf"
},
{
"StationName": "金比羅前",
"StationNumber": "N08",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kompiramae.pdf"
},
{
"StationName": "教会前",
"StationNumber": "N07",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kyokaimae.pdf"
},
{
"StationName": "立道",
"StationNumber": "N06",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tatsumichi.pdf"
},
{
"StationName": "阿波大谷",
"StationNumber": "N05",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-otani.pdf"
}
]
}
]

View File

@@ -0,0 +1,74 @@
[
{
"StartStationName": "坂出",
"EndStationName": "宇多津",
"LineName": "seto",
"Point": "Up",
"Half": "F-End",
"DispPos": "seto_0_児島"
},
{
"StartStationName": "多度津",
"EndStationName": "海岸寺",
"LineName": "dosan",
"Point": "Down",
"Half": "Start",
"DispPos": "dosan_0_多度津"
},
{
"StartStationName": "向井原",
"EndStationName": "伊予中山",
"LineName": "uwajima2",
"Point": "Up",
"Half": "Start",
"DispPos": "uwajima2_0_向井原"
},
{
"StartStationName": "新谷",
"EndStationName": "伊予大洲",
"LineName": "uwajima2",
"Point": "Up",
"Half": "End",
"DispPos": "uwajima2_3_伊予大洲"
},
{
"StartStationName": "箸蔵",
"EndStationName": "佃",
"LineName": "tokushima",
"Point": "Down",
"Half": "End",
"DispPos": "tokushima_16_佃"
},
{
"StartStationName": "吉成",
"EndStationName": "佐古",
"LineName": "tokushima",
"Point": "Up",
"Half": "End",
"DispPos": "tokushima_0_徳島"
},
{
"StartStationName": "佐古",
"EndStationName": "蔵本",
"LineName": "koutoku",
"Point": "Up",
"Half": "Start",
"DispPos": "koutoku_19_佐古"
},
{
"StartStationName": "辻",
"EndStationName": "佃",
"LineName": "dosan",
"Point": "Up",
"Half": "End",
"DispPos": "dosan_7_佃"
},
{
"StartStationName": "板東",
"EndStationName": "池谷",
"LineName": "naruto",
"Point": "Down",
"Half": "End",
"DispPos": "naruto_0_鳴門"
}
]

View File

View File

@@ -0,0 +1,10 @@
[
{
"TrainNum": "1219M",
"String": "※土曜・休日は多度津-琴平間運休"
},
{
"TrainNum": "5223M",
"String": "※休日は高松-多度津間運休"
}
]

View File

@@ -0,0 +1,20 @@
[
"70",
"71",
"73",
"74",
"75",
"76",
"3070",
"3071",
"3072",
"3073",
"3076",
"3077",
"3078",
"3079",
"8070",
"8071",
"8072",
"8077"
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,32 @@
[
{
"Line": "tokushimaa",
"Datas": [
{
"Name": "徳島",
"Color": "koutoku"
},
{
"Name": "徳島~佐古",
"Color": "koutoku"
},
{
"Name": "佃~阿波池田",
"Color": "dosan"
},
{
"Name": "阿波池田",
"Color": "dosan"
}
]
},
{
"Line": "setoa",
"Datas": [
{
"Name": "児島",
"Color": "other"
}
]
}
]

View File

@@ -0,0 +1,80 @@
[
{
"LineName": "yosan",
"Position": "end",
"OtherLineName": "uwajima",
"DispPos": "uwajima_0_松山"
},
{
"LineName": "yosan",
"Position": "start",
"OtherLineName": "koutoku",
"DispPos": "koutoku_0_高松"
},
{
"LineName": "uwajima",
"Position": "start",
"OtherLineName": "yosan",
"DispPos": "yosan_42_松山"
},
{
"LineName": "uwajima2",
"Position": "start",
"OtherLineName": "uwajima",
"DispPos": "uwajima_3_向井原"
},
{
"LineName": "uwajima2",
"Position": "end",
"OtherLineName": "uwajima",
"DispPos": "uwajima_8_伊予大洲"
},
{
"LineName": "seto",
"Position": "end",
"OtherLineName": "yosan",
"DispPos": "yosan_3_坂出"
},
{
"LineName": "dosan",
"Position": "start",
"OtherLineName": "yosan",
"DispPos": "yosan_6_多度津"
},
{
"LineName": "dosan",
"Position": "end",
"OtherLineName": "dosan2",
"DispPos": "dosan2_0_高知"
},
{
"LineName": "tokushima",
"Position": "start",
"OtherLineName": "koutoku",
"DispPos": "koutoku_20_徳島"
},
{
"LineName": "koutoku",
"Position": "start",
"OtherLineName": "yosan",
"DispPos": "yosan_0_高松"
},
{
"LineName": "dosan2",
"Position": "start",
"OtherLineName": "dosan",
"DispPos": "dosan_25_高知"
},
{
"LineName": "tokushima",
"Position": "end",
"OtherLineName": "dosan",
"DispPos": "dosan_8_阿波池田"
},
{
"LineName": "naruto",
"Position": "end",
"OtherLineName": "koutoku",
"DispPos": "koutoku_16_池谷"
}
]

View File

@@ -0,0 +1,7 @@
[
"伊予灘ものがたり",
"千年ものがたり",
"アンパンマントロッコ",
"夜明けのものがたり",
"藍よしのがわトロッコ"
]

View File

@@ -0,0 +1,272 @@
[
{
"Station_JP": "多度津",
"Station_EN": "Tadotsu",
"MyStation": "0",
"StationNumber": "D12",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tadotsu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.271088,133.756735",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tadotsu/"
},
{
"Station_JP": "金蔵寺",
"Station_EN": "Konzōji",
"MyStation": "0",
"StationNumber": "D13",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/konzoji.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.247861,133.777594",
"JrHpUrl": ""
},
{
"Station_JP": "善通寺",
"Station_EN": "Zentsūji",
"MyStation": "1",
"StationNumber": "D14",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/zentsuji.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.229958,133.789141",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/zentsuji/"
},
{
"Station_JP": "琴平",
"Station_EN": "Kotohira",
"MyStation": "2",
"StationNumber": "D15",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kotohira.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.191903,133.821295",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kotohira/"
},
{
"Station_JP": "塩入",
"Station_EN": "Shioiri",
"MyStation": "3",
"StationNumber": "D16",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shioiri.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.156034,133.849974",
"JrHpUrl": ""
},
{
"Station_JP": "讃岐財田",
"Station_EN": "Sanuki-Saida",
"MyStation": "4",
"StationNumber": "D18",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-saida.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.117586,133.814181",
"JrHpUrl": ""
},
{
"Station_JP": "坪尻",
"Station_EN": "Tsubojiri",
"MyStation": "5",
"StationNumber": "D19",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsubojiri.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.054035,133.823675",
"JrHpUrl": ""
},
{
"Station_JP": "箸蔵",
"Station_EN": "Hashikura",
"MyStation": "6",
"StationNumber": "D20",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hashikura.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.040343,133.848761",
"JrHpUrl": ""
},
{
"Station_JP": "佃",
"Station_EN": "Tsukuda",
"MyStation": "7",
"StationNumber": "D21",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsukuda.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.032458,133.857363",
"JrHpUrl": ""
},
{
"Station_JP": "阿波池田",
"Station_EN": "Awa-Ikeda",
"MyStation": "8",
"StationNumber": "D22",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-ikeda.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.027177,133.804619",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/awaikeda/"
},
{
"Station_JP": "三縄",
"Station_EN": "Minawa",
"MyStation": "9",
"StationNumber": "D23",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/minawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.006837,133.787427",
"JrHpUrl": ""
},
{
"Station_JP": "阿波川口",
"Station_EN": "Awa-Kawaguchi",
"MyStation": "10",
"StationNumber": "D25",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-kawaguchi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.963705,133.754617",
"JrHpUrl": ""
},
{
"Station_JP": "小歩危",
"Station_EN": "Koboke",
"MyStation": "11",
"StationNumber": "D26",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/koboke.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.923338,133.758747",
"JrHpUrl": ""
},
{
"Station_JP": "大歩危",
"Station_EN": "Ōboke",
"MyStation": "12",
"StationNumber": "D27",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oboke.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.876483,133.767298",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/oboke/"
},
{
"Station_JP": "土佐岩原",
"Station_EN": "Tosa-Iwahara",
"MyStation": "13",
"StationNumber": "D28",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-iwahara.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.822635,133.788204",
"JrHpUrl": ""
},
{
"Station_JP": "豊永",
"Station_EN": "Toyonaga",
"MyStation": "14",
"StationNumber": "D29",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/toyonaga.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.796841,133.759716",
"JrHpUrl": ""
},
{
"Station_JP": "大田口",
"Station_EN": "Ōtaguchi",
"MyStation": "15",
"StationNumber": "D30",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/otaguchi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.785517,133.726602",
"JrHpUrl": ""
},
{
"Station_JP": "大杉",
"Station_EN": "Ōsugi",
"MyStation": "16",
"StationNumber": "D32",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/osugi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.760938,133.664483",
"JrHpUrl": ""
},
{
"Station_JP": "土佐北川",
"Station_EN": "Tosa-Kitagawa",
"MyStation": "17",
"StationNumber": "D33",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-kitagawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.714781,133.686312",
"JrHpUrl": ""
},
{
"Station_JP": "繁藤",
"Station_EN": "Shigetō",
"MyStation": "18",
"StationNumber": "D35",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shigeto.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.680386,133.6902",
"JrHpUrl": ""
},
{
"Station_JP": "新改",
"Station_EN": "Shingai",
"MyStation": "19",
"StationNumber": "D36",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shingai.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.649629,133.695788",
"JrHpUrl": ""
},
{
"Station_JP": "土佐山田",
"Station_EN": "Tosa-Yamada",
"MyStation": "20",
"StationNumber": "D37",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-yamada.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.607099,133.684992",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tosayamada/"
},
{
"Station_JP": "後免",
"Station_EN": "Gomen",
"MyStation": "21",
"StationNumber": "D40",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/gomen.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.579234,133.645357",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/gomen/"
},
{
"Station_JP": "土佐大津",
"Station_EN": "Tosa-Ōtsu",
"MyStation": "22",
"StationNumber": "D41",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-otsu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.576384,133.611446",
"JrHpUrl": ""
},
{
"Station_JP": "土佐一宮",
"Station_EN": "Tosa-Ikku",
"MyStation": "23",
"StationNumber": "D43",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-ikku.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.579247,133.576891",
"JrHpUrl": ""
},
{
"Station_JP": "薊野",
"Station_EN": "Azōno",
"MyStation": "24",
"StationNumber": "D44",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/azono.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.578616,133.560692",
"JrHpUrl": ""
},
{
"Station_JP": "高知",
"Station_EN": "Kōchi",
"MyStation": "25",
"StationNumber": "D45",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kochi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.566525,133.543638",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kochi/"
}
]

View File

@@ -0,0 +1,152 @@
[
{
"Station_JP": "高知",
"Station_EN": "Kōchi",
"MyStation": "0",
"StationNumber": "K00",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kochi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.566525,133.543638",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kochi/"
},
{
"Station_JP": "旭",
"Station_EN": "Asahi",
"MyStation": "0",
"StationNumber": "K03",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/asahi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.558853,133.508817",
"JrHpUrl": ""
},
{
"Station_JP": "朝倉",
"Station_EN": "Asakura",
"MyStation": "1",
"StationNumber": "K05",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/asakura.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.551389,133.485354",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/asakura/"
},
{
"Station_JP": "伊野",
"Station_EN": "Ino",
"MyStation": "2",
"StationNumber": "K07",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ino.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.547505,133.430131",
"JrHpUrl": ""
},
{
"Station_JP": "日下",
"Station_EN": "Kusaka",
"MyStation": "3",
"StationNumber": "K09",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kusaka.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.53338,133.371236",
"JrHpUrl": ""
},
{
"Station_JP": "土佐加茂",
"Station_EN": "Tosa-Kamo",
"MyStation": "4",
"StationNumber": "K11",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-kamo.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.523569,133.321135",
"JrHpUrl": ""
},
{
"Station_JP": "西佐川",
"Station_EN": "Nishi-Sakawa",
"MyStation": "5",
"StationNumber": "K12",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nishi-sakawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.512623,133.286508",
"JrHpUrl": ""
},
{
"Station_JP": "佐川",
"Station_EN": "Sakawa",
"MyStation": "6",
"StationNumber": "K13",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sakawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.500105,133.292438",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/sakawa/"
},
{
"Station_JP": "斗賀野",
"Station_EN": "Togano",
"MyStation": "7",
"StationNumber": "K15",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/togano.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.475415,133.286372",
"JrHpUrl": ""
},
{
"Station_JP": "吾桑",
"Station_EN": "Asō",
"MyStation": "8",
"StationNumber": "K16",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/aso.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.430181,133.295638",
"JrHpUrl": ""
},
{
"Station_JP": "多ノ郷",
"Station_EN": "Ōnogō",
"MyStation": "9",
"StationNumber": "K17",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/onogo.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.410727,133.294630",
"JrHpUrl": ""
},
{
"Station_JP": "須崎",
"Station_EN": "Susaki",
"MyStation": "10",
"StationNumber": "K19",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/susaki.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.392624,133.293189",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/susaki/"
},
{
"Station_JP": "土佐久礼",
"Station_EN": "Tosa-Kure",
"MyStation": "11",
"StationNumber": "K22",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tosa-kure.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.329028,133.226483",
"JrHpUrl": ""
},
{
"Station_JP": "影野",
"Station_EN": "Kageno",
"MyStation": "12",
"StationNumber": "K23",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kageno.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.275882,133.17358",
"JrHpUrl": ""
},
{
"Station_JP": "窪川",
"Station_EN": "Kubokawa",
"MyStation": "13",
"StationNumber": "K26",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kubokawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.212436,133.13716",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kubokawa/"
}
]

View File

@@ -0,0 +1,222 @@
[
{
"Station_JP": "高松",
"Station_EN": "Takamatsu",
"MyStation": "0",
"StationNumber": "T28",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takamatsu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.350682,134.046938",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/takamatsu/"
},
{
"Station_JP": "栗林",
"Station_EN": "Ritsurin",
"MyStation": "0",
"StationNumber": "T25",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ritsurin.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.332203,134.053588",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/ritsurin/"
},
{
"Station_JP": "屋島",
"Station_EN": "Yashima",
"MyStation": "1",
"StationNumber": "T23",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yashima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.336655,134.109102",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/yashima/"
},
{
"Station_JP": "八栗口",
"Station_EN": "Yakuriguchi",
"MyStation": "2",
"StationNumber": "T21",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yakuriguchi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.339097,134.136488",
"JrHpUrl": ""
},
{
"Station_JP": "志度",
"Station_EN": "Shido",
"MyStation": "3",
"StationNumber": "T19",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shido.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.321624,134.1728",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/shido/"
},
{
"Station_JP": "オレンジタウン",
"Station_EN": "Orange-Town",
"MyStation": "4",
"StationNumber": "T18",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/orange-town.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.301827,134.180946",
"JrHpUrl": ""
},
{
"Station_JP": "造田",
"Station_EN": "Zōda",
"MyStation": "5",
"StationNumber": "T17",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/zoda.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.282822,134.185946",
"JrHpUrl": ""
},
{
"Station_JP": "讃岐津田",
"Station_EN": "Sanuki-Tsuda",
"MyStation": "6",
"StationNumber": "T15",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-tsuda.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.288442,134.248154",
"JrHpUrl": ""
},
{
"Station_JP": "鶴羽",
"Station_EN": "Tsuruwa",
"MyStation": "7",
"StationNumber": "T14",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsuruwa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.280008,134.273842",
"JrHpUrl": ""
},
{
"Station_JP": "丹生",
"Station_EN": "Nibu",
"MyStation": "8",
"StationNumber": "T13",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nibu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.254508,134.301487",
"JrHpUrl": ""
},
{
"Station_JP": "三本松",
"Station_EN": "Sambommatsu",
"MyStation": "9",
"StationNumber": "T12",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sambommatsu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.251538,134.334473",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/sanbonmatsu/"
},
{
"Station_JP": "讃岐白鳥",
"Station_EN": "Sanuki-Shirotori",
"MyStation": "10",
"StationNumber": "T11",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-shirotori.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.243642,134.366203",
"JrHpUrl": ""
},
{
"Station_JP": "引田",
"Station_EN": "Hiketa",
"MyStation": "11",
"StationNumber": "T10",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hiketa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.224467,134.402083",
"JrHpUrl": ""
},
{
"Station_JP": "讃岐相生",
"Station_EN": "Sanuki-Aioi",
"MyStation": "12",
"StationNumber": "T09",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sanuki-aioi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.211994,134.424534",
"JrHpUrl": ""
},
{
"Station_JP": "阿波大宮",
"Station_EN": "Awa-Ōmiya",
"MyStation": "13",
"StationNumber": "T08",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-omiya.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.17859,134.448947",
"JrHpUrl": ""
},
{
"Station_JP": "板野",
"Station_EN": "Itano",
"MyStation": "14",
"StationNumber": "T07",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/itano.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.142193,134.46597",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/itano/"
},
{
"Station_JP": "板東",
"Station_EN": "Bandō",
"MyStation": "15",
"StationNumber": "T05",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/bando.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.155432,134.506866",
"JrHpUrl": ""
},
{
"Station_JP": "池谷",
"Station_EN": "Ikenotani",
"MyStation": "16",
"StationNumber": "T04",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ikenotani.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.15293,134.528905",
"JrHpUrl": ""
},
{
"Station_JP": "勝瑞",
"Station_EN": "Shōzui",
"MyStation": "17",
"StationNumber": "T03",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shozui.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.128644,134.528267",
"JrHpUrl": ""
},
{
"Station_JP": "吉成",
"Station_EN": "Yoshinari",
"MyStation": "18",
"StationNumber": "T02",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yoshinari.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.118511,134.530745",
"JrHpUrl": ""
},
{
"Station_JP": "佐古",
"Station_EN": "Sako",
"MyStation": "19",
"StationNumber": "T01",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sako.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.080616,134.538576",
"JrHpUrl": ""
},
{
"Station_JP": "徳島",
"Station_EN": "Tokushima",
"MyStation": "20",
"StationNumber": "T00",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tokushima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.074642,134.550764",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tokushima/"
}
]

View File

@@ -0,0 +1,22 @@
[
{
"Station_JP": "鳴門",
"Station_EN": "Naruto",
"MyStation": "0",
"StationNumber": "N10",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/naruto.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.17925,134.608536",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/naruto/"
},
{
"Station_JP": "池谷",
"Station_EN": "Ikenotani",
"MyStation": "0",
"StationNumber": "N04",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ikenotani.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.15293,134.528905",
"JrHpUrl": ""
}
]

View File

@@ -0,0 +1,192 @@
[
{
"Station_JP": "徳島",
"Station_EN": "Tokushima",
"MyStation": "0",
"StationNumber": "T00",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tokushima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.074642,134.550764",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tokushima/"
},
{
"Station_JP": "佐古",
"Station_EN": "Sako",
"MyStation": "0",
"StationNumber": "B01",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sako.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.080616,134.538576",
"JrHpUrl": ""
},
{
"Station_JP": "蔵本",
"Station_EN": "Kuramoto",
"MyStation": "1",
"StationNumber": "B02",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kuramoto.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.079332,134.518705",
"JrHpUrl": ""
},
{
"Station_JP": "府中",
"Station_EN": "Kō",
"MyStation": "2",
"StationNumber": "B04",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ko.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.074134,134.482939",
"JrHpUrl": ""
},
{
"Station_JP": "石井",
"Station_EN": "Ishii",
"MyStation": "3",
"StationNumber": "B05",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ishii.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.070188,134.444343",
"JrHpUrl": ""
},
{
"Station_JP": "牛島",
"Station_EN": "Ushinoshima",
"MyStation": "4",
"StationNumber": "B07",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ushinoshima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.076709,134.397553",
"JrHpUrl": ""
},
{
"Station_JP": "鴨島",
"Station_EN": "Kamojima",
"MyStation": "5",
"StationNumber": "B09",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kamojima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.073615,134.356559",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kamojima/"
},
{
"Station_JP": "阿波川島",
"Station_EN": "Awa-Kawashima",
"MyStation": "6",
"StationNumber": "B11",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-kawashima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.061857,134.320768",
"JrHpUrl": ""
},
{
"Station_JP": "学",
"Station_EN": "Gaku",
"MyStation": "7",
"StationNumber": "B12",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/gaku.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.057766,134.286411",
"JrHpUrl": ""
},
{
"Station_JP": "山瀬",
"Station_EN": "Yamase",
"MyStation": "8",
"StationNumber": "B13",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yamase.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.060226,134.256297",
"JrHpUrl": ""
},
{
"Station_JP": "川田",
"Station_EN": "Kawata",
"MyStation": "9",
"StationNumber": "B15",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kawata.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.061533,134.204329",
"JrHpUrl": ""
},
{
"Station_JP": "穴吹",
"Station_EN": "Anabuki",
"MyStation": "10",
"StationNumber": "B16",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/anabuki.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.05615,134.163064",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/anabuki/"
},
{
"Station_JP": "小島",
"Station_EN": "Oshima",
"MyStation": "11",
"StationNumber": "B17",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oshima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.050665,134.106521",
"JrHpUrl": ""
},
{
"Station_JP": "貞光",
"Station_EN": "Sadamitsu",
"MyStation": "12",
"StationNumber": "B18",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sadamitsu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.039354,134.058774",
"JrHpUrl": ""
},
{
"Station_JP": "江口",
"Station_EN": "Eguchi",
"MyStation": "13",
"StationNumber": "B20",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/eguchi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.037364,133.973229",
"JrHpUrl": ""
},
{
"Station_JP": "阿波加茂",
"Station_EN": "Awa-Kamo",
"MyStation": "14",
"StationNumber": "B22",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-kamo.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.037063,133.926505",
"JrHpUrl": ""
},
{
"Station_JP": "辻",
"Station_EN": "Tsuji",
"MyStation": "15",
"StationNumber": "B23",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsuji.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.028992,133.873234",
"JrHpUrl": ""
},
{
"Station_JP": "佃",
"Station_EN": "Tsukuda",
"MyStation": "16",
"StationNumber": "B24",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tsukuda.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.032458,133.857363",
"JrHpUrl": ""
},
{
"Station_JP": "阿波池田",
"Station_EN": "Awa-Ikeda",
"MyStation": "17",
"StationNumber": "B25",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awa-ikeda.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.027177,133.804619",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/awaikeda/"
}
]

View File

@@ -0,0 +1,212 @@
[
{
"Station_JP": "松山",
"Station_EN": "Matsuyama",
"MyStation": "0",
"StationNumber": "U00",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/matsuyama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.84039,132.75139",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/matsuyama/"
},
{
"Station_JP": "市坪",
"Station_EN": "Ichitsubo",
"MyStation": "0",
"StationNumber": "U01",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ichitsubo.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.809441,132.749325",
"JrHpUrl": ""
},
{
"Station_JP": "北伊予",
"Station_EN": "Kita-Iyo",
"MyStation": "1",
"StationNumber": "U02",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kita-iyo.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.787699,132.748963",
"JrHpUrl": ""
},
{
"Station_JP": "伊予市",
"Station_EN": "Iyoshi",
"MyStation": "2",
"StationNumber": "U05",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyoshi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.755352,132.702327",
"JrHpUrl": ""
},
{
"Station_JP": "向井原",
"Station_EN": "Mukaibara",
"MyStation": "3",
"StationNumber": "U06",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/mukaibara.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.736043,132.695825",
"JrHpUrl": ""
},
{
"Station_JP": "伊予中山",
"Station_EN": "Iyo-Nakayama",
"MyStation": "4",
"StationNumber": "U08",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-nakayama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.649292,132.711857",
"JrHpUrl": ""
},
{
"Station_JP": "伊予立川",
"Station_EN": "Iyo-Tachikawa",
"MyStation": "5",
"StationNumber": "U09",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-tachikawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.601197,132.677898",
"JrHpUrl": ""
},
{
"Station_JP": "内子",
"Station_EN": "Uchiko",
"MyStation": "6",
"StationNumber": "U10",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/uchiko.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.549461,132.646304",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/uchiko/"
},
{
"Station_JP": "新谷",
"Station_EN": "Niiya",
"MyStation": "7",
"StationNumber": "U13",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/niiya.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.534106,132.59904",
"JrHpUrl": ""
},
{
"Station_JP": "伊予大洲",
"Station_EN": "Iyo-Ōzu",
"MyStation": "8",
"StationNumber": "U14",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-ozu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.518495,132.544878",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/iyozu/"
},
{
"Station_JP": "伊予平野",
"Station_EN": "Iyo-Hirano",
"MyStation": "9",
"StationNumber": "U16",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-hirano.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.495827,132.518108",
"JrHpUrl": ""
},
{
"Station_JP": "千丈",
"Station_EN": "Senjō",
"MyStation": "10",
"StationNumber": "U17",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/senjo.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.465646,132.457416",
"JrHpUrl": ""
},
{
"Station_JP": "八幡浜",
"Station_EN": "Yawatahama",
"MyStation": "11",
"StationNumber": "U18",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/yawatahama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.458146,132.436002",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/yawatahama/"
},
{
"Station_JP": "双岩",
"Station_EN": "Futaiwa",
"MyStation": "12",
"StationNumber": "U19",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/futaiwa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.424669,132.457934",
"JrHpUrl": ""
},
{
"Station_JP": "伊予石城",
"Station_EN": "Iyo-Iwaki",
"MyStation": "13",
"StationNumber": "U20",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-iwaki.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.391413,132.473259",
"JrHpUrl": ""
},
{
"Station_JP": "卯之町",
"Station_EN": "Unomachi",
"MyStation": "14",
"StationNumber": "U22",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/unomachi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.363175,132.509959",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/unomachi/"
},
{
"Station_JP": "下宇和",
"Station_EN": "Shimo-Uwa",
"MyStation": "15",
"StationNumber": "U23",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/shimo-uwa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.349786,132.531172",
"JrHpUrl": ""
},
{
"Station_JP": "立間",
"Station_EN": "Tachima",
"MyStation": "16",
"StationNumber": "U24",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tachima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.296721,132.539509",
"JrHpUrl": ""
},
{
"Station_JP": "伊予吉田",
"Station_EN": "Iyo-Yoshida",
"MyStation": "17",
"StationNumber": "U25",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-yoshida.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.273287,132.544098",
"JrHpUrl": ""
},
{
"Station_JP": "北宇和島",
"Station_EN": "Kita-Uwajima",
"MyStation": "18",
"StationNumber": "U27",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kita-uwajima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.238636,132.569909",
"JrHpUrl": ""
},
{
"Station_JP": "宇和島",
"Station_EN": "Uwajima",
"MyStation": "19",
"StationNumber": "U28",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/uwajima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.225793,132.567498",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/uwajima/"
}
]

View File

@@ -0,0 +1,442 @@
[
{
"Station_JP": "高松",
"Station_EN": "Takamatsu",
"MyStation": "0",
"StationNumber": "Y00",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takamatsu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.350682,134.046938",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/takamatsu/"
},
{
"Station_JP": "鬼無",
"Station_EN": "Kinashi",
"MyStation": "0",
"StationNumber": "Y02",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kinashi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.325494,133.993861",
"JrHpUrl": ""
},
{
"Station_JP": "端岡",
"Station_EN": "Hashioka",
"MyStation": "1",
"StationNumber": "Y03",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hashioka.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.305027,133.967643",
"JrHpUrl": ""
},
{
"Station_JP": "鴨川",
"Station_EN": "Kamogawa",
"MyStation": "2",
"StationNumber": "Y06",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kamogawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.306877,133.905229",
"JrHpUrl": ""
},
{
"Station_JP": "坂出",
"Station_EN": "Sakaide",
"MyStation": "3",
"StationNumber": "Y08",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sakaide.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.313222,133.856325",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/sakaide/"
},
{
"Station_JP": "宇多津",
"Station_EN": "Utazu",
"MyStation": "4",
"StationNumber": "Y09",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/utazu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.306379,133.813784",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/utazu/"
},
{
"Station_JP": "丸亀",
"Station_EN": "Marugame",
"MyStation": "5",
"StationNumber": "Y10",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/marugame.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.292006,133.793175",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/marugame/"
},
{
"Station_JP": "多度津",
"Station_EN": "Tadotsu",
"MyStation": "6",
"StationNumber": "Y12",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/tadotsu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.271088,133.756735",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/tadotsu/"
},
{
"Station_JP": "海岸寺",
"Station_EN": "Kaiganji",
"MyStation": "7",
"StationNumber": "Y13",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kaiganji.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.253932,133.729307",
"JrHpUrl": ""
},
{
"Station_JP": "詫間",
"Station_EN": "Takuma",
"MyStation": "8",
"StationNumber": "Y14",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takuma.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.220826,133.692737",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/takuma/"
},
{
"Station_JP": "高瀬",
"Station_EN": "Takase",
"MyStation": "9",
"StationNumber": "Y16",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takase.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.184083,133.711397",
"JrHpUrl": ""
},
{
"Station_JP": "本山",
"Station_EN": "Motoyama",
"MyStation": "10",
"StationNumber": "Y18",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/motoyama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.146075,133.686473",
"JrHpUrl": ""
},
{
"Station_JP": "観音寺",
"Station_EN": "Kan-onji",
"MyStation": "11",
"StationNumber": "Y19",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kan-onji.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.124478,133.655709",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kanonji/"
},
{
"Station_JP": "豊浜",
"Station_EN": "Toyohama",
"MyStation": "12",
"StationNumber": "Y20",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/toyohama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.079775,133.644206",
"JrHpUrl": ""
},
{
"Station_JP": "箕浦",
"Station_EN": "Minoura",
"MyStation": "13",
"StationNumber": "Y21",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/minoura.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.048043,133.618998",
"JrHpUrl": ""
},
{
"Station_JP": "川之江",
"Station_EN": "Kawanoe",
"MyStation": "14",
"StationNumber": "Y22",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kawanoe.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.014209,133.575856",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/kawanoe/"
},
{
"Station_JP": "伊予三島",
"Station_EN": "Iyo-Mishima",
"MyStation": "15",
"StationNumber": "Y23",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-mishima.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.979583,133.541984",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/iyomishima/"
},
{
"Station_JP": "伊予寒川",
"Station_EN": "Iyo-Sangawa",
"MyStation": "16",
"StationNumber": "Y24",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-sangawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.967803,133.500153",
"JrHpUrl": ""
},
{
"Station_JP": "伊予土居",
"Station_EN": "Iyo-Doi",
"MyStation": "17",
"StationNumber": "Y26",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-doi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.959024,133.428294",
"JrHpUrl": ""
},
{
"Station_JP": "関川",
"Station_EN": "Sekigawa",
"MyStation": "18",
"StationNumber": "Y27",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/sekigawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.955777,133.392567",
"JrHpUrl": ""
},
{
"Station_JP": "多喜浜",
"Station_EN": "Takihama",
"MyStation": "19",
"StationNumber": "Y28",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/takihama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.970564,133.32415",
"JrHpUrl": ""
},
{
"Station_JP": "新居浜",
"Station_EN": "Niihama",
"MyStation": "20",
"StationNumber": "Y29",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/niihama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.947996,133.294341",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/niihama/"
},
{
"Station_JP": "中萩",
"Station_EN": "Nakahagi",
"MyStation": "21",
"StationNumber": "Y30",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nakahagi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.926168,133.253391",
"JrHpUrl": ""
},
{
"Station_JP": "伊予西条",
"Station_EN": "Iyo-Saijo",
"MyStation": "22",
"StationNumber": "Y31",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-saijo.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.912492,133.187578",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/iyosaijo/index.html"
},
{
"Station_JP": "石鎚山",
"Station_EN": "Ishizuchiyama",
"MyStation": "23",
"StationNumber": "Y32",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/ishizuchiyama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.895746,133.157257",
"JrHpUrl": ""
},
{
"Station_JP": "伊予小松",
"Station_EN": "Iyo-Komatsu",
"MyStation": "24",
"StationNumber": "Y34",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-komatsu.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.897567,133.116717",
"JrHpUrl": ""
},
{
"Station_JP": "壬生川",
"Station_EN": "Nyūgawa",
"MyStation": "25",
"StationNumber": "Y36",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/nyugawa.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.929851,133.08552",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/nyugawa/"
},
{
"Station_JP": "伊予三芳",
"Station_EN": "Iyo-Miyoshi",
"MyStation": "26",
"StationNumber": "Y37",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-miyoshi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.954251,133.06422",
"JrHpUrl": ""
},
{
"Station_JP": "伊予桜井",
"Station_EN": "Iyo-Sakurai",
"MyStation": "27",
"StationNumber": "Y38",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-sakurai.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.012073,133.03593",
"JrHpUrl": ""
},
{
"Station_JP": "伊予富田",
"Station_EN": "Iyo-Tomita",
"MyStation": "28",
"StationNumber": "Y39",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-tomita.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.036447,133.008989",
"JrHpUrl": ""
},
{
"Station_JP": "今治",
"Station_EN": "Imabari",
"MyStation": "29",
"StationNumber": "Y40",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/imabari.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.064167,132.993655",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/imabari/"
},
{
"Station_JP": "波止浜",
"Station_EN": "Hashihama",
"MyStation": "30",
"StationNumber": "Y41",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/hashihama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.098067,132.968786",
"JrHpUrl": ""
},
{
"Station_JP": "波方",
"Station_EN": "Namikata",
"MyStation": "31",
"StationNumber": "Y42",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/namikata.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.095134,132.941888",
"JrHpUrl": ""
},
{
"Station_JP": "大西",
"Station_EN": "Ōnishi",
"MyStation": "32",
"StationNumber": "Y43",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/onishi.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.066125,132.929367",
"JrHpUrl": ""
},
{
"Station_JP": "伊予亀岡",
"Station_EN": "Iyo-kameoka",
"MyStation": "33",
"StationNumber": "Y44",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-kameoka.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.053886,132.874104",
"JrHpUrl": ""
},
{
"Station_JP": "菊間",
"Station_EN": "Kikuma",
"MyStation": "34",
"StationNumber": "Y45",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/kikuma.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.033421,132.840883",
"JrHpUrl": ""
},
{
"Station_JP": "浅海",
"Station_EN": "Asanami",
"MyStation": "35",
"StationNumber": "Y46",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/asanami.pdf",
"StationMap": "https://www.google.co.jp/maps/place/34.010028,132.802691",
"JrHpUrl": ""
},
{
"Station_JP": "大浦",
"Station_EN": "Ōura",
"MyStation": "36",
"StationNumber": "Y47",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/oura.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.999461,132.77555",
"JrHpUrl": ""
},
{
"Station_JP": "伊予北条",
"Station_EN": "Iyo-Hōjō",
"MyStation": "37",
"StationNumber": "Y48",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-hojo.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.972595,132.775097",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/iyohojo/"
},
{
"Station_JP": "粟井",
"Station_EN": "Awai",
"MyStation": "38",
"StationNumber": "Y50",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/awai.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.942729,132.77044",
"JrHpUrl": ""
},
{
"Station_JP": "堀江",
"Station_EN": "Horie",
"MyStation": "39",
"StationNumber": "Y52",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/horie.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.905558,132.753212",
"JrHpUrl": ""
},
{
"Station_JP": "伊予和気",
"Station_EN": "Iyo-Wake",
"MyStation": "40",
"StationNumber": "Y53",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/iyo-wake.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.889798,132.740708",
"JrHpUrl": ""
},
{
"Station_JP": "三津浜",
"Station_EN": "Mitsuhama",
"MyStation": "41",
"StationNumber": "Y54",
"DispNum": "2",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/mitsuhama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.861165,132.728612",
"JrHpUrl": ""
},
{
"Station_JP": "松山",
"Station_EN": "Matsuyama",
"MyStation": "42",
"StationNumber": "Y55",
"DispNum": "3",
"StationTimeTable": "http://www.jr-shikoku.co.jp/01_trainbus/jikoku/pdf/matsuyama.pdf",
"StationMap": "https://www.google.co.jp/maps/place/33.84039,132.75139",
"JrHpUrl": "http://www.jr-shikoku.co.jp/01_trainbus/kakueki/matsuyama/"
}
]

View File

@@ -0,0 +1,945 @@
[
{
"Index": 2,
"TrainNum": "363D",
"Pos": "高松",
"PosNum": 279,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 8,
"TrainNum": "5253M",
"Pos": "高松",
"PosNum": 277,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 23,
"TrainNum": "3160M",
"Pos": "高松~鬼無(下り)",
"PosNum": 286,
"delay": 0,
"Direction": 1,
"Type": "rapid:マリンライナー60号\r",
"Line": "yosan"
},
{
"Index": 68,
"TrainNum": "152M",
"Pos": "端岡",
"PosNum": 9,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 72,
"TrainNum": "5145M",
"Pos": "端岡~鴨川(下り)",
"PosNum": 14,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 86,
"TrainNum": "3157M",
"Pos": "児島予告窓",
"PosNum": 246,
"delay": "入線",
"Direction": 1,
"Type": "rapid:マリンライナー57号\r",
"Line": "yosan"
},
{
"Index": 87,
"TrainNum": "25M",
"Pos": "児島予告窓",
"PosNum": 93,
"delay": "入線",
"Direction": 1,
"Type": "express:しおかぜ25号\r",
"Line": "yosan"
},
{
"Index": 114,
"TrainNum": "3158M",
"Pos": "児島~宇多津(上り)",
"PosNum": 236,
"delay": 14,
"Direction": 0,
"Type": "rapid:マリンライナー58号\r",
"Line": "yosan"
},
{
"Index": 129,
"TrainNum": "5251M",
"Pos": "坂出~宇多津(下り)",
"PosNum": 26,
"delay": 7,
"Direction": 1,
"Type": "rapid:サンポート南風リレー\r",
"Line": "yosan"
},
{
"Index": 145,
"TrainNum": "9028M",
"Pos": "宇多津",
"PosNum": 27,
"delay": "入線",
"Direction": 0,
"Type": "express:いしづち28号\r",
"Line": "yosan"
},
{
"Index": 149,
"TrainNum": "9196R",
"Pos": "宇多津",
"PosNum": 227,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 274,
"TrainNum": "141M",
"Pos": "多度津",
"PosNum": 41,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 289,
"TrainNum": "1250M",
"Pos": "多度津予告窓",
"PosNum": 45,
"delay": "入線",
"Direction": 0,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 291,
"TrainNum": "56D",
"Pos": "多度津予告窓",
"PosNum": 303,
"delay": "入線",
"Direction": 0,
"Type": "express:南風26号\r",
"Line": "yosan"
},
{
"Index": 297,
"TrainNum": "23M",
"Pos": "詫間",
"PosNum": 53,
"delay": 0,
"Direction": 1,
"Type": "express:しおかぜ23号\r",
"Line": "yosan"
},
{
"Index": 305,
"TrainNum": "28M",
"Pos": "詫間~高瀬",
"PosNum": 55,
"delay": 22,
"Direction": 0,
"Type": "express:しおかぜ28号\r",
"Line": "yosan"
},
{
"Index": 308,
"TrainNum": "1606M",
"Pos": "高瀬",
"PosNum": 56,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 321,
"TrainNum": "143M",
"Pos": "観音寺",
"PosNum": 64,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 325,
"TrainNum": "4608M",
"Pos": "観音寺",
"PosNum": 66,
"delay": "入線",
"Direction": 0,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 341,
"TrainNum": "550M",
"Pos": "箕浦~川之江",
"PosNum": 100,
"delay": 14,
"Direction": 0,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 375,
"TrainNum": "4563M",
"Pos": "関川~多喜浜",
"PosNum": 121,
"delay": 13,
"Direction": 1,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 385,
"TrainNum": "5552M",
"Pos": "多喜浜~新居浜",
"PosNum": 125,
"delay": 4,
"Direction": 0,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 517,
"TrainNum": "30M",
"Pos": "伊予小松~壬生川",
"PosNum": 148,
"delay": 6,
"Direction": 0,
"Type": "express:しおかぜ30号\r",
"Line": "yosan"
},
{
"Index": 530,
"TrainNum": "21M",
"Pos": "伊予三芳",
"PosNum": 166,
"delay": 6,
"Direction": 1,
"Type": "express:しおかぜ21号\r",
"Line": "yosan"
},
{
"Index": 534,
"TrainNum": "561M",
"Pos": "伊予桜井",
"PosNum": 171,
"delay": 3,
"Direction": 1,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 545,
"TrainNum": "5556M",
"Pos": "伊予富田~今治",
"PosNum": 177,
"delay": 2,
"Direction": 0,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 585,
"TrainNum": "559M",
"Pos": "菊間~浅海",
"PosNum": 199,
"delay": 3,
"Direction": 1,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 600,
"TrainNum": "558M",
"Pos": "伊予北条",
"PosNum": 206,
"delay": 3,
"Direction": 0,
"Type": "normal",
"Line": "yosan"
},
{
"Index": 614,
"TrainNum": "1042M",
"Pos": "粟井~堀江",
"PosNum": 212,
"delay": 1,
"Direction": 0,
"Type": "express:いしづち102号\r",
"Line": "yosan"
},
{
"Index": 3,
"TrainNum": "363D",
"Pos": "高松",
"PosNum": 279,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 5,
"TrainNum": "5253M",
"Pos": "高松",
"PosNum": 277,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 24,
"TrainNum": "4360D",
"Pos": "屋島~八栗口",
"PosNum": 412,
"delay": 2,
"Direction": 0,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 25,
"TrainNum": "361D",
"Pos": "八栗口",
"PosNum": 514,
"delay": 2,
"Direction": 1,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 55,
"TrainNum": "5359D",
"Pos": "鶴羽",
"PosNum": 521,
"delay": 2,
"Direction": 1,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 56,
"TrainNum": "3027D",
"Pos": "鶴羽~丹生",
"PosNum": 522,
"delay": 2,
"Direction": 1,
"Type": "express:うずしお27号\r",
"Line": "koutoku"
},
{
"Index": 68,
"TrainNum": "366D",
"Pos": "三本松",
"PosNum": 435,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 96,
"TrainNum": "3028D",
"Pos": "阿波大宮~板野",
"PosNum": 459,
"delay": 0,
"Direction": 0,
"Type": "express:うずしお28号\r",
"Line": "koutoku"
},
{
"Index": 100,
"TrainNum": "355D",
"Pos": "板野~板東",
"PosNum": 465,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 103,
"TrainNum": "976D",
"Pos": "鳴門",
"PosNum": 472,
"delay": 4,
"Direction": 0,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 117,
"TrainNum": "364D",
"Pos": "池谷~勝瑞",
"PosNum": 480,
"delay": 1,
"Direction": 0,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 130,
"TrainNum": "480D",
"Pos": "蔵本",
"PosNum": 605,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 136,
"TrainNum": "979D",
"Pos": "佐古",
"PosNum": 494,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 148,
"TrainNum": "5481D",
"Pos": "石井",
"PosNum": 611,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "koutoku"
},
{
"Index": 7,
"TrainNum": "979D",
"Pos": "佐古",
"PosNum": 494,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "tokushima"
},
{
"Index": 19,
"TrainNum": "480D",
"Pos": "蔵本",
"PosNum": 605,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "tokushima"
},
{
"Index": 25,
"TrainNum": "5481D",
"Pos": "石井",
"PosNum": 611,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "tokushima"
},
{
"Index": 37,
"TrainNum": "4482D",
"Pos": "鴨島",
"PosNum": 623,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "tokushima"
},
{
"Index": 49,
"TrainNum": "5479D",
"Pos": "学",
"PosNum": 629,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "tokushima"
},
{
"Index": 65,
"TrainNum": "484D",
"Pos": "穴吹",
"PosNum": 645,
"delay": "入線",
"Direction": 0,
"Type": "normal",
"Line": "tokushima"
},
{
"Index": 85,
"TrainNum": "475D",
"Pos": "江口~阿波加茂",
"PosNum": 661,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "tokushima"
},
{
"Index": 99,
"TrainNum": "5486D",
"Pos": "佃予告窓",
"PosNum": 669,
"delay": "入線",
"Direction": 0,
"Type": "normal",
"Line": "tokushima"
},
{
"Index": 100,
"TrainNum": "488D",
"Pos": "佃予告窓",
"PosNum": 670,
"delay": "入線",
"Direction": 0,
"Type": "normal",
"Line": "tokushima"
},
{
"Index": 0,
"TrainNum": "21M",
"Pos": "",
"PosNum": 228,
"delay": "入線",
"Direction": 1,
"Type": "express:しおかぜ21号\r",
"Line": "uwajima"
},
{
"Index": 1,
"TrainNum": "559M",
"Pos": "",
"PosNum": 227,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 10,
"TrainNum": "4555M",
"Pos": "松山",
"PosNum": 243,
"delay": 1,
"Direction": 1,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 12,
"TrainNum": "925D",
"Pos": "松山",
"PosNum": 241,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 22,
"TrainNum": "19E",
"Pos": "市坪",
"PosNum": 18,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 37,
"TrainNum": "9046E",
"Pos": "",
"PosNum": 35,
"delay": "入線",
"Direction": 0,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 38,
"TrainNum": "560E",
"Pos": "",
"PosNum": 34,
"delay": "入線",
"Direction": 0,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 43,
"TrainNum": "1077D",
"Pos": "向井原~伊予中山",
"PosNum": 49,
"delay": 0,
"Direction": 1,
"Type": "express:宇和海27号\r",
"Line": "uwajima"
},
{
"Index": 51,
"TrainNum": "4926D",
"Pos": "伊予上灘~伊予長浜",
"PosNum": 197,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 54,
"TrainNum": "4655D",
"Pos": "伊予立川~内子",
"PosNum": 68,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 69,
"TrainNum": "1078D",
"Pos": "新谷~伊予大洲",
"PosNum": 87,
"delay": 4,
"Direction": 0,
"Type": "express:宇和海28号\r",
"Line": "uwajima"
},
{
"Index": 83,
"TrainNum": "923D",
"Pos": "千丈",
"PosNum": 113,
"delay": 4,
"Direction": 1,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 86,
"TrainNum": "4928D",
"Pos": "八幡浜",
"PosNum": 123,
"delay": "入線",
"Direction": 0,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 89,
"TrainNum": "4659D",
"Pos": "八幡浜",
"PosNum": 124,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 101,
"TrainNum": "1075D",
"Pos": "卯之町~下宇和",
"PosNum": 150,
"delay": 0,
"Direction": 1,
"Type": "express:宇和海25号\r",
"Line": "uwajima"
},
{
"Index": 110,
"TrainNum": "4825D",
"Pos": "北宇和島~宮野下方予告窓",
"PosNum": 185,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "uwajima"
},
{
"Index": 116,
"TrainNum": "1080D",
"Pos": "宇和島",
"PosNum": 188,
"delay": "入線",
"Direction": 0,
"Type": "express:宇和海30号\r",
"Line": "uwajima"
},
{
"Index": 15,
"TrainNum": "1250M",
"Pos": "善通寺",
"PosNum": 24,
"delay": 1,
"Direction": 0,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 18,
"TrainNum": "5249M",
"Pos": "善通寺~琴平",
"PosNum": 29,
"delay": 1,
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 24,
"TrainNum": "4255D",
"Pos": "琴平",
"PosNum": 34,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 44,
"TrainNum": "475D",
"Pos": "佃予告窓",
"PosNum": 68,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 45,
"TrainNum": "5479D",
"Pos": "佃予告窓",
"PosNum": 69,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 55,
"TrainNum": "5486D",
"Pos": "阿波池田",
"PosNum": 82,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 68,
"TrainNum": "51D",
"Pos": "阿波川口",
"PosNum": 98,
"delay": 5,
"Direction": 1,
"Type": "express:南風21号\r",
"Line": "dosan"
},
{
"Index": 70,
"TrainNum": "56D",
"Pos": "阿波川口~小歩危",
"PosNum": 101,
"delay": 4,
"Direction": 0,
"Type": "express:南風26号\r",
"Line": "dosan"
},
{
"Index": 73,
"TrainNum": "4253D",
"Pos": "小歩危~大歩危",
"PosNum": 107,
"delay": 4,
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 109,
"TrainNum": "4256D",
"Pos": "新改~土佐山田",
"PosNum": 164,
"delay": 7,
"Direction": 0,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 113,
"TrainNum": "4257D",
"Pos": "土佐山田",
"PosNum": 169,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 117,
"TrainNum": "5885D",
"Pos": "後免予告窓",
"PosNum": 176,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 118,
"TrainNum": "5887D",
"Pos": "後免予告窓",
"PosNum": 177,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 119,
"TrainNum": "5883D",
"Pos": "なはり方~後免",
"PosNum": 178,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 125,
"TrainNum": "753D",
"Pos": "後免",
"PosNum": 179,
"delay": 5,
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 127,
"TrainNum": "49D",
"Pos": "後免~土佐大津",
"PosNum": 189,
"delay": 7,
"Direction": 1,
"Type": "express:南風19号\r",
"Line": "dosan"
},
{
"Index": 129,
"TrainNum": "58D",
"Pos": "土佐大津",
"PosNum": 191,
"delay": 4,
"Direction": 0,
"Type": "express:南風28号\r",
"Line": "dosan"
},
{
"Index": 132,
"TrainNum": "3759A",
"Pos": "土佐一宮予告窓",
"PosNum": 197,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 133,
"TrainNum": "289A",
"Pos": "土佐一宮予告窓",
"PosNum": 198,
"delay": "入線",
"Direction": 1,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 141,
"TrainNum": "756D",
"Pos": "薊野",
"PosNum": 210,
"delay": 1,
"Direction": 0,
"Type": "normal",
"Line": "dosan"
},
{
"Index": 2,
"TrainNum": "4758D",
"Pos": "高知~旭",
"PosNum": 258,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "kubokawa"
},
{
"Index": 12,
"TrainNum": "751D",
"Pos": "伊野~日下",
"PosNum": 277,
"delay": 0,
"Direction": 1,
"Type": "normal",
"Line": "kubokawa"
},
{
"Index": 27,
"TrainNum": "2081D",
"Pos": "斗賀野",
"PosNum": 306,
"delay": 0,
"Direction": 1,
"Type": "express:あしずり11号\r",
"Line": "kubokawa"
},
{
"Index": 31,
"TrainNum": "4760D",
"Pos": "吾桑~多ノ郷",
"PosNum": 314,
"delay": 0,
"Direction": 0,
"Type": "normal",
"Line": "kubokawa"
},
{
"Index": 38,
"TrainNum": "2004D",
"Pos": "須崎~土佐久礼",
"PosNum": 327,
"delay": 0,
"Direction": 0,
"Type": "express:しまんと4号\r",
"Line": "kubokawa"
},
{
"GetDateTime": "2026/05/01 19:42:21"
}
]

View File

@@ -0,0 +1,946 @@
[
{
"Station_JP": "高知商業前",
"Station_EN": "Kōchi-Shōgyōmae"
},
{
"Station_JP": "古高松南",
"Station_EN": "Furutakamatsu-Minami"
},
{
"Station_JP": "高松",
"Station_EN": "Takamatsu"
},
{
"Station_JP": "香西",
"Station_EN": "Kōzai"
},
{
"Station_JP": "鬼無",
"Station_EN": "Kinashi"
},
{
"Station_JP": "端岡",
"Station_EN": "Hashioka"
},
{
"Station_JP": "国分",
"Station_EN": "Kokubu"
},
{
"Station_JP": "讃岐府中",
"Station_EN": "Sanuki-Fuchū"
},
{
"Station_JP": "鴨川",
"Station_EN": "Kamogawa"
},
{
"Station_JP": "八十場",
"Station_EN": "Yasoba"
},
{
"Station_JP": "坂出",
"Station_EN": "Sakaide"
},
{
"Station_JP": "宇多津",
"Station_EN": "Utazu"
},
{
"Station_JP": "丸亀",
"Station_EN": "Marugame"
},
{
"Station_JP": "讃岐塩屋",
"Station_EN": "Sanuki-Shioya"
},
{
"Station_JP": "多度津",
"Station_EN": "Tadotsu"
},
{
"Station_JP": "海岸寺",
"Station_EN": "Kaiganji"
},
{
"Station_JP": "詫間",
"Station_EN": "Takuma"
},
{
"Station_JP": "みの",
"Station_EN": "Mino"
},
{
"Station_JP": "高瀬",
"Station_EN": "Takase"
},
{
"Station_JP": "比地大",
"Station_EN": "Hijidai"
},
{
"Station_JP": "本山",
"Station_EN": "Motoyama"
},
{
"Station_JP": "観音寺",
"Station_EN": "Kan-onji"
},
{
"Station_JP": "豊浜",
"Station_EN": "Toyohama"
},
{
"Station_JP": "箕浦",
"Station_EN": "Minoura"
},
{
"Station_JP": "川之江",
"Station_EN": "Kawanoe"
},
{
"Station_JP": "伊予三島",
"Station_EN": "Iyo-Mishima"
},
{
"Station_JP": "伊予寒川",
"Station_EN": "Iyo-Sangawa"
},
{
"Station_JP": "赤星",
"Station_EN": "Akaboshi"
},
{
"Station_JP": "伊予土居",
"Station_EN": "Iyo-Doi"
},
{
"Station_JP": "関川",
"Station_EN": "Sekigawa"
},
{
"Station_JP": "多喜浜",
"Station_EN": "Takihama"
},
{
"Station_JP": "新居浜",
"Station_EN": "Niihama"
},
{
"Station_JP": "中萩",
"Station_EN": "Nakahagi"
},
{
"Station_JP": "伊予西条",
"Station_EN": "Iyo-Saijo"
},
{
"Station_JP": "石鎚山",
"Station_EN": "Ishizuchiyama"
},
{
"Station_JP": "伊予氷見",
"Station_EN": "Iyo-Himi"
},
{
"Station_JP": "伊予小松",
"Station_EN": "Iyo-Komatsu"
},
{
"Station_JP": "玉之江",
"Station_EN": "Tamanoe"
},
{
"Station_JP": "壬生川",
"Station_EN": "Nyūgawa"
},
{
"Station_JP": "伊予三芳",
"Station_EN": "Iyo-Miyoshi"
},
{
"Station_JP": "伊予桜井",
"Station_EN": "Iyo-Sakurai"
},
{
"Station_JP": "伊予富田",
"Station_EN": "Iyo-Tomita"
},
{
"Station_JP": "今治",
"Station_EN": "Imabari"
},
{
"Station_JP": "波止浜",
"Station_EN": "Hashihama"
},
{
"Station_JP": "波方",
"Station_EN": "Namikata"
},
{
"Station_JP": "大西",
"Station_EN": "Ōnishi"
},
{
"Station_JP": "伊予亀岡",
"Station_EN": "Iyo-kameoka"
},
{
"Station_JP": "菊間",
"Station_EN": "Kikuma"
},
{
"Station_JP": "浅海",
"Station_EN": "Asanami"
},
{
"Station_JP": "大浦",
"Station_EN": "Ōura"
},
{
"Station_JP": "伊予北条",
"Station_EN": "Iyo-Hōjō"
},
{
"Station_JP": "柳原",
"Station_EN": "Yanagihara"
},
{
"Station_JP": "粟井",
"Station_EN": "Awai"
},
{
"Station_JP": "光洋台",
"Station_EN": "Kōyōdai"
},
{
"Station_JP": "堀江",
"Station_EN": "Horie"
},
{
"Station_JP": "伊予和気",
"Station_EN": "Iyo-Wake"
},
{
"Station_JP": "三津浜",
"Station_EN": "Mitsuhama"
},
{
"Station_JP": "松山",
"Station_EN": "Matsuyama"
},
{
"Station_JP": "松山",
"Station_EN": "Matsuyama"
},
{
"Station_JP": "市坪",
"Station_EN": "Ichitsubo"
},
{
"Station_JP": "北伊予",
"Station_EN": "Kita-Iyo"
},
{
"Station_JP": "南伊予",
"Station_EN": "Minami-Iyo"
},
{
"Station_JP": "伊予横田",
"Station_EN": "Iyo-Yokota"
},
{
"Station_JP": "鳥ノ木",
"Station_EN": "Torinoki"
},
{
"Station_JP": "伊予市",
"Station_EN": "Iyoshi"
},
{
"Station_JP": "向井原",
"Station_EN": "Mukaibara"
},
{
"Station_JP": "高野川",
"Station_EN": "Kōnokawa"
},
{
"Station_JP": "伊予上灘",
"Station_EN": "Iyo-Kaminada"
},
{
"Station_JP": "下灘",
"Station_EN": "Shimonada"
},
{
"Station_JP": "串",
"Station_EN": "Kushi"
},
{
"Station_JP": "喜多灘",
"Station_EN": "Kitanada"
},
{
"Station_JP": "伊予長浜",
"Station_EN": "Iyo-Nagahama"
},
{
"Station_JP": "伊予出石",
"Station_EN": "Iyo-Izushi"
},
{
"Station_JP": "伊予白滝",
"Station_EN": "Iyo-Shirataki"
},
{
"Station_JP": "八多喜",
"Station_EN": "Hataki"
},
{
"Station_JP": "春賀",
"Station_EN": "Haruka"
},
{
"Station_JP": "五郎",
"Station_EN": "Gorō"
},
{
"Station_JP": "伊予大洲",
"Station_EN": "Iyo-Ōzu"
},
{
"Station_JP": "西大洲",
"Station_EN": "Nishi-Ōzu"
},
{
"Station_JP": "伊予平野",
"Station_EN": "Iyo-Hirano"
},
{
"Station_JP": "千丈",
"Station_EN": "Senjō"
},
{
"Station_JP": "八幡浜",
"Station_EN": "Yawatahama"
},
{
"Station_JP": "双岩",
"Station_EN": "Futaiwa"
},
{
"Station_JP": "伊予石城",
"Station_EN": "Iyo-Iwaki"
},
{
"Station_JP": "上宇和",
"Station_EN": "Kami-Uwa"
},
{
"Station_JP": "卯之町",
"Station_EN": "Unomachi"
},
{
"Station_JP": "下宇和",
"Station_EN": "Shimo-Uwa"
},
{
"Station_JP": "立間",
"Station_EN": "Tachima"
},
{
"Station_JP": "伊予吉田",
"Station_EN": "Iyo-Yoshida"
},
{
"Station_JP": "高光",
"Station_EN": "Takamitsu"
},
{
"Station_JP": "北宇和島",
"Station_EN": "Kita-Uwajima"
},
{
"Station_JP": "宇和島",
"Station_EN": "Uwajima"
},
{
"Station_JP": "佐古",
"Station_EN": "Sako"
},
{
"Station_JP": "蔵本",
"Station_EN": "Kuramoto"
},
{
"Station_JP": "鮎喰",
"Station_EN": "Akui"
},
{
"Station_JP": "府中",
"Station_EN": "Kō"
},
{
"Station_JP": "石井",
"Station_EN": "Ishii"
},
{
"Station_JP": "下浦",
"Station_EN": "Shimoura"
},
{
"Station_JP": "牛島",
"Station_EN": "Ushinoshima"
},
{
"Station_JP": "麻植塚",
"Station_EN": "Oezuka"
},
{
"Station_JP": "鴨島",
"Station_EN": "Kamojima"
},
{
"Station_JP": "西麻植",
"Station_EN": "Nishi-Oe"
},
{
"Station_JP": "阿波川島",
"Station_EN": "Awa-Kawashima"
},
{
"Station_JP": "学",
"Station_EN": "Gaku"
},
{
"Station_JP": "山瀬",
"Station_EN": "Yamase"
},
{
"Station_JP": "阿波山川",
"Station_EN": "Awa-Yamakawa"
},
{
"Station_JP": "川田",
"Station_EN": "Kawata"
},
{
"Station_JP": "穴吹",
"Station_EN": "Anabuki"
},
{
"Station_JP": "小島",
"Station_EN": "Oshima"
},
{
"Station_JP": "貞光",
"Station_EN": "Sadamitsu"
},
{
"Station_JP": "阿波半田",
"Station_EN": "Awa-Handa"
},
{
"Station_JP": "江口",
"Station_EN": "Eguchi"
},
{
"Station_JP": "三加茂",
"Station_EN": "Mikamo"
},
{
"Station_JP": "阿波加茂",
"Station_EN": "Awa-Kamo"
},
{
"Station_JP": "辻",
"Station_EN": "Tsuji"
},
{
"Station_JP": "高松",
"Station_EN": "Takamatsu"
},
{
"Station_JP": "昭和町",
"Station_EN": "Shōwachō"
},
{
"Station_JP": "栗林公園北口",
"Station_EN": "Ritsurinkōen-Kitaguchi"
},
{
"Station_JP": "栗林",
"Station_EN": "Ritsurin"
},
{
"Station_JP": "木太町",
"Station_EN": "Kitachō"
},
{
"Station_JP": "屋島",
"Station_EN": "Yashima"
},
{
"Station_JP": "八栗口",
"Station_EN": "Yakuriguchi"
},
{
"Station_JP": "讃岐牟礼",
"Station_EN": "Sanuki-Mure"
},
{
"Station_JP": "志度",
"Station_EN": "Shido"
},
{
"Station_JP": "オレンジタウン",
"Station_EN": "Orange-Town"
},
{
"Station_JP": "造田",
"Station_EN": "Zōda"
},
{
"Station_JP": "神前",
"Station_EN": "Kanzaki"
},
{
"Station_JP": "讃岐津田",
"Station_EN": "Sanuki-Tsuda"
},
{
"Station_JP": "鶴羽",
"Station_EN": "Tsuruwa"
},
{
"Station_JP": "丹生",
"Station_EN": "Nibu"
},
{
"Station_JP": "三本松",
"Station_EN": "Sambommatsu"
},
{
"Station_JP": "讃岐白鳥",
"Station_EN": "Sanuki-Shirotori"
},
{
"Station_JP": "引田",
"Station_EN": "Hiketa"
},
{
"Station_JP": "讃岐相生",
"Station_EN": "Sanuki-Aioi"
},
{
"Station_JP": "阿波大宮",
"Station_EN": "Awa-Ōmiya"
},
{
"Station_JP": "板野",
"Station_EN": "Itano"
},
{
"Station_JP": "阿波川端",
"Station_EN": "Awa-Kawabata"
},
{
"Station_JP": "板東",
"Station_EN": "Bandō"
},
{
"Station_JP": "池谷",
"Station_EN": "Ikenotani"
},
{
"Station_JP": "勝瑞",
"Station_EN": "Shōzui"
},
{
"Station_JP": "吉成",
"Station_EN": "Yoshinari"
},
{
"Station_JP": "佐古",
"Station_EN": "Sako"
},
{
"Station_JP": "徳島",
"Station_EN": "Tokushima"
},
{
"Station_JP": "多度津",
"Station_EN": "Tadotsu"
},
{
"Station_JP": "金蔵寺",
"Station_EN": "Konzōji"
},
{
"Station_JP": "善通寺",
"Station_EN": "Zentsūji"
},
{
"Station_JP": "琴平",
"Station_EN": "Kotohira"
},
{
"Station_JP": "塩入",
"Station_EN": "Shioiri"
},
{
"Station_JP": "黒川",
"Station_EN": "Kurokawa"
},
{
"Station_JP": "讃岐財田",
"Station_EN": "Sanuki-Saida"
},
{
"Station_JP": "坪尻",
"Station_EN": "Tsubojiri"
},
{
"Station_JP": "箸蔵",
"Station_EN": "Hashikura"
},
{
"Station_JP": "佃",
"Station_EN": "Tsukuda"
},
{
"Station_JP": "阿波池田",
"Station_EN": "Awa-Ikeda"
},
{
"Station_JP": "三縄",
"Station_EN": "Minawa"
},
{
"Station_JP": "祖谷口",
"Station_EN": "Iyaguchi"
},
{
"Station_JP": "阿波川口",
"Station_EN": "Awa-Kawaguchi"
},
{
"Station_JP": "小歩危",
"Station_EN": "Koboke"
},
{
"Station_JP": "大歩危",
"Station_EN": "Ōboke"
},
{
"Station_JP": "土佐岩原",
"Station_EN": "Tosa-Iwahara"
},
{
"Station_JP": "豊永",
"Station_EN": "Toyonaga"
},
{
"Station_JP": "大田口",
"Station_EN": "Ōtaguchi"
},
{
"Station_JP": "土佐穴内",
"Station_EN": "Tosa-Ananai"
},
{
"Station_JP": "大杉",
"Station_EN": "Ōsugi"
},
{
"Station_JP": "土佐北川",
"Station_EN": "Tosa-Kitagawa"
},
{
"Station_JP": "角茂谷",
"Station_EN": "Kakumodani"
},
{
"Station_JP": "繁藤",
"Station_EN": "Shigetō"
},
{
"Station_JP": "新改",
"Station_EN": "Shingai"
},
{
"Station_JP": "土佐山田",
"Station_EN": "Tosa-Yamada"
},
{
"Station_JP": "山田西町",
"Station_EN": "Yamadanishimachi"
},
{
"Station_JP": "土佐長岡",
"Station_EN": "Tosa-Nagaoka"
},
{
"Station_JP": "後免",
"Station_EN": "Gomen"
},
{
"Station_JP": "土佐大津",
"Station_EN": "Tosa-Ōtsu"
},
{
"Station_JP": "布師田",
"Station_EN": "Nunoshida"
},
{
"Station_JP": "土佐一宮",
"Station_EN": "Tosa-Ikku"
},
{
"Station_JP": "薊野",
"Station_EN": "Azōno"
},
{
"Station_JP": "高知",
"Station_EN": "Kōchi"
},
{
"Station_JP": "入明",
"Station_EN": "Iriake"
},
{
"Station_JP": "円行寺口",
"Station_EN": "Engyōjiguchi"
},
{
"Station_JP": "旭",
"Station_EN": "Asahi"
},
{
"Station_JP": "朝倉",
"Station_EN": "Asakura"
},
{
"Station_JP": "枝川",
"Station_EN": "Edagawa"
},
{
"Station_JP": "伊野",
"Station_EN": "Ino"
},
{
"Station_JP": "波川",
"Station_EN": "Hakawa"
},
{
"Station_JP": "小村神社前",
"Station_EN": "Omurajinjamae"
},
{
"Station_JP": "日下",
"Station_EN": "Kusaka"
},
{
"Station_JP": "岡花",
"Station_EN": "Okabana"
},
{
"Station_JP": "土佐加茂",
"Station_EN": "Tosa-Kamo"
},
{
"Station_JP": "西佐川",
"Station_EN": "Nishi-Sakawa"
},
{
"Station_JP": "佐川",
"Station_EN": "Sakawa"
},
{
"Station_JP": "襟野々",
"Station_EN": "Erinono"
},
{
"Station_JP": "斗賀野",
"Station_EN": "Togano"
},
{
"Station_JP": "吾桑",
"Station_EN": "Asō"
},
{
"Station_JP": "多ノ郷",
"Station_EN": "Ōnogō"
},
{
"Station_JP": "大間",
"Station_EN": "Ōma"
},
{
"Station_JP": "須崎",
"Station_EN": "Susaki"
},
{
"Station_JP": "土佐新荘",
"Station_EN": "Tosa-Shinjō"
},
{
"Station_JP": "安和",
"Station_EN": "Awa"
},
{
"Station_JP": "土佐久礼",
"Station_EN": "Tosa-Kure"
},
{
"Station_JP": "影野",
"Station_EN": "Kageno"
},
{
"Station_JP": "六反地",
"Station_EN": "Rokutanji"
},
{
"Station_JP": "仁井田",
"Station_EN": "Niida"
},
{
"Station_JP": "窪川",
"Station_EN": "Kubokawa"
},
{
"Station_JP": "児島",
"Station_EN": "Kojima"
},
{
"Station_JP": "上の町",
"Station_EN": "KaminochŌ"
},
{
"Station_JP": "木見",
"Station_EN": "Kimi"
},
{
"Station_JP": "植松",
"Station_EN": "Uematsu"
},
{
"Station_JP": "茶屋町",
"Station_EN": "Chayamachi"
},
{
"Station_JP": "久々原",
"Station_EN": "Kuguhara"
},
{
"Station_JP": "早島",
"Station_EN": "kojima"
},
{
"Station_JP": "備中箕島",
"Station_EN": "Bitchū-Mishima"
},
{
"Station_JP": "妹尾",
"Station_EN": "Senoo"
},
{
"Station_JP": "備前西市",
"Station_EN": "Bizen-Nishiichi"
},
{
"Station_JP": "大元",
"Station_EN": "Ōmoto"
},
{
"Station_JP": "岡山",
"Station_EN": "Okayama"
},
{
"Station_JP": "新谷",
"Station_EN": "Niiya"
},
{
"Station_JP": "伊予大洲",
"Station_EN": "Iyo-Ōzu"
},
{
"Station_JP": "伊予平野",
"Station_EN": "Iyo-Hirano"
},
{
"Station_JP": "内子",
"Station_EN": "Uchiko"
},
{
"Station_JP": "五十崎",
"Station_EN": "Ikazaki"
},
{
"Station_JP": "喜多山",
"Station_EN": "Kitayama"
},
{
"Station_JP": "伊予立川",
"Station_EN": "Iyo-Tachikawa"
},
{
"Station_JP": "伊予大平",
"Station_EN": "Iyo-Ōhira"
},
{
"Station_JP": "伊予中山",
"Station_EN": "Iyo-Nakayama"
},
{
"Station_JP": "中村",
"Station_EN": "Nakamura"
},
{
"Station_JP": "土佐入野",
"Station_EN": "Tosa-irino"
},
{
"Station_JP": "土佐上川口",
"Station_EN": "Tosa-kamikawaguchi"
},
{
"Station_JP": "土佐佐賀",
"Station_EN": "Tosasaga"
},
{
"Station_JP": "浮鞭",
"Station_EN": "Ukibuchi"
},
{
"Station_JP": "安芸",
"Station_EN": "Aki"
},
{
"Station_JP": "阿波大谷",
"Station_EN": "Awa-Ōtani"
},
{
"Station_JP": "立道",
"Station_EN": "Tatsumichi"
},
{
"Station_JP": "教会前",
"Station_EN": "Kyōkaimae"
},
{
"Station_JP": "金比羅前",
"Station_EN": "Kompiramae"
},
{
"Station_JP": "撫養",
"Station_EN": "Muya"
},
{
"Station_JP": "鳴門",
"Station_EN": "Naruto"
}
]

View File

@@ -0,0 +1,292 @@
[
{
"1M": {
"1001M": "いしづち1号"
}
},
{
"1001M": {
"1M": "しおかぜ1号"
}
},
{
"3M": {
"1003M": "いしづち3号"
}
},
{
"1003M": {
"3M": "しおかぜ3号"
}
},
{
"5M": {
"1005M": "いしづち5号"
}
},
{
"1005M": {
"5M": "しおかぜ5号"
}
},
{
"7M": {
"1007M": "いしづち7号"
}
},
{
"1007M": {
"7M": "しおかぜ7号"
}
},
{
"9M": {
"1009M": "いしづち9号"
}
},
{
"1009M": {
"9M": "しおかぜ9号"
}
},
{
"11M": {
"1011M": "いしづち11号"
}
},
{
"1011M": {
"11M": "しおかぜ11号"
}
},
{
"13M": {
"1013M": "いしづち13号"
}
},
{
"1013M": {
"13M": "しおかぜ13号"
}
},
{
"15M": {
"1015M": "いしづち15号"
}
},
{
"1015M": {
"15M": "しおかぜ15号"
}
},
{
"17M": {
"1017M": "いしづち17号"
}
},
{
"1017M": {
"17M": "しおかぜ17号"
}
},
{
"19M": {
"1019M": "いしづち19号"
}
},
{
"1019M": {
"19M": "しおかぜ19号"
}
},
{
"21M": {
"1021M": "いしづち21号"
}
},
{
"1021M": {
"21M": "しおかぜ21号"
}
},
{
"23M": {
"1023M": "いしづち23号"
}
},
{
"1023M": {
"23M": "しおかぜ23号"
}
},
{
"25M": {
"1025M": "いしづち25号"
}
},
{
"1025M": {
"25M": "しおかぜ25号"
}
},
{
"27M": {
"1027M": "いしづち27号"
}
},
{
"1027M": {
"27M": "しおかぜ27号"
}
},
{
"29M": {
"1029M": "いしづち29号"
}
},
{
"1029M": {
"29M": "しおかぜ29号"
}
},
{
"4M": {
"1004M": "いしづち4号"
}
},
{
"1004M": {
"4M": "しおかぜ4号"
}
},
{
"6M": {
"1006M": "いしづち6号"
}
},
{
"1006M": {
"6M": "しおかぜ6号"
}
},
{
"8M": {
"1008M": "いしづち8号"
}
},
{
"1008M": {
"8M": "しおかぜ8号"
}
},
{
"10M": {
"1010M": "いしづち10号"
}
},
{
"1010M": {
"10M": "しおかぜ10号"
}
},
{
"12M": {
"1012M": "いしづち12号"
}
},
{
"1012M": {
"12M": "しおかぜ12号"
}
},
{
"14M": {
"1014M": "いしづち14号"
}
},
{
"1014M": {
"14M": "しおかぜ14号"
}
},
{
"16M": {
"1016M": "いしづち16号"
}
},
{
"1016M": {
"16M": "しおかぜ16号"
}
},
{
"18M": {
"1018M": "いしづち18号"
}
},
{
"1018M": {
"18M": "しおかぜ18号"
}
},
{
"20M": {
"1020M": "いしづち20号"
}
},
{
"1020M": {
"20M": "しおかぜ20号"
}
},
{
"22M": {
"1022M": "いしづち22号"
}
},
{
"1022M": {
"22M": "しおかぜ22号"
}
},
{
"24M": {
"1024M": "いしづち24号"
}
},
{
"1024M": {
"24M": "しおかぜ24号"
}
},
{
"26M": {
"1026M": "いしづち26号"
}
},
{
"1026M": {
"26M": "しおかぜ26号"
}
},
{
"28M": {
"1028M": "いしづち28号"
}
},
{
"1028M": {
"28M": "しおかぜ28号"
}
},
{
"30M": {
"1030M": "いしづち30号"
}
},
{
"1030M": {
"30M": "しおかぜ30号"
}
}
]

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,94 @@
import { AS } from '../../storageControl';
import { STORAGE_KEYS } from '../../constants/storage';
import { TrainEntry } from './webviewXhrInterceptor';
export type TrainSnapshot = {
/** ms elapsed from recording start */
t: number;
trains: TrainEntry[];
};
/** 録画のメタ情報(一覧表示用・軽量) */
export type RecordingMeta = {
id: string;
recordedAt: string;
durationMs: number;
snapshotCount: number;
};
/** フル録画データ(再生時のみロード) */
export type TrainRecording = {
id: string;
recordedAt: string;
durationMs: number;
snapshots: TrainSnapshot[];
};
const parse = <T>(raw: string | null | boolean): T | null => {
if (!raw) return null;
try {
return JSON.parse(typeof raw === 'string' ? raw : JSON.stringify(raw)) as T;
} catch {
return null;
}
};
/** 録画IDを生成recordedAt ISO文字列からファイル名に使えるIDへ */
export const generateRecordingId = (recordedAt: string) =>
recordedAt.replace(/[:.]/g, '-');
/** 録画インデックス(メタ一覧)を読み込む */
export const loadRecordingList = async (): Promise<RecordingMeta[]> => {
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX).catch(() => null);
return parse<RecordingMeta[]>(raw) ?? [];
};
/** フル録画データをIDで読み込む */
export const loadRecordingById = async (id: string): Promise<TrainRecording | null> => {
const raw = await AS.getItem((STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as any).catch(() => null);
return parse<TrainRecording>(raw);
};
/** 録画を保存してインデックスに追加する */
export const saveRecording = async (recording: TrainRecording): Promise<void> => {
await AS.setItem(
(STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + recording.id) as any,
JSON.stringify(recording),
);
const list = await loadRecordingList();
const meta: RecordingMeta = {
id: recording.id,
recordedAt: recording.recordedAt,
durationMs: recording.durationMs,
snapshotCount: recording.snapshots.length,
};
// 先頭に追加新しい順、同IDは重複排除
const newList = [meta, ...list.filter((m) => m.id !== recording.id)];
await AS.setItem(STORAGE_KEYS.MOCK_RECORDINGS_INDEX, JSON.stringify(newList));
};
/** 録画をIDで削除する */
export const deleteRecordingById = async (id: string): Promise<void> => {
await AS.removeItem((STORAGE_KEYS.MOCK_RECORDING_DATA_PREFIX + id) as any).catch(() => {});
const list = await loadRecordingList();
await AS.setItem(
STORAGE_KEYS.MOCK_RECORDINGS_INDEX,
JSON.stringify(list.filter((m) => m.id !== id)),
);
};
/**
* 旧フォーマットMOCK_RECORDINGからマイグレーション。
* 新インデックスが空の場合のみ実行する。
*/
export const migrateOldRecording = async (): Promise<void> => {
const list = await loadRecordingList();
if (list.length > 0) return; // 既に移行済み
const raw = await AS.getItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => null);
const old = parse<Omit<TrainRecording, 'id'>>(raw);
if (!old) return;
const id = generateRecordingId(old.recordedAt);
await saveRecording({ ...old, id });
await AS.removeItem(STORAGE_KEYS.MOCK_RECORDING).catch(() => {});
};

View File

@@ -0,0 +1,218 @@
/**
* WebView XHR Interceptor for JR Shikoku official train position site
*
* The official site (train.jr-shikoku.co.jp/sp.html) makes XMLHttpRequest calls
* to its own /g?arg1=...&arg2=... API. This module generates a JavaScript string
* that, when injected into the WebView before page load, overrides XMLHttpRequest
* so those calls can be served with mock/alternative data.
*
* API endpoint reference:
* Static (loaded once on startup):
* GET /g?arg1=lang&arg2=lang - i18n strings
* GET /g?arg1=station&arg2=traintimeinfo&arg3=dia - station timetable info
* GET /g?arg1=train&arg2=trainpare - train pair info (Shinkansen links)
* GET /g?arg1=station&arg2=between - between-station hidden stops
* GET /g?arg1=station&arg2=othercolor - colour overrides for other lines
* GET /g?arg1=train&arg2=holidaydisp - holiday train display rules
* GET /g?arg1=train&arg2=sightseeingtrainname - sightseeing train names
* GET /g?arg1=station&arg2=cross - cross-line station data
* GET /g?arg1=station&arg2=otherline - other-line station data
* GET /g?arg1=line&arg2=train_lang - line/train i18n names
* GET /g?arg1=train&arg2=ignore - trains to hide from display
* GET /g?arg1=station&arg2={line} - station list for a line
* line values: yosan | koutoku | tokushima | dosan | dosan2 | uwajima | naruto
*
* Dynamic (polled ~every few seconds):
* GET /g?arg1=train&arg2=train - live train positions ← main target
*
* Train position response schema:
* Array of { Index, TrainNum, Pos, PosNum, delay, Direction, Type, Line }
* with a trailing { GetDateTime: "YYYY/MM/DD HH:MM:SS" } element.
*/
export interface TrainEntry {
Index: number;
TrainNum: string;
/** 走行位置テキスト e.g. "高松〜鬼無(上り)" */
Pos: string;
PosNum: number;
/** 遅延分数 or "入線" */
delay: number | string;
/** 0: 下り, 1: 上り */
Direction: 0 | 1;
/** "normal" | "rapid:..." | "express:..." | "ltd:..." */
Type: string;
/** "yosan" | "koutoku" | "tokushima" | "dosan" | "dosan2" | "uwajima" | "naruto" */
Line: string;
}
export interface MockApiConfig {
/**
* Mock train position data injected in place of the live /g?arg1=train&arg2=train API.
* When provided, the WebView will use this data instead of polling the real server.
*/
trainPositions: TrainEntry[];
/**
* When true, all /g? static API calls are also intercepted and served from
* the supplied staticData map. When false (default), only the train position
* polling API is intercepted; all other calls reach the real server.
*/
interceptStaticApis?: boolean;
/**
* Optional map of query-string patterns to JSON response strings for the
* static APIs. Only used when interceptStaticApis is true.
* Keys are the full query string, e.g. "arg1=lang&arg2=lang".
*/
staticData?: Record<string, string>;
}
/**
* Generates a JavaScript string that should be prepended to the WebView's
* injectedJavaScript (runs before page scripts). It overrides XMLHttpRequest
* so the specified /g?arg1=...&arg2=... calls are intercepted and served
* from mock data.
*/
export const generateXhrInterceptorJs = (config: MockApiConfig): string => {
const trainJson = JSON.stringify([
...config.trainPositions,
{ GetDateTime: new Date().toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }).replace(/\//g, "/") },
]);
const staticEntries = config.interceptStaticApis && config.staticData
? Object.entries(config.staticData)
.map(([qs, data]) => `${JSON.stringify(qs)}: ${JSON.stringify(data)}`)
.join(",\n ")
: "";
return `
(function() {
// Double-injection guard: IJBCL and injectedJavaScript may both run this code.
// The guard ensures the prototype is only patched once.
if (window.__jrsMockActive) return;
'use strict';
// ── Mock data ──────────────────────────────────────────────────────────────
var _MOCK_TRAIN = ${trainJson};
var _INTERCEPT_STATIC = ${config.interceptStaticApis ? "true" : "false"};
var _STATIC_MAP = {
${staticEntries}
};
// ── Prototype-patching approach ────────────────────────────────────────────
// Instead of replacing window.XMLHttpRequest with a wrapper class,
// we patch the prototype methods directly. This ensures:
// • instanceof XMLHttpRequest still works
// • All native properties (responseType, withCredentials, etc.) work
// • 'this' context in callbacks is always the real XHR instance
var _proto = window.XMLHttpRequest.prototype;
var _origOpen = _proto.open;
var _origSend = _proto.send;
_proto.open = function(method, url) {
var qs = (url || '').replace(/^[^?]+\\?/, '');
if (qs === 'arg1=train&arg2=train') {
this.__jrsMockBody = JSON.stringify(_MOCK_TRAIN);
// Still call origOpen so the XHR enters OPENED state.
// This allows the page to call setRequestHeader() without errors.
// The real request is suppressed in send() by not calling _origSend.
_origOpen.apply(this, arguments);
return;
}
if (_INTERCEPT_STATIC && _STATIC_MAP[qs] !== undefined) {
this.__jrsMockBody = _STATIC_MAP[qs];
_origOpen.apply(this, arguments);
return;
}
this.__jrsMockBody = null;
_origOpen.apply(this, arguments);
};
_proto.send = function(body) {
var self = this;
if (self.__jrsMockBody != null) {
var mockStr = self.__jrsMockBody;
// Support responseType='json': return parsed object from .response
var parsed = null;
try { parsed = JSON.parse(mockStr); } catch(e) {}
// Override instance properties to serve mock data
// (configurable:true allows re-override if needed)
Object.defineProperties(self, {
readyState: { get: function() { return 4; }, configurable: true, enumerable: true },
status: { get: function() { return 200; }, configurable: true, enumerable: true },
statusText: { get: function() { return 'OK'; }, configurable: true, enumerable: true },
responseText: { get: function() { return mockStr; }, configurable: true, enumerable: true },
response: {
get: function() {
var rt = self.responseType;
if (rt === 'json') return parsed;
if (rt === 'arraybuffer' || rt === 'blob') return mockStr; // best-effort
return mockStr;
},
configurable: true, enumerable: true
},
});
setTimeout(function() {
// Fire events via dispatchEvent only.
// dispatchEvent fires both addEventListener callbacks AND onXxx property handlers,
// so calling onreadystatechange.call() / onload.call() separately would double-fire.
if (typeof self.dispatchEvent === 'function') {
try { self.dispatchEvent(new Event('readystatechange')); } catch(e) {}
try { self.dispatchEvent(new ProgressEvent('load')); } catch(e) {
try { self.dispatchEvent(new Event('load')); } catch(e2) {}
}
try { self.dispatchEvent(new ProgressEvent('loadend')); } catch(e) {
try { self.dispatchEvent(new Event('loadend')); } catch(e2) {}
}
} else {
// Fallback for environments without dispatchEvent
if (typeof self.onreadystatechange === 'function') {
try { self.onreadystatechange.call(self); } catch(e) {}
}
if (typeof self.onload === 'function') {
try { self.onload.call(self); } catch(e) {}
}
}
}, 0);
return;
}
_origSend.apply(this, arguments);
};
// ── Live-update hook ───────────────────────────────────────────────────────
// Called from React Native via injectJavaScript when the playback frame changes.
window.__jrsMockUpdateTrain = function(newData) {
_MOCK_TRAIN = newData;
};
window.__jrsMockActive = true;
console.log('[JRS Mock] XHR interceptor active (prototype-patch) train position data is mocked');
})();
`;
};
/**
* 再生コマが変化したときに WebView へ injectJavaScript で流し込む更新スクリプトを生成する。
* window.__jrsMockUpdateTrain で _MOCK_TRAIN を差し替え、window.setReload() で再描画を促す。
*/
export const generateMockUpdateScript = (trainPositions: TrainEntry[]): string => {
const trainJson = JSON.stringify([
...trainPositions,
{ GetDateTime: new Date().toLocaleString("ja-JP", { timeZone: "Asia/Tokyo" }).replace(/\//g, "/") },
]);
return `
(function() {
if (typeof window.__jrsMockUpdateTrain === 'function') {
window.__jrsMockUpdateTrain(${trainJson});
}
if (typeof window.setReload === 'function') {
window.setReload();
}
})();
true;
`;
};

View File

@@ -1,3 +1,47 @@
import { createNavigationContainerRef } from "@react-navigation/native";
import { createNavigationContainerRef, StackActions } from "@react-navigation/native";
export const rootNavigationRef = createNavigationContainerRef<any>();
/** positions タブの Stack.Navigator navigation を登録するグローバルref */
export const positionsStackNavRef: { current: any } = { current: null };
/**
* 遷移先タブのネストスタックを一度 popToTop してからナビゲートする。
* ウィジェットや外部リンクからの遷移時に、既存の開いている画面を閉じてから目的の画面へ移動するために使用する。
*/
export function stackAwareNavigate(tabName: string, params?: any) {
if (!rootNavigationRef.isReady()) return;
const doNavigate = () => {
if (params !== undefined) {
rootNavigationRef.navigate(tabName, params);
} else {
rootNavigationRef.navigate(tabName as any);
}
};
// positions タブは直接 stackNavRef を使って確実に popToTop する
if (tabName === "positions" && positionsStackNavRef.current) {
const stackNav = positionsStackNavRef.current;
if ((stackNav.getState()?.index ?? 0) > 0) {
stackNav.popToTop();
setTimeout(doNavigate, 350);
} else {
doNavigate();
}
return;
}
const state = rootNavigationRef.getState();
const tabRoute = state?.routes?.find((r: any) => r.name === tabName);
if (tabRoute?.state && (tabRoute.state.index ?? 0) > 0) {
rootNavigationRef.dispatch({
...StackActions.popToTop(),
target: tabRoute.state.key,
});
// popToTop のアニメーション完了後に navigate を実行
setTimeout(doNavigate, 350);
} else {
doNavigate();
}
}

View File

@@ -1,8 +1,39 @@
import { TransitionPresets } from "@react-navigation/stack";
import { Platform } from "react-native";
import {
CardStyleInterpolators,
TransitionPresets,
} from "@react-navigation/stack";
import type { StackCardInterpolationProps } from "@react-navigation/stack";
/**
* Android用: モーダルのスライドアップはそのまま維持しつつ、
* 背景カードへのアニメーション(scale, borderRadius等)を無効化する。
* Android で背景カードのアニメーションが描画の乱れを引き起こすため。
*/
const forModalPresentationAndroid = (
props: StackCardInterpolationProps
) => {
const result = CardStyleInterpolators.forModalPresentationIOS(props);
// 背景カードnext が存在する)にはスタイル変更を適用しない
if (props.next) {
return {
cardStyle: {},
overlayStyle: result.overlayStyle,
};
}
return result;
};
export const optionData = {
gestureEnabled: true,
...TransitionPresets.ModalPresentationIOS,
...(Platform.OS === "android" && {
cardStyleInterpolator: forModalPresentationAndroid,
}),
cardOverlayEnabled: true,
headerTransparent: true,
headerShown: false,
detachPreviousScreen: false,
};

View File

@@ -1,5 +1,5 @@
import { useEffect, useRef, useState } from "react";
import { Keyboard, LayoutAnimation, Platform } from "react-native";
import { Animated, Easing, Keyboard, Platform } from "react-native";
interface UseKeyboardAvoidOptions {
/** measure()対象のViewのref。指定するとrefの画面座標からオフセットを精密計算する */
@@ -11,22 +11,29 @@ interface UseKeyboardAvoidOptions {
interface UseKeyboardAvoidResult {
/** キーボードが表示中か */
keyboardVisible: boolean;
/** キーボードの生の高さ(キャッシュ済み) */
/** キーボードの生の高さ */
keyboardHeight: number;
/** measure()またはfallbackで計算されたオフセット値paddingBottom/bottomに使う */
/**
* Animated.Value によるオフセットAnimated.View の paddingBottom/bottom に使う)。
* measure() 非同期コールバック内から Animated.timing で駆動するため
* LayoutAnimation と異なりタイミングを問わず正しくアニメーションする。
*/
animatedOffset: Animated.Value;
/**
* 現在のオフセット数値Animated.Value を使えない箇所向け)。
* SearchUnitBox など position:absolute で bottom を直接指定する場合に使う。
*/
measuredOffset: number;
}
const LAYOUT_ANIM_CONFIG = {
duration: 250,
update: { type: LayoutAnimation.Types.easeInEaseOut },
};
const ANIM_DURATION = 250;
/**
* キーボード回避の共通hook。
* - Androidの偽イベントheight<100をガードキャッシュで対応
* - hide→show高速切替時のデバウンス100ms
* - Android measure()150ms遅延
* - height<=0 の偽イベントをガードキャッシュで対応
* - iOS: keyboardWillShow/Hideアニメーション同期+ keyboardWillChangeFrame
* - Android: hide→show 高速切替デバウンス300ms+ measure() 150ms 遅延
* - Animated.timing で paddingBottom を駆動LayoutAnimation 廃止)
*/
export function useKeyboardAvoid(
options: UseKeyboardAvoidOptions = {}
@@ -37,15 +44,58 @@ export function useKeyboardAvoid(
const [keyboardHeight, setKeyboardHeight] = useState(0);
const [measuredOffset, setMeasuredOffset] = useState(0);
// 再レンダーで Value が作り直されないよう useRef で保持
const animatedOffset = useRef(new Animated.Value(0)).current;
const showTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const hideTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const keyboardVisibleRef = useRef(false);
const lastValidKbRef = useRef<{
height: number;
screenY: number;
} | null>(null);
// 実行中アニメーションの参照(高速切替時に前アニメをキャンセルするため)
const currentAnimRef = useRef<Animated.CompositeAnimation | null>(null);
// 世代カウンタ: hide/show イベント毎にインクリメントし、
// 飛行中の古い measure() コールバックを無効化する
const measureGenRef = useRef(0);
useEffect(() => {
const doMeasure = (kbScreenY: number, kbHeight: number) => {
const animateTo = (toValue: number) => {
// 前のアニメーションを明示的にキャンセルしてから新しいものを開始
if (currentAnimRef.current) {
currentAnimRef.current.stop();
currentAnimRef.current = null;
}
setMeasuredOffset(toValue); // SearchUnitBox など plain number が必要な箇所向け
let anim: Animated.CompositeAnimation;
if (Platform.OS === "ios") {
// iOS: キーボードの spring アニメーションに近い挙動
anim = Animated.spring(animatedOffset, {
toValue,
damping: 500,
stiffness: 1000,
mass: 3,
useNativeDriver: false,
});
} else {
// Android: easeOut で自然な減速カーブ
anim = Animated.timing(animatedOffset, {
toValue,
duration: ANIM_DURATION,
easing: Easing.out(Easing.cubic),
useNativeDriver: false,
});
}
currentAnimRef.current = anim;
anim.start(({ finished }) => {
if (finished) currentAnimRef.current = null;
});
};
const doMeasure = (kbScreenY: number, kbHeight: number, gen: number) => {
if (measureRef?.current) {
(measureRef.current as any).measure(
(
@@ -56,28 +106,39 @@ export function useKeyboardAvoid(
_pageX: number,
pageY: number
) => {
// 世代が変わっていれば hide/show が割り込んだ証拠 → 破棄
if (gen !== measureGenRef.current) return;
const bottomY = pageY + h;
const offset = Math.max(0, bottomY - kbScreenY);
LayoutAnimation.configureNext(LAYOUT_ANIM_CONFIG);
setMeasuredOffset(offset);
// measure() コールバック内から直接 Animated.timing を起動 → OK
animateTo(offset);
}
);
} else {
LayoutAnimation.configureNext(LAYOUT_ANIM_CONFIG);
setMeasuredOffset(
Platform.OS === "ios" ? kbHeight - tabBarHeight : kbHeight
);
const offset =
Platform.OS === "ios" ? kbHeight - tabBarHeight : kbHeight;
animateTo(offset);
}
};
const showSubscription = Keyboard.addListener("keyboardDidShow", (e) => {
const showEventName =
Platform.OS === "ios" ? "keyboardWillShow" : "keyboardDidShow";
const hideEventName =
Platform.OS === "ios" ? "keyboardWillHide" : "keyboardDidHide";
const showSubscription = Keyboard.addListener(showEventName, (e) => {
if (hideTimerRef.current) {
clearTimeout(hideTimerRef.current);
hideTimerRef.current = null;
}
if (showTimerRef.current) clearTimeout(showTimerRef.current);
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
const isValid = e.endCoordinates.height >= 100;
// height <= 0 の偽イベントは無視してキャッシュを使う
const isValid = e.endCoordinates.height > 0;
const kbInfo = isValid
? {
height: e.endCoordinates.height,
@@ -88,35 +149,85 @@ export function useKeyboardAvoid(
if (isValid) lastValidKbRef.current = kbInfo;
setKeyboardVisible(true);
keyboardVisibleRef.current = true;
setKeyboardHeight(kbInfo.height);
if (Platform.OS === "android") {
// Android: IME が完全に表示されてから measure() する
// 世代をインクリメントしてから timer に渡す
// → timer 発火後に飛行中の measure() callback を世代で識別できる
const gen = ++measureGenRef.current;
showTimerRef.current = setTimeout(
() => doMeasure(kbInfo.screenY, kbInfo.height),
() => doMeasure(kbInfo.screenY, kbInfo.height, gen),
150
);
// adjustResize のウィンドウリサイズは非同期で 250-300ms かかる。
// 閉じ→すぐ開き の場合、150ms では中間座標を拾うことがあるため
// 500ms 後にリトライして自動訂正する。gen チェックで陳腐化コールバックは破棄される。
retryTimerRef.current = setTimeout(
() => doMeasure(kbInfo.screenY, kbInfo.height, gen),
500
);
} else {
doMeasure(kbInfo.screenY, kbInfo.height);
// iOS: keyboardWillShow のタイミングで開始すればキーボード出現と同期する
const gen = ++measureGenRef.current;
doMeasure(kbInfo.screenY, kbInfo.height, gen);
}
});
const hideSubscription = Keyboard.addListener("keyboardDidHide", () => {
const hideSubscription = Keyboard.addListener(hideEventName, () => {
if (showTimerRef.current) clearTimeout(showTimerRef.current);
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
// timer 発火済みで measure() が飛行中の場合はタイマークリアでは止められない。
// 世代をインクリメントすることで、コールバックが返っても破棄させる。
measureGenRef.current++;
// Android: IME切替時の hide→show 連続発火に備えて 300ms debounce
// iOS: 50ms のバッファを設ける(即 0ms だと rapid close→open で animateTo(0) が
// 先に走りパディングが一瞬ゼロになる cosmetic 問題を回避)
const delay = Platform.OS === "android" ? 300 : 50;
hideTimerRef.current = setTimeout(() => {
LayoutAnimation.configureNext(LAYOUT_ANIM_CONFIG);
setKeyboardVisible(false);
keyboardVisibleRef.current = false;
setKeyboardHeight(0);
setMeasuredOffset(0);
}, 100);
animateTo(0);
}, delay);
});
// iOS のみ: キーボード表示中のサイズ変化(絵文字切替等)に追従
let frameChangeSubscription: ReturnType<
typeof Keyboard.addListener
> | null = null;
if (Platform.OS === "ios") {
frameChangeSubscription = Keyboard.addListener(
"keyboardWillChangeFrame",
(e) => {
if (!keyboardVisibleRef.current) return;
const kbHeight = e.endCoordinates.height;
if (kbHeight <= 0) return;
lastValidKbRef.current = {
height: kbHeight,
screenY: e.endCoordinates.screenY,
};
setKeyboardHeight(kbHeight);
const gen = ++measureGenRef.current;
doMeasure(e.endCoordinates.screenY, kbHeight, gen);
}
);
}
return () => {
if (showTimerRef.current) clearTimeout(showTimerRef.current);
if (hideTimerRef.current) clearTimeout(hideTimerRef.current);
if (retryTimerRef.current) clearTimeout(retryTimerRef.current);
if (currentAnimRef.current) currentAnimRef.current.stop();
showSubscription.remove();
hideSubscription.remove();
frameChangeSubscription?.remove();
};
}, [measureRef, tabBarHeight]);
return { keyboardVisible, keyboardHeight, measuredOffset };
return { keyboardVisible, keyboardHeight, animatedOffset, measuredOffset };
}

128
lib/useWebViewRemount.ts Normal file
View File

@@ -0,0 +1,128 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { AppState, AppStateStatus } from "react-native";
import WebView from "react-native-webview";
/**
* WebView のメモリ解放・プロセス終了による白画面を自動復帰させるフック。
*
* 使い方:
* const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount();
* <WebView key={remountKey} ref={webViewRef} {...processHandlers} {...pingHandlers} ... />
*
* 既存の ref がある場合は webViewRef を使わず processHandlers / pingHandlers だけ使ってもよい。
* pingHandlers を使う場合は onMessage を上書きせず spread すること。
* ping による白画面検知を有効にするには pingEnabled: true を渡す。
*/
export function useWebViewRemount(options?: { pingEnabled?: boolean; backgroundThresholdMs?: number }) {
const pingEnabled = options?.pingEnabled ?? false;
const backgroundThresholdMs = options?.backgroundThresholdMs ?? 300_000; // デフォルト5分
const [remountKey, setRemountKey] = useState(0);
const backgroundedAt = useRef<number | null>(null);
const webViewRef = useRef<WebView>(null);
// ping watchdog 用
const lastPongAt = useRef(Date.now());
const isLoadingRef = useRef(true);
const remount = useCallback(() => {
lastPongAt.current = Date.now();
isLoadingRef.current = true;
setRemountKey((k) => k + 1);
}, []);
// バックグラウンドから復帰したら再マウントデフォルト5分超
useEffect(() => {
const onAppStateChange = (nextState: AppStateStatus) => {
if (nextState.match(/inactive|background/)) {
backgroundedAt.current = Date.now();
} else if (nextState === "active" && backgroundedAt.current !== null) {
const elapsed = Date.now() - backgroundedAt.current;
backgroundedAt.current = null;
if (elapsed > backgroundThresholdMs) {
remount();
}
}
};
const subscription = AppState.addEventListener("change", onAppStateChange);
return () => subscription.remove();
}, [remount, backgroundThresholdMs]);
// ping watchdog: 5秒ごとに生存確認と白画面検知を行う
// - ローディング中isLoadingRef=trueでも45秒超なら remountレンダラー死亡でonLoadEndが来ないケース
// - ロード完了後は30秒 pong 無応答で remount
const maxTextLenRef = useRef(0);
const blankCountRef = useRef(0);
useEffect(() => {
if (!pingEnabled) return;
const id = setInterval(() => {
const elapsed = Date.now() - lastPongAt.current;
if (isLoadingRef.current) {
// ローディング中でも45秒超はレンダラー死亡と判定
if (elapsed > 45_000) remount();
return;
}
// ロード完了後30秒 pong 無応答 → レンダラー死亡
if (elapsed > 30_000) {
remount();
return;
}
webViewRef.current?.injectJavaScript(
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
);
}, 5_000);
return () => clearInterval(id);
}, [pingEnabled, remount]);
const processHandlers = {
onRenderProcessGone: () => remount(),
onContentProcessDidTerminate: () => remount(),
} as const;
// pingHandlers は onLoadEnd と onMessage を提供する
// 既存の onMessage がある場合は手動でマージすること
const pingHandlers = pingEnabled ? {
onLoadEnd: () => {
isLoadingRef.current = false;
lastPongAt.current = Date.now();
maxTextLenRef.current = 0;
blankCountRef.current = 0;
// ロード完了3秒後に初回コンテンツチェック
setTimeout(() => {
if (!isLoadingRef.current) {
webViewRef.current?.injectJavaScript(
`(function(){var t=document.body?(document.body.innerText||'').replace(/\\s+/g,'').length:0;window.ReactNativeWebView&&window.ReactNativeWebView.postMessage(JSON.stringify({type:'__ping',len:t}));})();true;`
);
}
}, 3000);
},
onLoadStart: () => {
isLoadingRef.current = true;
lastPongAt.current = Date.now(); // ナビゲーション開始時にタイムアウトリセット
maxTextLenRef.current = 0;
blankCountRef.current = 0;
},
onMessage: (event: any) => {
try {
const parsed = JSON.parse(event.nativeEvent.data);
if (parsed.type === "__ping") {
lastPongAt.current = Date.now(); // 応答ごとにタイムアウトリセット
const len: number = parsed.len ?? 0;
if (len > maxTextLenRef.current) maxTextLenRef.current = len;
// 一度でも20文字超になったページが5文字未満になったら白画面と判定
if (maxTextLenRef.current > 20 && len < 5) {
blankCountRef.current += 1;
if (blankCountRef.current >= 3) {
blankCountRef.current = 0;
maxTextLenRef.current = 0;
remount();
}
} else {
blankCountRef.current = 0;
}
}
} catch {}
},
} : {};
return { remountKey, remount, processHandlers, pingHandlers, webViewRef };
}

View File

@@ -2,6 +2,7 @@ import { INTERVALS, API_ENDPOINTS } from '@/constants';
import { TRAIN_TYPE_CONFIG } from './webview/trainTypeConfig';
import { TRAIN_ICON_MAP, TRAIN_ICON_REGEX } from './webview/trainIconMap';
import { STATION_DATA } from './webview/stationData';
import { generateXhrInterceptorJs, MockApiConfig } from './mockApi/webviewXhrInterceptor';
export interface InjectJavascriptOptions {
/** 地図スイッチ ("true" | "false") */
@@ -20,8 +21,21 @@ export interface InjectJavascriptOptions {
useElesite: string;
/** ダークモードかどうか */
isDark: boolean;
/**
* モックAPIの設定。指定した場合、WebView内の公式サイトAPIをインターセプトして
* モックデータを返すXHRインターセプターが注入されます。
* 未指定または null の場合、インターセプターは無効で通常通り公式APIに接続します。
*/
mockApiConfig?: MockApiConfig | null;
/**
* バックエンドAPIのベースURL。省略時は本番URL。
* experimental 環境では https://jr-shikoku-backend-api-v1-beta.haruk.in を渡す。
*/
backendApiBaseUrl?: string;
}
const PRODUCTION_BACKEND_BASE = 'https://jr-shikoku-backend-api-v1.haruk.in';
export const injectJavascriptData = ({
mapSwitch,
iconSetting,
@@ -31,7 +45,10 @@ export const injectJavascriptData = ({
useUnyohub,
useElesite,
isDark,
backendApiBaseUrl,
mockApiConfig,
}: InjectJavascriptOptions): string => {
// 一番上のメニュー非表示 地図スイッチによって切り替え
const topMenu =
mapSwitch != "true"
@@ -107,8 +124,8 @@ export const injectJavascriptData = ({
+ '#header{background-color:#454b54!important;color:#e6edf3!important;border-bottom:1px solid #555d66!important;}'
+ '#headerStr{color:#e6edf3!important;}'
+ '#topHeader{background-color:#454b54!important;border-bottom:1px solid #555d66!important;}'
+ '#topHeader div{color:#e6edf3!important;}'
+ '#topHeader .accordion{background-color:#4d545d!important;color:#e6edf3!important;}'
+ '#topHeader div{color:#000000!important;}'
+ '#topHeader .accordion{background-color:#4d545d!important;color:#000000!important;}'
+ '${uiSetting === "tokyo" ? '[id^="stationBlock"]{background-color:rgba(0,0,0,0.45)!important;}#disp>[id*=""]{background-color:rgba(0,0,0,0.25)!important;}' : ''}'
+ '#pMENU_2,#pMENU_3,#pMENU_k{background-color:#454b54!important;border-color:#555d66!important;}'
+ '#pMENU_2 div,#pMENU_3 div,#pMENU_k div{color:#e6edf3!important;}'
@@ -117,7 +134,10 @@ export const injectJavascriptData = ({
+ '.accordionClass{background-color:#454b54!important;color:#e6edf3!important;}'
+ 'select{background-color:#4d545d!important;color:#e6edf3!important;border-color:#555d66!important;}'
+ '#upTrainCrossBar,#dwTrainCrossBar{opacity:0.85!important;}'
) : '');
) : (
'#topHeader div{color:#000000!important;}'
+ '#topHeader .accordion{color:#000000!important;}'
));
document.head.appendChild(s);
})();
@@ -186,12 +206,18 @@ export const injectJavascriptData = ({
}
};
const setReload = () => {
window.setReload = () => {
try {
document.getElementById('refreshIcon').click();
setStrings();
// Also run after async XHR responses settle.
// When mock mode is active, the XHR interceptor responds via setTimeout(0),
// which means the site re-renders trains AFTER this synchronous call.
// The delayed call ensures Tokyo UX is re-applied after the DOM update.
setTimeout(() => { try { setStrings(); } catch(e) {} }, 200);
} catch(e) {}
};
const setReload = window.setReload;
// ポーリング処理 (Phase 3 以降)
const startPolling = () => {
@@ -859,7 +885,7 @@ export const injectJavascriptData = ({
if(hasElesite) {
const elesiteOffsetPx = _blueOffset + (hasUnyohub ? 20 : 0);
const offsetStyle = badgeVerticalPos + ":" + elesiteOffsetPx + "px;";
badgeHtml += "<div style='position:absolute;" + badgePosition + ":0;" + offsetStyle + "background-color:#44bb44;border-radius:50%;border:1px solid #228822;width:19px;height:19px;box-sizing:border-box;display:flex;align-items:center;justify-content:center;'><span style='color:white;font-size:10px;font-weight:bold;line-height:1;'>E</span></div>";
badgeHtml += "<div style='position:absolute;" + badgePosition + ":0;" + offsetStyle + "background-color:#44bb44;border-radius:50%;border:1px solid #228822;width:19px;height:19px;box-sizing:border-box;overflow:hidden;display:flex;align-items:center;justify-content:center;'><img src='https://storage.haruk.in/elesite_logo.jpg' style='width:19px;height:19px;'/></div>";
}
行き先情報.insertAdjacentHTML('beforebegin', "<div style='width:100%;display:flex;flex:1;flex-direction:"+(isLeft ? "column-reverse" : "column") + ";font-weight:bold;'>" + badgeHtml + "<p style='font-size:6px;padding:0;color:black;text-align:center;'>" + (TrainNumberOverride ? TrainNumberOverride : TrainNumber) + "</p><div style='flex:1;'></div><p style='font-size:8px;font-weight:bold;padding:0;color:black;text-align:center;'>" + (isWanman ? "ワンマン " : "") + "</p><p style='font-size:6px;font-weight:bold;padding:0;color:black;text-align:center;border-style:solid;border-width: "+(!!yosan2Color ? "2px" : "0px")+";border-color:" + yosan2Color + "'>" + viaData + "</p><p style='font-size:8px;font-weight:bold;padding:0;color: " + optionalTextColor + ";text-align:center;'>" + optionalText + "</p><p style='font-size:8px;font-weight:bold;padding:0;color:black;text-align:center;'>" + trainName + "</p><div style='width:100%;background:" + gradient + ";'><p style='font-size:10px;font-weight:bold;padding:0;margin:0;color:white;align-items:center;align-content:center;text-align:center;text-shadow:1px 1px 0px #00000030, -1px -1px 0px #00000030,-1px 1px 0px #00000030, 1px -1px 0px #00000030,1px 0px 0px #00000030, -1px 0px 0px #00000030,0px 1px 0px #00000030, 0px -1px 0px #00000030;'>" + (ToData ? ToData + "行" : ToData) + "</p></div><div style='width:100%;background:" + trainTypeColor + ";border-radius:"+(isLeft ? "4px 4px 0 0" : "0 0 4px 4px")+";'><p style='font-size:10px;font-weight:bold;font-style:italic;padding:0;color: white;text-align:center;'>" + trainType + "</p></div><p style='font-size:8px;font-weight:bold;padding:0;text-align:center;color: "+(hasProblem ? "red": "black")+"; "+(hasProblem ? "animation:_jrs_blink 3s linear infinite;" : "")+"'>" + (hasProblem ? "‼️停止中‼️" : "") + "</p></div>");
@@ -1227,20 +1253,26 @@ const setStrings = () =>{
} catch (e) {}
}
let _ssDebounce = null;
const textInsert = new MutationObserver( (mutations) =>{
setStrings();
const currentLines = document.querySelector('#topHeader div').innerText;
window.ReactNativeWebView.postMessage(JSON.stringify({type:"currentLines",currentLines}));
// Only send currentLines when direct children of #disp change (line selection change)
const disp = document.getElementById('disp');
if (mutations.some(m => m.target === disp)) {
const currentLines = document.querySelector('#topHeader div').innerText;
window.ReactNativeWebView.postMessage(JSON.stringify({type:"currentLines",currentLines}));
}
// Apply Tokyo UX immediately (prevents visible flash on normal renders).
try { setStrings(); } catch(e) {}
// Also schedule a follow-up after 250ms to catch async re-renders
// triggered by mock XHR setTimeout(0) responses that settle later.
clearTimeout(_ssDebounce);
_ssDebounce = setTimeout(() => { try { setStrings(); } catch(e) {} }, 250);
});
// 監視を開始
// 監視を開始 (subtree: true で深い子孫の変化も捕捉する)
textInsert.observe(document.getElementById('disp'), {
attributes: true, // 属性変化の監視
//attributeOldValue: true, // 変化前の属性値を matation.oldValue に格納する
//characterData: true, // テキストノードの変化を監視
//characterDataOldValue: true, // 変化前のテキストを matation.oldValue に格納する
childList: true, // 子ノードの変化を監視
//subtree: true // 子孫ノードも監視対象に含める
subtree: true, // 子孫ノードも監視対象に含める (train elements are 3 levels deep)
});
`;
@@ -1390,7 +1422,7 @@ setStationMenuDialog.observe(document.querySelector('#disp'), {
});
`
: ``;
return (
let result =
bootData +
topMenu +
trainIcon +
@@ -1398,6 +1430,28 @@ setStationMenuDialog.observe(document.querySelector('#disp'), {
makeTrainView +
makeTrainMenu +
textInsert +
makeStationMenu
);
makeStationMenu;
if (backendApiBaseUrl && backendApiBaseUrl !== PRODUCTION_BACKEND_BASE) {
result = result.split(PRODUCTION_BACKEND_BASE).join(backendApiBaseUrl);
}
// Prepend XHR interceptor as a fallback for platforms where
// injectedJavaScriptBeforeContentLoaded doesn't run reliably.
// The __jrsMockActive guard in the interceptor prevents double-patching
// when IJBCL already installed it.
if (mockApiConfig) {
result = generateXhrInterceptorJs(mockApiConfig) + '\n' + result;
}
return result;
};
/**
* XHRインターセプターJSを生成するinjectedJavaScriptBeforeContentLoaded用
* ページスクリプトより前に実行する必要があるため、別プロップで渡す。
*/
export const generateBeforeContentLoadedScript = (mockApiConfig: MockApiConfig | null): string => {
if (!mockApiConfig) return '';
return generateXhrInterceptorJs(mockApiConfig);
};

View File

@@ -1,12 +1,11 @@
import React, { useRef, useState, useEffect, useCallback, useMemo, FC } from "react";
import { Platform, View, ScrollView, LayoutAnimation, Text, InteractionManager, useWindowDimensions } from "react-native";
import { Platform, View, ScrollView, LayoutAnimation, Text, InteractionManager, useWindowDimensions, Image, StatusBar } from "react-native";
import Constants from "expo-constants";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import {
configureReanimatedLogger,
ReanimatedLogLevel,
} from "react-native-reanimated";
import StatusbarDetect from "@/StatusbarDetect";
import { useThemeColors } from "@/lib/theme";
import { useResponsive } from "@/lib/responsive";
@@ -16,7 +15,7 @@ import { FixedContentBottom } from "@/components/Menu/FixedContentBottom";
import { lineList, stationIDPair } from "@/lib/getStationList";
import { useFavoriteStation } from "@/stateBox/useFavoriteStation";
import { useNavigation } from "@react-navigation/native";
import { useNavigation, useIsFocused } from "@react-navigation/native";
import { useStationList } from "@/stateBox/useStationList";
import { TopMenuButton } from "@/components/Menu/TopMenuButton";
import { JRSTraInfoBox } from "@/components/Menu/JRSTraInfoBox";
@@ -43,10 +42,13 @@ type props = {
setMapMode: React.Dispatch<React.SetStateAction<boolean>>;
};
const MAP_PIN_SIZE = Platform.OS === "android" ? 32 : 36;
export const Menu: FC<props> = (props) => {
const { scrollRef, mapHeight, MapFullHeight, mapMode, setMapMode } = props;
const { navigate } = useNavigation();
const { colors, isDark } = useThemeColors();
const isMenuFocused = useIsFocused();
const { verticalScale } = useResponsive();
const insets = useSafeAreaInsets();
const { favoriteStation } = useFavoriteStation();
@@ -287,7 +289,12 @@ export const Menu: FC<props> = (props) => {
paddingTop: Platform.OS === "web" ? 0 : insets.top,
}}
>
<StatusbarDetect />
{isMenuFocused && (
<StatusBar
barStyle={isDark ? "light-content" : "dark-content"}
translucent
/>
)}
{!mapMode ? <TitleBar /> : <></>}
<ScrollView
ref={scrollRef}
@@ -345,7 +352,8 @@ export const Menu: FC<props> = (props) => {
latitude: lat,
longitude: lng,
}}
image={require("@/assets/reccha-small.png")}
anchor={{ x: 0.5, y: 1 }}
tracksViewChanges={false}
onPress={() => {
setMapMode(false);
setListIndex(index);
@@ -373,7 +381,13 @@ export const Menu: FC<props> = (props) => {
});
returnToTop();
}}
/>
>
<Image
source={require("@/assets/reccha-small.png")}
style={{ width: MAP_PIN_SIZE, height: MAP_PIN_SIZE }}
resizeMode="contain"
/>
</Marker>
))}
</MapView>
{!mapMode && (

View File

@@ -242,9 +242,12 @@ if (ExpoLiveActivityModule) {
* このデバイスで Live Activity が使用可能かを返す。
* iOS 16.2+ の実機かつユーザーが許可している場合のみ true。
* Android では常に true。
*
* NOTE: 一時的に無効化中 — 常に false を返す
*/
export function isAvailable(): boolean {
return ExpoLiveActivityModule?.isAvailable() ?? false;
return false;
// return ExpoLiveActivityModule?.isAvailable() ?? false;
}
/**

View File

@@ -1,4 +1,4 @@
import React, { Ref, useRef, useState, useEffect } from "react";
import React, { Ref, useRef, useState, useEffect, useCallback } from "react";
import {
View,
Platform,
@@ -6,18 +6,44 @@ import {
StyleProp,
ViewStyle,
Linking,
AppState,
} from "react-native";
import { WebView } from "react-native-webview";
import Constants from "expo-constants";
import { Ionicons } from "@expo/vector-icons";
import { useNavigation } from "@react-navigation/native";
import { useNavigation, useFocusEffect } from "@react-navigation/native";
import { useThemeColors } from "@/lib/theme";
import { useSafeAreaInsets } from "react-native-safe-area-context";
import { useWebViewRemount } from "@/lib/useWebViewRemount";
export default function tndView() {
const webview = useRef<WebView>(null);
const { remountKey, remount, processHandlers, pingHandlers, webViewRef } = useWebViewRemount({ pingEnabled: true, backgroundThresholdMs: 60_000 }); // 1分超でAppState remount
const { navigate, addListener, isFocused } = useNavigation();
const { fixed } = useThemeColors();
const { top } = useSafeAreaInsets();
// タブにフォーカスが来たとき、バックグラウンドから戻ってきた場合 remount
const bgAtRef = useRef<number | null>(null);
useEffect(() => {
const sub = AppState.addEventListener("change", (state) => {
if (state.match(/inactive|background/)) {
bgAtRef.current = Date.now();
} else if (state === "active") {
// useWebViewRemount側(1分超)とは独立: ここではフォーカス時判定用に保持するだけ
}
});
return () => sub.remove();
}, []);
// タブにフォーカスが来たとき、3秒以上バックグラウンドだった場合 remount
useFocusEffect(
useCallback(() => {
if (bgAtRef.current !== null) {
const elapsed = Date.now() - bgAtRef.current;
bgAtRef.current = null;
if (elapsed > 3_000) remount();
}
}, [remount])
);
const jsa = `
document.querySelector('.sitettl').style.display = 'none';
document.querySelector('.attention').style.display = 'none';
@@ -137,7 +163,8 @@ setInterval(() => {
}}
>
<WebView
ref={webview}
key={remountKey}
ref={webViewRef}
source={{ uri: "https://www.jr-shikoku.co.jp/info/" }}
originWhitelist={["https://www.jr-shikoku.co.jp"]}
mixedContentMode={"compatibility"}
@@ -145,13 +172,16 @@ setInterval(() => {
javaScriptEnabled={true}
injectedJavaScript={jsa}
pullToRefreshEnabled
onError={() => webview.current?.reload()}
onMessage={() => {
// 必要に応じてメッセージ処理を実装
onError={() => remount()}
{...processHandlers}
{...pingHandlers}
onMessage={(event) => {
// pingHandlers の __ping メッセージを処理
pingHandlers.onMessage?.(event);
}}
/>
<ReloadButton
onPress={() => webview.current.reload()}
onPress={() => webViewRef.current?.reload()}
top={top}
/>
</View>

View File

@@ -10,6 +10,10 @@ import React, {
useState,
} from "react";
import { API_ENDPOINTS, STORAGE_KEYS } from "@/constants";
import {
getBackendApiBaseUrl,
BACKEND_API_BASE_URLS,
} from "@/lib/jrDataSystemEnvironment";
const initialState = {
allTrainDiagram: {},
setAllTrainDiagram: (e) => {},
@@ -46,6 +50,16 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
setKeyList(Object.keys(allTrainDiagram));
else setKeyList([]);
}, [allTrainDiagram]);
const [backendApiBaseUrl, setBackendApiBaseUrl] = React.useState(
BACKEND_API_BASE_URLS.production,
);
React.useEffect(() => {
AS.getItem(STORAGE_KEYS.JR_DATA_SYSTEM_ENV)
.then((value) => setBackendApiBaseUrl(getBackendApiBaseUrl(value)))
.catch(() => {});
}, []);
const getTrainDiagram = () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
@@ -92,7 +106,7 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
const getCustomTrainData = () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
fetch("https://jr-shikoku-backend-api-v1.haruk.in/train-data", { signal: controller.signal })
fetch(`${backendApiBaseUrl}/train-data`, { signal: controller.signal })
.then((res) => res.json())
.then((res) => {
setAllCustomTrainData(res.data);
@@ -111,7 +125,7 @@ export const AllTrainDiagramProvider: FC<Props> = ({ children }) => {
const getTodayOperation = () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 15000);
fetch(API_ENDPOINTS.OPERATION_LOGS, { signal: controller.signal })
fetch(`${backendApiBaseUrl}/operation-logs`, { signal: controller.signal })
.then((res) => res.json())
.then((res) => {
if (res.data === null) {

View File

@@ -100,6 +100,12 @@ export const BusAndTrainDataProvider: FC<Props> = ({ children }) => {
case "139M":
returnArray.push("143M");
break;
case "4126M":
returnArray.push("5126M");
break;
case "5126M":
returnArray.push("4126M");
break;
// 土讃線琴平界隈
case "1263M":
returnArray.push("4263M");

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