diff --git a/package-lock.json b/package-lock.json
index db9656854..069393955 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -29,6 +29,7 @@
"expo-battery": "~57.0.2",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.14",
+ "expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.10",
"expo-crypto": "~57.0.2",
"expo-device": "~57.0.1",
@@ -8965,6 +8966,17 @@
"node": ">=10"
}
},
+ "node_modules/expo-clipboard": {
+ "version": "57.0.2",
+ "resolved": "https://registry.npmjs.org/expo-clipboard/-/expo-clipboard-57.0.2.tgz",
+ "integrity": "sha512-VB4Au8X/RbvJKUtJk+87vdtveWSNEa5RzY3ooeO6VNF7Rd49RbQXZOu3/TfEW9p7mU8TCfyTvjyCiAUwSoSJ4w==",
+ "license": "MIT",
+ "peerDependencies": {
+ "expo": "*",
+ "react": "*",
+ "react-native": "*"
+ }
+ },
"node_modules/expo-constants": {
"version": "57.0.14",
"resolved": "https://registry.npmjs.org/expo-constants/-/expo-constants-57.0.14.tgz",
diff --git a/package.json b/package.json
index 0652df1f0..5b4af88ab 100644
--- a/package.json
+++ b/package.json
@@ -39,6 +39,7 @@
"expo-battery": "~57.0.2",
"expo-blur": "~57.0.2",
"expo-build-properties": "~57.0.14",
+ "expo-clipboard": "~57.0.2",
"expo-constants": "~57.0.10",
"expo-crypto": "~57.0.2",
"expo-device": "~57.0.1",
diff --git a/src/components/DevOverlay.test.tsx b/src/components/DevOverlay.test.tsx
index e4d2fb9b0..79cc0bb34 100644
--- a/src/components/DevOverlay.test.tsx
+++ b/src/components/DevOverlay.test.tsx
@@ -1,4 +1,4 @@
-import { act, render } from '@testing-library/react-native';
+import { act, fireEvent, render } from '@testing-library/react-native';
import * as Application from 'expo-application';
import { useAtomValue } from 'jotai';
import { Dimensions, StyleSheet } from 'react-native';
@@ -55,8 +55,14 @@ jest.mock('~/utils/etaPhaseNow', () => ({
getEtaPhaseNow: jest.fn(() => null),
}));
+// クリップボードは react-native core の非推奨 Clipboard を触るため、テストでは差し替える
+jest.mock('~/utils/clipboard', () => ({
+ copyTextToClipboard: jest.fn(),
+}));
+
// Import mocked hooks for type safety
import { useDistanceToNextStation, useNextStation } from '~/hooks';
+import { copyTextToClipboard } from '~/utils/clipboard';
const mockUseAtomValue = useAtomValue as jest.MockedFunction<
typeof useAtomValue
@@ -72,6 +78,9 @@ const mockUseNextStation = useNextStation as jest.MockedFunction<
const mockGetEtaPhaseNow = getEtaPhaseNow as jest.MockedFunction<
typeof getEtaPhaseNow
>;
+const mockCopyTextToClipboard = copyTextToClipboard as jest.MockedFunction<
+ typeof copyTextToClipboard
+>;
describe('DevOverlay', () => {
const mockDimensionsGet = jest.spyOn(Dimensions, 'get');
@@ -291,6 +300,95 @@ describe('DevOverlay', () => {
});
});
+ describe('診断情報のコピー', () => {
+ // DevOverlay の COPIED_FEEDBACK_DURATION_MS と同値。exportしていないのでここで持つ
+ const COPIED_FEEDBACK_DURATION_MS = 1500;
+
+ beforeEach(() => {
+ mockCopyTextToClipboard.mockResolvedValue(true);
+ });
+
+ it('ボタンを押すと診断情報をクリップボードへ載せる', async () => {
+ const { getByTestId } = render();
+
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ // コピーはPromiseを返すので、解決後の状態更新までactの中で流す
+ await act(async () => {});
+
+ expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1);
+ const copied = JSON.parse(mockCopyTextToClipboard.mock.calls[0][0]);
+ // 座標だけでなく実効設定も載っていること。設定が無いと同じ測位でも
+ // 挙動を説明できないため、これが欠けると持ち出す意味が薄れる
+ expect(copied.config).toMatchObject({
+ maxPermitAccuracy: MAX_PERMIT_ACCURACY,
+ telemetryEnabled: true,
+ autoModeEnabled: false,
+ });
+ expect(copied.location.raw).toMatchObject({ accuracy: 15 });
+ expect(copied.build.appVersion).toBe(
+ `${Application.nativeApplicationVersion}(${Application.nativeBuildVersion})`
+ );
+ });
+
+ it('押した直後はCOPIED表示になり、一定時間で戻る', async () => {
+ jest.useFakeTimers();
+ try {
+ const { getByTestId, getByText, queryByText } = render();
+ expect(getByText('COPY')).toBeTruthy();
+
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ await act(async () => {});
+ expect(getByText('COPIED')).toBeTruthy();
+
+ act(() => {
+ jest.advanceTimersByTime(COPIED_FEEDBACK_DURATION_MS);
+ });
+ expect(queryByText('COPIED')).toBeNull();
+ expect(getByText('COPY')).toBeTruthy();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('成功直後にコピーが失敗したらCOPIED表示とタイマーを解除する', async () => {
+ // 成功のタイマーが生きている間に失敗すると、古い表示が残って
+ // 「最後のコピーは失敗しているのにCOPIEDに見える」状態になる
+ jest.useFakeTimers();
+ try {
+ const { getByTestId, getByText, queryByText } = render();
+
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ await act(async () => {});
+ expect(getByText('COPIED')).toBeTruthy();
+
+ mockCopyTextToClipboard.mockResolvedValue(false);
+ act(() => {
+ jest.advanceTimersByTime(COPIED_FEEDBACK_DURATION_MS / 2);
+ });
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ await act(async () => {});
+
+ expect(queryByText('COPIED')).toBeNull();
+ expect(getByText('COPY')).toBeTruthy();
+ } finally {
+ jest.useRealTimers();
+ }
+ });
+
+ it('クリップボードへ載せられなかった場合はCOPIEDを出さない', async () => {
+ // 失敗しているのに成功表示を出すと、貼り付けてみるまで気付けない
+ mockCopyTextToClipboard.mockResolvedValue(false);
+ const { getByTestId, getByText, queryByText } = render();
+
+ fireEvent.press(getByTestId('dev-overlay-copy-button'));
+ await act(async () => {});
+
+ expect(mockCopyTextToClipboard).toHaveBeenCalledTimes(1);
+ expect(queryByText('COPIED')).toBeNull();
+ expect(getByText('COPY')).toBeTruthy();
+ });
+ });
+
describe('D&D座標変換', () => {
it('物理横向きではドラッグ量をright/top基準の移動量に変換する', () => {
expect(getDevOverlayDragTranslation(24, 10, false)).toEqual({
diff --git a/src/components/DevOverlay.tsx b/src/components/DevOverlay.tsx
index 7a67f909d..83072c378 100644
--- a/src/components/DevOverlay.tsx
+++ b/src/components/DevOverlay.tsx
@@ -7,6 +7,8 @@ import {
Animated,
Easing,
PanResponder,
+ Platform,
+ Pressable,
type StyleProp,
StyleSheet,
type TextStyle,
@@ -29,11 +31,14 @@ import {
rawLocationAtom,
} from '~/store/atoms/location';
import { autoModeEnabledAtom } from '~/store/atoms/navigation';
+import { copyTextToClipboard } from '~/utils/clipboard';
+import { formatDevDiagnosticsSnapshot } from '~/utils/devDiagnosticsSnapshot';
import {
getDisplacementSpeed,
hasMeasuredSpeed,
} from '~/utils/displacementSpeed';
import { getEtaPhaseNow } from '~/utils/etaPhaseNow';
+import { isDevApp } from '~/utils/isDevApp';
import AccuracyHistoryChart from './AccuracyHistoryChart';
import Typography from './Typography';
@@ -45,6 +50,9 @@ const EXPAND_DURATION = 280;
const ACCURACY_CHART_SAMPLE_INTERVAL_MS = 1000;
const ACCURACY_CHART_LIMIT = 12;
+// 「コピーした」表示を出しておく時間(ms)
+const COPIED_FEEDBACK_DURATION_MS = 1500;
+
const PANEL_BORDER = 'rgba(255,255,255,0.18)';
const PANEL_BG = 'rgba(7, 11, 24, 0.78)';
const LABEL_COLOR = 'rgba(199, 210, 254, 0.72)';
@@ -163,6 +171,19 @@ const styles = StyleSheet.create({
borderWidth: 1,
minWidth: 72,
},
+ copyButton: {
+ borderRadius: 999,
+ paddingHorizontal: 10,
+ paddingVertical: 6,
+ borderWidth: 1,
+ minWidth: 72,
+ borderColor: 'rgba(148, 163, 184, 0.45)',
+ backgroundColor: 'rgba(30, 41, 59, 0.55)',
+ },
+ copyButtonPressed: {
+ borderColor: 'rgba(56, 189, 248, 0.6)',
+ backgroundColor: 'rgba(14, 165, 233, 0.28)',
+ },
statusLabel: {
color: 'rgba(226, 232, 240, 0.78)',
fontSize: 8,
@@ -502,6 +523,64 @@ const DevOverlay: React.FC = ({ unrotated = false }) => {
const versionLabel = `TrainLCD DO ${Application.nativeApplicationVersion}(${Application.nativeBuildVersion})`;
const telemetryValue = isTelemetryEnabled ? 'ON' : 'OFF';
const backgroundValue = isBackgroundLocationTracking ? 'ON' : 'OFF';
+
+ // 診断情報をクリップボードへ載せたことの一時的なフィードバック。
+ // タイマーはアンマウントと連打で必ず張り直す(残ると解除済みの状態を書きに行く)。
+ const [hasCopied, setHasCopied] = useState(false);
+ const copiedTimerRef = useRef | null>(null);
+ useEffect(
+ () => () => {
+ if (copiedTimerRef.current !== null) {
+ clearTimeout(copiedTimerRef.current);
+ }
+ },
+ []
+ );
+
+ const handleCopyDiagnostics = async () => {
+ // 実際に載ったときだけ COPIED を出す。失敗しているのに成功表示を出すと、
+ // 貼り付けてみるまで気付けない。
+ const copied = await copyTextToClipboard(
+ formatDevDiagnosticsSnapshot({
+ // レンダー中ではなくイベントハンドラ内なので Date.now() を直接読んでよい
+ nowMs: Date.now(),
+ appVersion: Application.nativeApplicationVersion ?? 'unknown',
+ buildNumber: Application.nativeBuildVersion ?? 'unknown',
+ channel: isDevApp ? 'canary' : 'production',
+ platform: Platform.OS,
+ osVersion: Platform.Version,
+ autoModeEnabled,
+ telemetryEnabled: isTelemetryEnabled,
+ backgroundLocationTracking: isBackgroundLocationTracking,
+ rawLocation,
+ filteredLocation: simulatedLocation,
+ accuracyHistory: chartHistory,
+ effectiveSpeedMps: effectiveSpeed,
+ hasMeasuredSpeed: hasEverMeasuredSpeed,
+ maxPermitAccuracy,
+ etaAssistEnabled,
+ etaPhase,
+ etaAnchor,
+ nextStation,
+ distanceToNextStation,
+ })
+ );
+ if (copiedTimerRef.current !== null) {
+ clearTimeout(copiedTimerRef.current);
+ copiedTimerRef.current = null;
+ }
+ if (!copied) {
+ // 直前の成功で立てた表示とタイマーをここで落とす。残すと、最後のコピーが
+ // 失敗しているのに前回のタイマーが切れるまでCOPIEDのままになる。
+ setHasCopied(false);
+ return;
+ }
+ setHasCopied(true);
+ copiedTimerRef.current = setTimeout(() => {
+ copiedTimerRef.current = null;
+ setHasCopied(false);
+ }, COPIED_FEEDBACK_DURATION_MS);
+ };
// ETA推定フェーズ(RUNNING/APPROACHING/DWELLING)を表示。フェーズ未推定時は IDLE。
const etaFallbackValue = etaPhase?.kind ?? 'IDLE';
// 推定対象の駅ID(走行/接近中は目標駅、停車中は当該駅)。
@@ -850,6 +929,27 @@ const DevOverlay: React.FC = ({ unrotated = false }) => {
value={backgroundValue}
style={statusPillStyle}
/>
+ {/* パネルのPanResponderはcaptureを使っていないため、子のPressableが
+ 先にタッチを取る。展開/折りたたみのトグルとは競合しない。
+ 折りたたみ中は上に載るcollapsedOverlayがタッチを受けるので押せない。 */}
+ {
+ void handleCopyDiagnostics();
+ }}
+ style={({ pressed }) => [
+ styles.copyButton,
+ statusPillStyle,
+ pressed && styles.copyButtonPressed,
+ ]}
+ >
+ DIAGNOSTICS
+
+ {hasCopied ? 'COPIED' : 'COPY'}
+
+
diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts
new file mode 100644
index 000000000..01eae4220
--- /dev/null
+++ b/src/utils/clipboard.ts
@@ -0,0 +1,19 @@
+import * as Clipboard from 'expo-clipboard';
+
+/**
+ * 文字列をクリップボードへ載せる。載せられたかどうかを返す。
+ *
+ * react-native の Clipboard は core から切り出され「将来のリリースで削除する」と
+ * 予告されている(参照するとその旨の警告が出る)。新しく使い始める先としては選ばない。
+ *
+ * 呼び出し側をこの1か所に閉じてあるので、載せ方を変えるときはこの関数の中だけで済む。
+ * 用途は DevOverlay の診断情報の持ち出しに限る(本番の画面からは呼ばない)。
+ */
+export const copyTextToClipboard = async (text: string): Promise => {
+ try {
+ return await Clipboard.setStringAsync(text);
+ } catch (error) {
+ console.warn('クリップボードへのコピーに失敗しました:', error);
+ return false;
+ }
+};
diff --git a/src/utils/devDiagnosticsSnapshot.test.ts b/src/utils/devDiagnosticsSnapshot.test.ts
new file mode 100644
index 000000000..9cd9ca299
--- /dev/null
+++ b/src/utils/devDiagnosticsSnapshot.test.ts
@@ -0,0 +1,152 @@
+import type * as Location from 'expo-location';
+import type { Station } from '~/@types/graphql';
+import {
+ buildDevDiagnosticsSnapshot,
+ type DevDiagnosticsInput,
+ formatDevDiagnosticsSnapshot,
+} from './devDiagnosticsSnapshot';
+
+const makeLocation = (
+ latitude: number,
+ longitude: number,
+ accuracy: number | null,
+ timestamp: number
+): Location.LocationObject => ({
+ coords: {
+ latitude,
+ longitude,
+ accuracy,
+ altitude: null,
+ altitudeAccuracy: null,
+ heading: null,
+ speed: 12.5,
+ },
+ timestamp,
+});
+
+const baseInput: DevDiagnosticsInput = {
+ nowMs: Date.UTC(2026, 8, 15, 4, 5, 6),
+ appVersion: '10.15.1',
+ buildNumber: '123',
+ channel: 'canary',
+ platform: 'ios',
+ osVersion: '18.2',
+ autoModeEnabled: false,
+ telemetryEnabled: true,
+ backgroundLocationTracking: true,
+ rawLocation: makeLocation(35.732538, 139.670653, 312, 1_700_000_000_000),
+ filteredLocation: makeLocation(35.7325, 139.6706, 312, 1_700_000_000_000),
+ accuracyHistory: [20, 45, 310],
+ effectiveSpeedMps: 12.5,
+ hasMeasuredSpeed: true,
+ maxPermitAccuracy: 1500,
+ etaAssistEnabled: false,
+ etaPhase: { kind: 'RUNNING', targetStationId: 9930135 },
+ etaAnchor: {
+ stationId: 9930134,
+ kind: 'DEPARTED',
+ observedAtMs: 1_700_000_000_000,
+ },
+ nextStation: { id: 9930135, name: '練馬' } as Station,
+ distanceToNextStation: '1,234',
+};
+
+describe('buildDevDiagnosticsSnapshot', () => {
+ it('実効設定を座標と一緒に持ち出す', () => {
+ // 設定が分からないと同じ測位でも挙動を説明できないため、
+ // 座標だけを持ち出せても診断には足りない
+ const snapshot = buildDevDiagnosticsSnapshot(baseInput);
+
+ expect(snapshot.config).toEqual({
+ maxPermitAccuracy: 1500,
+ etaAssistEnabled: false,
+ autoModeEnabled: false,
+ telemetryEnabled: true,
+ backgroundLocationTracking: true,
+ });
+ expect(snapshot.build).toEqual({
+ appVersion: '10.15.1(123)',
+ channel: 'canary',
+ platform: 'ios',
+ osVersion: '18.2',
+ });
+ });
+
+ it('フィルタ前の生の測位と、アプリが使っている測位の両方を持つ', () => {
+ // 片方だけだと「どの測位がどう補正されたか」が追えない
+ const snapshot = buildDevDiagnosticsSnapshot(baseInput);
+
+ expect(snapshot.location.raw).toMatchObject({
+ latitude: 35.732538,
+ longitude: 139.670653,
+ accuracy: 312,
+ timestamp: 1_700_000_000_000,
+ });
+ expect(snapshot.location.filtered).toMatchObject({
+ latitude: 35.7325,
+ longitude: 139.6706,
+ });
+ expect(snapshot.location.accuracyHistory).toEqual([20, 45, 310]);
+ });
+
+ it('測位が無い場合もnullで表現して壊れない', () => {
+ const snapshot = buildDevDiagnosticsSnapshot({
+ ...baseInput,
+ rawLocation: null,
+ filteredLocation: null,
+ });
+
+ expect(snapshot.location.raw).toBeNull();
+ expect(snapshot.location.filtered).toBeNull();
+ });
+
+ it('タイムスタンプをISO文字列でも併記する', () => {
+ // 生のミリ秒だけだと目視で時系列を追えない
+ const snapshot = buildDevDiagnosticsSnapshot(baseInput);
+
+ expect(snapshot.capturedAt).toBe('2026-09-15T04:05:06.000Z');
+ expect(snapshot.location.raw?.timestampISO).toBe(
+ new Date(1_700_000_000_000).toISOString()
+ );
+ });
+
+ it('タイムスタンプを持たない測位でも例外を出さない', () => {
+ // 診断情報の持ち出しで落ちては本末転倒。Date は不正な値へ toISOString すると送出する
+ const withoutTimestamp = {
+ coords: { speed: 10, accuracy: 15 },
+ } as unknown as Location.LocationObject;
+
+ const snapshot = buildDevDiagnosticsSnapshot({
+ ...baseInput,
+ rawLocation: withoutTimestamp,
+ });
+
+ expect(snapshot.location.raw).toMatchObject({
+ accuracy: 15,
+ timestamp: null,
+ timestampISO: null,
+ latitude: null,
+ });
+ });
+
+ it('ETAのフェーズとアンカーをそのまま持つ', () => {
+ const snapshot = buildDevDiagnosticsSnapshot(baseInput);
+
+ expect(snapshot.eta.phase).toEqual({
+ kind: 'RUNNING',
+ targetStationId: 9930135,
+ });
+ expect(snapshot.eta.anchor?.stationId).toBe(9930134);
+ });
+});
+
+describe('formatDevDiagnosticsSnapshot', () => {
+ it('貼り付けられる整形済みJSONを返す', () => {
+ const text = formatDevDiagnosticsSnapshot(baseInput);
+
+ expect(() => JSON.parse(text)).not.toThrow();
+ expect(JSON.parse(text)).toEqual(buildDevDiagnosticsSnapshot(baseInput));
+ // 読みながら貼れるようインデントする
+ expect(text).toContain('\n "config": {');
+ });
+});
diff --git a/src/utils/devDiagnosticsSnapshot.ts b/src/utils/devDiagnosticsSnapshot.ts
new file mode 100644
index 000000000..b1e3f64f5
--- /dev/null
+++ b/src/utils/devDiagnosticsSnapshot.ts
@@ -0,0 +1,129 @@
+import type * as Location from 'expo-location';
+import type { Station } from '~/@types/graphql';
+import type { EtaAnchor, EtaPhase } from './etaFallback';
+
+/**
+ * DevOverlay が表示している診断値を、そのまま貼り付けられる JSON へ組み立てる。
+ *
+ * 画面の数値を読み上げてもらう代わりに、判断に要る値を丸ごと持ち出せるようにするのが目的。
+ * 画面には出していない実効設定(リモート設定の値・プラットフォーム)も含める。設定が
+ * 分からないと同じ測位でも挙動を説明できないため、座標だけ持ち出しても再現できない。
+ *
+ * ここで持ち出せるのは「その瞬間のスナップショット」であって、棄却された測位の履歴や
+ * 測位の出所(継続測位/補完測位)ではない。それらは setLocation / handleTrackingLocation の
+ * 判定箇所に記録を足さないと取れないので、本関数の対象外。
+ */
+
+export type DevDiagnosticsInput = {
+ /** 生成時刻(ms)。呼び出し側から渡してレンダーの純粋性を保つ */
+ nowMs: number;
+ appVersion: string;
+ buildNumber: string;
+ /** canary(開発ビルド)か production か */
+ channel: 'canary' | 'production';
+ platform: string;
+ osVersion: string | number;
+ autoModeEnabled: boolean;
+ telemetryEnabled: boolean;
+ backgroundLocationTracking: boolean;
+ /** 継続測位の生の値(フィルタ前) */
+ rawLocation: Location.LocationObject | null;
+ /** フィルタ・スムージングを通した、アプリが現在地として使っている値 */
+ filteredLocation: Location.LocationObject | null;
+ /** DevOverlay のチャートが持つ精度履歴(古い順) */
+ accuracyHistory: number[];
+ /** 表示に使っている速度(m/s)と、それが実測かどうか */
+ effectiveSpeedMps: number;
+ hasMeasuredSpeed: boolean;
+ maxPermitAccuracy: number;
+ etaAssistEnabled: boolean;
+ etaPhase: EtaPhase | null;
+ etaAnchor: EtaAnchor | null;
+ nextStation: Station | null | undefined;
+ /**
+ * 次駅までの距離。useDistanceToNextStation は表示用に桁区切りした文字列
+ * (測位が無いときは 0)を返すので、型もそれに合わせる。ここで数値へ直すと
+ * フックの算出と二重に持つことになるため、表示値をそのまま持ち出す。
+ */
+ distanceToNextStation: string | number | null | undefined;
+};
+
+type CoordsSnapshot = {
+ latitude: number | null;
+ longitude: number | null;
+ accuracy: number | null;
+ speed: number | null;
+ timestamp: number | null;
+ timestampISO: string | null;
+} | null;
+
+/**
+ * ミリ秒をISO文字列にする。値が無い・不正なら null を返す。
+ *
+ * 診断情報の持ち出しで例外を出すわけにはいかない。Date は不正な値へ toISOString すると
+ * 送出するので、ここで必ず止める。OSやシミュレーションの経路によっては timestamp を
+ * 持たない測位が届きうる。
+ */
+const toISOStringOrNull = (ms: number | null | undefined): string | null => {
+ if (ms == null || !Number.isFinite(ms)) {
+ return null;
+ }
+ const date = new Date(ms);
+ return Number.isNaN(date.getTime()) ? null : date.toISOString();
+};
+
+const toCoordsSnapshot = (
+ location: Location.LocationObject | null
+): CoordsSnapshot => {
+ if (!location) {
+ return null;
+ }
+ return {
+ latitude: location.coords?.latitude ?? null,
+ longitude: location.coords?.longitude ?? null,
+ accuracy: location.coords?.accuracy ?? null,
+ speed: location.coords?.speed ?? null,
+ timestamp: location.timestamp ?? null,
+ timestampISO: toISOStringOrNull(location.timestamp),
+ };
+};
+
+export const buildDevDiagnosticsSnapshot = (input: DevDiagnosticsInput) => ({
+ capturedAt: toISOStringOrNull(input.nowMs),
+ build: {
+ appVersion: `${input.appVersion}(${input.buildNumber})`,
+ channel: input.channel,
+ platform: input.platform,
+ osVersion: String(input.osVersion),
+ },
+ // 実効設定。同じ測位でもこれが違えば挙動が変わるので、座標と必ずセットで持ち出す
+ config: {
+ maxPermitAccuracy: input.maxPermitAccuracy,
+ etaAssistEnabled: input.etaAssistEnabled,
+ autoModeEnabled: input.autoModeEnabled,
+ telemetryEnabled: input.telemetryEnabled,
+ backgroundLocationTracking: input.backgroundLocationTracking,
+ },
+ location: {
+ raw: toCoordsSnapshot(input.rawLocation),
+ filtered: toCoordsSnapshot(input.filteredLocation),
+ accuracyHistory: input.accuracyHistory,
+ effectiveSpeedMps: input.effectiveSpeedMps,
+ // 変位から算出した値か、測位が運んできた実測かを区別する
+ speedIsMeasured: input.hasMeasuredSpeed,
+ },
+ eta: {
+ phase: input.etaPhase,
+ anchor: input.etaAnchor,
+ },
+ derived: {
+ nextStationId: input.nextStation?.id ?? null,
+ nextStationName: input.nextStation?.name ?? null,
+ distanceToNextStation: input.distanceToNextStation ?? null,
+ },
+});
+
+/** クリップボードへ載せる文字列。読みながら貼れるよう整形する */
+export const formatDevDiagnosticsSnapshot = (
+ input: DevDiagnosticsInput
+): string => JSON.stringify(buildDevDiagnosticsSnapshot(input), null, 2);