From 25e0496d0d18a0db9ee3a49bef4826fcabdacc33 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 12:28:37 +0000 Subject: [PATCH 1/4] =?UTF-8?q?=E5=9C=B0=E4=B8=8B=E9=89=84=E5=88=86?= =?UTF-8?q?=E5=B2=90=E3=81=A7=E3=82=82=E6=B8=AC=E4=BD=8D=E3=83=8E=E3=82=A4?= =?UTF-8?q?=E3=82=BA=E3=82=92=E5=B7=AE=E3=81=97=E5=BC=95=E3=81=84=E3=81=9F?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=E3=83=95=E3=82=A3=E3=83=AB=E3=82=BF=E3=82=92?= =?UTF-8?q?=E6=8E=9B=E3=81=91=E3=81=A6=E6=95=B0km=E5=85=88=E3=81=B8?= =?UTF-8?q?=E3=81=AE=E3=83=AF=E3=83=BC=E3=83=97=E3=82=92=E6=8A=91=E3=81=88?= =?UTF-8?q?=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit setLocationの地下鉄分岐(skipSmoothing)は速度フィルタより手前でreturnしていたため、 LineTypeがSubwayかつ精度不安定のあいだはワープ対策が一切効かず、4km/10秒(≒1540km/h)の 跳躍でもlocationAtomへそのまま入っていた。大江戸線の実座標で流すと新江古田から光が丘へ 1サンプルで飛ぶ。 この分岐が速度フィルタごと外れたのは#5665で、当時は基準がEMA後の座標で追従遅れが変位へ 乗っていたうえ、誤棄却からの脱出口(MAX_CONSECUTIVE_SPEED_REJECTIONS / STALE_REFERENCE_MS) も無く、一度弾き始めると位置が永久に凍結したため。基準は生座標へ移り(#6899)脱出口も 揃ったので、平滑化だけを外してフィルタは通す形へ戻す。#5665が嫌ったノイズ由来の誤棄却は、 変位から両測位の精度ぶんを差し引いてから判定することで避ける(平滑化を通す本線側は 控除しない。1Hz配信では予算100mを精度が上回り、フィルタが実質無効になるため)。 あわせて、連続棄却の上限で基準を張り直す際に「ETAが許す進行量を超えていないこと」を 条件として足した。ETAの打ち切り(ETA_BOUND_MAX_HOLD_MS)は位置を永久に凍結させないための 保険で、打ち切り後は範囲外の測位も受理へ回るため、無条件に張り直すと一貫した誤測位の クラスタが上限回数ぶん粘っただけで基準ごと乗っ取られる。ETAが判断できない場合(無効・ アンカー無し)は意見なしとして従来どおり張り直す。 なお本変更だけでは、同じ誤った座標が届き続けるクラスタは止まらない。許容量は MAX_PLAUSIBLE_SPEED×Δtで、棄却中は基準を更新しないためΔtが伸び続け、いずれ 「そこまで移動できた」側へ入る。実測では大江戸線のワープ到達が1サンプルから50秒 (ETA無効時)へ延びるにとどまる。運動学だけでは張り付いたクラスタを止められないのは #6939の計測どおりで、引き金側(#6969の補完測位が地下で強制する基地局測位)の扱いは別途。 location.etaBound.test.tsの駅間を1.1kmから150mへ縮めた。ETAの進行量上限だけを分離して 測るためのフィクスチャで、駅間が長いと数駅ぶん飛ぶ測位が1〜10秒間隔では物理的に ありえない跳躍になり、ETAではなく速度フィルタが棄却してETA側の挙動を測れなくなる。 各テストが固定しているガード(打ち切りが継続する状態であること等)は変えていない。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sfi8S4Yvob2sEzib4VUUBs --- src/store/atoms/location.etaBound.test.ts | 11 +- .../atoms/location.subwaySpeedBound.test.ts | 204 ++++++++++++++++++ src/store/atoms/location.ts | 184 ++++++++++++---- 3 files changed, 354 insertions(+), 45 deletions(-) create mode 100644 src/store/atoms/location.subwaySpeedBound.test.ts diff --git a/src/store/atoms/location.etaBound.test.ts b/src/store/atoms/location.etaBound.test.ts index 0ee8b299f..93ce94c82 100644 --- a/src/store/atoms/location.etaBound.test.ts +++ b/src/store/atoms/location.etaBound.test.ts @@ -16,9 +16,14 @@ jest.mock('~/lib/remoteConfig', () => ({ })); const METERS_PER_DEG_LAT = 111_320; -const STATION_INTERVAL_M = 1_100; - -// 1.1km間隔で南北に並ぶ10駅の路線 +// 駅間を短く取り、ETAの進行量上限だけを分離して測れるようにする。setLocationには +// 速度フィルタ(ワープ対策)も入っており、地下鉄分岐でも精度ぶんを差し引いたうえで働く。 +// 駅間を長く取ると、ここで使う「数駅ぶん飛ぶ測位」が1〜10秒間隔では物理的にありえない +// 跳躍になり、ETAではなく速度フィルタが棄却するため、ETA側の挙動を測れなくなる。 +// 最大の跳躍(4駅ぶん)でも精度2件ぶん(300m×2)の控除内に収まる値にしてある。 +const STATION_INTERVAL_M = 150; + +// 150m間隔で南北に並ぶ10駅の路線 const stations: Station[] = Array.from( { length: 10 }, (_, i) => diff --git a/src/store/atoms/location.subwaySpeedBound.test.ts b/src/store/atoms/location.subwaySpeedBound.test.ts new file mode 100644 index 000000000..9a90944cf --- /dev/null +++ b/src/store/atoms/location.subwaySpeedBound.test.ts @@ -0,0 +1,204 @@ +/** + * 地下鉄分岐(skipSmoothing)を通る測位にも、ワープ対策の速度フィルタが掛かることを固定する。 + * + * 分岐そのものは #5661 が平滑化を外すために入れたもので、#5665 が速度フィルタごと外した。 + * 当時は基準がEMA後の座標で追従遅れが変位へ乗っていたうえ、誤棄却からの脱出口 + * (MAX_CONSECUTIVE_SPEED_REJECTIONS / STALE_REFERENCE_MS)も無く、一度弾き始めると位置が + * 永久に凍結したためで、フィルタが不要と判断されたわけではない。基準が生座標へ移り + * (#6899)脱出口も揃ったので、測位ノイズぶんを差し引いたうえで検査を掛ける。 + * + * 座標は都営大江戸線の実測値(StationAPI lineStations)。合成の等間隔路線だと「数km離れた + * 駅へ張り付く」という地下鉄の誤測位の距離感が出ず、控除量の妥当性を測れない。 + * + * 限界も明記しておく。許容量は MAX_PLAUSIBLE_SPEED×Δt で、棄却中は基準を更新しないため + * Δtが伸び続ける。同じ誤った座標が届き続けるクラスタは、いずれ「そこまで移動できた」側へ + * 入って受理される(このファイルでは固定しない)。運動学だけでは張り付いたクラスタは + * 止められないというのが #6939 の計測結果で、そこはETAの進行量上限が受け持つ。 + */ +import type * as Location from 'expo-location'; +import { LineType, type Station } from '~/@types/graphql'; +import { store } from '..'; +import { etaAnchorAtom, etaStopsAtom } from './etaFallback'; +import { locationAtom, resetLocationState, setLocation } from './location'; +import stationState from './station'; + +let mockEtaAssistEnabled = false; +jest.mock('~/lib/remoteConfig', () => ({ + isEtaAssistEnabled: () => mockEtaAssistEnabled, + getEtaFallbackArrivalConfirmMarginSec: () => 30, + getMaxPermitAccuracy: () => 1500, + isForceNotArrivedOnLowAccuracyEnabled: () => true, +})); + +// 都営大江戸線 落合南長崎→光が丘(進行方向順)。新江古田→光が丘は約4.1km離れている。 +const OEDO_STATIONS: { id: number; name: string; lat: number; lon: number }[] = + [ + { id: 9930133, name: '落合南長崎', lat: 35.723608, lon: 139.683303 }, + { id: 9930134, name: '新江古田', lat: 35.732538, lon: 139.670653 }, + { id: 9930135, name: '練馬', lat: 35.737404, lon: 139.65477 }, + { id: 9930136, name: '豊島園', lat: 35.742567043044, lon: 139.64894845621 }, + { id: 9930137, name: '練馬春日町', lat: 35.751452, lon: 139.640236 }, + { id: 9930138, name: '光が丘', lat: 35.758526, lon: 139.628603 }, + ]; + +const stations: Station[] = OEDO_STATIONS.map( + (s) => + ({ + id: s.id, + name: s.name, + latitude: s.lat, + longitude: s.lon, + }) as Station +); + +const at = (name: string) => { + const found = OEDO_STATIONS.find((s) => s.name === name); + if (!found) { + throw new Error(`駅が見つかりません: ${name}`); + } + return found; +}; + +// 坑口付近の基地局測位の帯(scripts/generate-location-gpx.mjs の portalAccuracy)。 +// BAD_ACCURACY_THRESHOLD(200m)を超えるので地下鉄分岐へ入る。 +const PORTAL_ACCURACY = 300; + +const makeLocation = ( + lat: number, + lon: number, + timestamp: number, + accuracy = PORTAL_ACCURACY +): Location.LocationObject => ({ + coords: { + latitude: lat, + longitude: lon, + accuracy, + altitude: null, + altitudeAccuracy: null, + heading: null, + speed: null, + }, + timestamp, +}); + +const setLineType = (lineType: LineType) => { + store.set(stationState, { + ...store.get(stationState), + station: { line: { lineType } } as Station, + stations, + }); +}; + +const currentLatLon = () => { + const location = store.get(locationAtom); + return location + ? [location.coords.latitude, location.coords.longitude] + : null; +}; + +const T0 = 1_700_000_000_000; + +beforeEach(() => { + mockEtaAssistEnabled = false; + resetLocationState(); + store.set(etaAnchorAtom, null); + store.set(etaStopsAtom, []); + setLineType(LineType.Subway); +}); + +afterEach(() => { + jest.clearAllMocks(); +}); + +describe('地下鉄分岐の速度フィルタ', () => { + const shinegota = at('新江古田'); + const hikarigaoka = at('光が丘'); + + it('数km先の駅へ張り付いた測位を1件目で棄却する', () => { + setLocation(makeLocation(shinegota.lat, shinegota.lon, T0)); + + // 10秒後に約4.1km先(光が丘)の座標が届く ≒ 1470km/h + setLocation(makeLocation(hikarigaoka.lat, hikarigaoka.lon, T0 + 10_000)); + + expect(currentLatLon()).toEqual([shinegota.lat, shinegota.lon]); + }); + + it('精度で説明が付く範囲の揺れは棄却しない', () => { + // 精度300mの測位2件ぶん(=600m)までは測位ノイズで説明が付くため、1秒間隔でも通す。 + // ここを棄却すると #5665 が分岐ごとフィルタを外す原因になった凍結が再発する。 + setLocation(makeLocation(shinegota.lat, shinegota.lon, T0)); + + const jittered = [shinegota.lat + 0.004, shinegota.lon] as const; + setLocation(makeLocation(jittered[0], jittered[1], T0 + 1_000)); + + expect(currentLatLon()).toEqual([jittered[0], jittered[1]]); + }); + + it('受理した測位は次の測位を検査する基準として残る', () => { + // 基準を更新しないと、地下にいるあいだフィルタが一度も働かない。 + setLocation(makeLocation(shinegota.lat, shinegota.lon, T0)); + // 2件目を受理させる(ノイズ相当の揺れ) + setLocation(makeLocation(shinegota.lat + 0.001, shinegota.lon, T0 + 1_000)); + // 3件目はその2件目を基準に検査される + setLocation(makeLocation(hikarigaoka.lat, hikarigaoka.lon, T0 + 11_000)); + + expect(currentLatLon()).toEqual([shinegota.lat + 0.001, shinegota.lon]); + }); + + it('地上へ戻った最初の測位はEMAを掛けずに基準を張り直す', () => { + // 地下鉄分岐はEMAの基準(lastFilteredLocationAtom)を残さないので、地上復帰後の + // 1件目はノイジーな地下の座標と混ざらず、生の座標がそのまま入る。 + setLocation(makeLocation(shinegota.lat, shinegota.lon, T0)); + + setLineType(LineType.Normal); + const surfaced = at('練馬'); + setLocation(makeLocation(surfaced.lat, surfaced.lon, T0 + 60_000, 20)); + + expect(currentLatLon()).toEqual([surfaced.lat, surfaced.lon]); + }); +}); + +describe('連続棄却による基準の張り直し', () => { + const shinegota = at('新江古田'); + const hikarigaoka = at('光が丘'); + + /** 上限回数ぶん、同じ誤った座標を短い間隔で送り込む */ + const feedRejectedCluster = () => { + setLocation(makeLocation(shinegota.lat, shinegota.lon, T0)); + for (let i = 1; i <= 6; i += 1) { + setLocation( + makeLocation(hikarigaoka.lat, hikarigaoka.lon, T0 + i * 2_000) + ); + } + }; + + it('ETAが判断できない場合は従来どおり張り直す', () => { + // ETA補助が無効・アンカーが無い状況では「そこまで進めない」と言える材料が無い。 + // 位置が凍結したまま復帰できなくなるのを避けるため、上限回数で基準を張り直す。 + feedRejectedCluster(); + + expect(currentLatLon()).toEqual([hikarigaoka.lat, hikarigaoka.lon]); + }); + + it('ETAが「そこまで進んでいるはずがない」と言う測位では張り直さない', () => { + mockEtaAssistEnabled = true; + store.set( + etaStopsAtom, + OEDO_STATIONS.map((s, i) => ({ + stationId: s.id, + cumulativeMinutes: i * 2, + departureCumulativeMinutes: i * 2 + 0.5, + })) + ); + // 新江古田を発車した直後。ETAの許容は次の停車駅(練馬)の1つ先(豊島園)まで。 + store.set(etaAnchorAtom, { + stationId: at('新江古田').id, + kind: 'DEPARTED', + observedAtMs: T0, + }); + + feedRejectedCluster(); + + expect(currentLatLon()).toEqual([shinegota.lat, shinegota.lon]); + }); +}); diff --git a/src/store/atoms/location.ts b/src/store/atoms/location.ts index 1edb9341c..e92f85b47 100644 --- a/src/store/atoms/location.ts +++ b/src/store/atoms/location.ts @@ -107,10 +107,11 @@ export const backgroundLocationTrackingAtom = atom(false); export const locationAccuracyOutlierAtom = atom(false); // EMAスムージングの基準として使う「最後にフィルタ処理を通過した位置」 -// 地下鉄モード中は更新しないため、モード復帰後にノイジーなprevで誤棄却されるのを防ぐ +// 地下鉄モード中はnullへ落とすため、モード復帰後の最初の測位がノイジーなprevと混ざらない const lastFilteredLocationAtom = atom(null); -// 速度フィルタの基準として使う「最後に受理した“生の”座標」。 +// 速度フィルタの基準として使う「最後に受理した“生の”座標」。地下鉄モード中も更新する +// (更新しないと地下にいるあいだ検査の相手が無く、速度フィルタが一度も働かない)。 // EMA後の座標を基準にすると、EMAの追従遅れ(定速時 ((1-α)/α)·v·dt)が変位へ上乗せ // され、算出速度が実速度の 1/α 倍に膨らむ。実効的なしきい値が α×360km/h まで下がり、 // 精度が良くても288km/h、精度200m超では108km/hで棄却が始まるため、新幹線の320km/h @@ -182,6 +183,43 @@ const resetEtaBoundHold = () => { etaBoundBypassedContext = null; }; +/** ETAの進行量上限の素の判定。打ち切り(ETA_BOUND_MAX_HOLD_MS)の状態は見ない。 */ +const evaluateEtaProgressBound = ( + anchorStationId: number, + targetStationId: number, + location: Location.LocationObject +): boolean => + isBeyondEtaProgress({ + stations: store.get(stationState).stations, + anchorStationId, + targetStationId, + latitude: location.coords.latitude, + longitude: location.coords.longitude, + toleranceStations: ETA_BOUND_TOLERANCE_STATIONS, + // 許容は停車駅単位で数える。stationsは通過駅を含むため、ETA側の停車駅リストを + // 渡さないと急行の通過駅ぶんだけ許容が目減りする。 + stopStationIds: store.get(etaStopsAtom).map((s) => s.stationId), + }); + +/** + * ETAから見て「そこまで進んでいるはずがない」位置か。打ち切りの状態を見ない素の判定で、 + * ETAが無効・アンカーが無い等で判断できない場合は false(=意見なし)を返す。 + * + * isImplausibleByEta と違い、こちらは「受理するか」ではなく「基準として採用してよいか」を + * 決めるために使う。打ち切りは位置が凍結し続けないための保険なので、打ち切り中でも + * 「ETAはここまで進めないと言っている」という事実自体は残り、基準の張り直しには使える。 + */ +const isBeyondEtaProgressNow = (location: Location.LocationObject): boolean => { + const anchor = store.get(etaAnchorAtom); + const phase = getEtaPhaseNow(location.timestamp); + if (!anchor || !phase) { + return false; + } + const targetStationId = + phase.kind === 'DWELLING' ? phase.stationId : phase.targetStationId; + return evaluateEtaProgressBound(anchor.stationId, targetStationId, location); +}; + /** * ETAが許す進行量を超えた測位か。超えていれば受理せず、位置を据え置く。 * ETAは位置を進めない(#6369の方針)ので、棄却にのみ使う。 @@ -203,17 +241,11 @@ const isImplausibleByEta = (location: Location.LocationObject): boolean => { resetEtaBoundHold(); } - const beyond = isBeyondEtaProgress({ - stations: store.get(stationState).stations, - anchorStationId: anchor.stationId, + const beyond = evaluateEtaProgressBound( + anchor.stationId, targetStationId, - latitude: location.coords.latitude, - longitude: location.coords.longitude, - toleranceStations: ETA_BOUND_TOLERANCE_STATIONS, - // 許容は停車駅単位で数える。stationsは通過駅を含むため、ETA側の停車駅リストを - // 渡さないと急行の通過駅ぶんだけ許容が目減りする。 - stopStationIds: store.get(etaStopsAtom).map((s) => s.stationId), - }); + location + ); if (!beyond) { // 範囲内の測位が届いた=ETAと実測が再び噛み合った resetEtaBoundHold(); @@ -236,6 +268,72 @@ const isImplausibleByEta = (location: Location.LocationObject): boolean => { return true; }; +/** + * ノイズ控除に使える精度(m)。未取得・非数・負値はノイズの大きさを表さないので0として扱う。 + * NaNをそのまま控除に使うと比較が常にfalseになり、フィルタが静かに無効化される。 + */ +const usableAccuracy = (accuracy: number | null | undefined): number => + accuracy != null && Number.isFinite(accuracy) && accuracy > 0 ? accuracy : 0; + +/** + * 見かけ速度が物理的にありえない跳躍か。判定は生座標同士で行う(lastRawLocationAtom参照)。 + * + * noiseMarginMeters は「変位のうち測位ノイズで説明が付く量」で、これを差し引いた残りだけを + * 実際の移動とみなす。地下鉄分岐は平滑化を掛けない生の座標を相手にするため、控除が無いと + * ノイズだけで閾値へ届き、#5665 が分岐ごとフィルタを外す原因になった誤棄却が再発する。 + * 平滑化を通す本線側は控除しない(0を渡す)。精度が安定した帯でしか通らない経路なので、 + * 控除すると 1Hz 配信では予算(MAX_PLAUSIBLE_SPEED×Δt=100m)を精度が上回り、フィルタが + * 実質無効になる。 + */ +const isImplausibleBySpeed = ( + location: Location.LocationObject, + rawPrev: Location.LocationObject, + noiseMarginMeters: number +): boolean => { + const dt = (location.timestamp - rawPrev.timestamp) / 1000; // 秒 + if (dt <= 0) { + return false; + } + const dist = getDistance( + { + latitude: rawPrev.coords.latitude, + longitude: rawPrev.coords.longitude, + }, + { + latitude: location.coords.latitude, + longitude: location.coords.longitude, + } + ); + const explainedByNoise = Math.max(dist - noiseMarginMeters, 0); + return explainedByNoise / dt > MAX_PLAUSIBLE_SPEED; +}; + +/** 速度フィルタが棄却した測位の後始末。棄却を数え、上限に達したら基準を張り直す。 */ +const handleSpeedRejection = ( + location: Location.LocationObject, + updatedHistory: number[] +) => { + consecutiveSpeedRejections += 1; + // 棄却が続くのは基準側が誤っている可能性が高い。位置が凍結したまま復帰できなく + // なるのを避けるため、上限に達したら届いた測位で基準を張り直す。 + // + // ただしETAが「そこまで進んでいるはずがない」と言える測位では張り直さない。 + // ETA側の打ち切り(ETA_BOUND_MAX_HOLD_MS)は「位置を永久に凍結させない」ための保険で、 + // 打ち切り後は範囲外の測位も受理へ回るため、ここで無条件に基準を張り直すと + // 一貫した誤測位のクラスタが上限回数ぶん粘っただけで基準ごと乗っ取られる + // (地下鉄で数駅先へ飛んで戻らない)。ETAが判断できない場合(無効・アンカー無し)は + // 意見なしとして従来どおり張り直す。張り直さなかった場合も棄却数は数え続けるので、 + // ETAが認める測位が届いた時点で即座に張り直される。 + if ( + consecutiveSpeedRejections >= MAX_CONSECUTIVE_SPEED_REJECTIONS && + !isBeyondEtaProgressNow(location) + ) { + resyncLocationReference(location, updatedHistory); + return; + } + store.set(accuracyHistoryAtom, updatedHistory); +}; + // 受理した測位が反映される唯一の入口。継続測位の正常系に加え、ワンショット取得や // 手動選択(StationSearchModal/useInitialNearbyStation/Privacy等)もここを通る。 export const setLocation = (location: Location.LocationObject) => { @@ -268,13 +366,37 @@ export const setLocation = (location: Location.LocationObject) => { return; } - // スムージングスキップ時はフィルタ・スムージングを全てスキップする - // UIには生の座標を反映するが、EMA基準(lastFilteredLocationAtom)も速度フィルタ基準 - // (lastRawLocationAtom)も更新しない。地上復帰時は基準が古いためSTALE_REFERENCE_MSの - // 判定に掛かり、そこで張り直される + // スムージングスキップ時はEMAを掛けず、UIへ生の座標をそのまま反映する。 + // + // 平滑化だけを外し、ワープ対策の速度フィルタは通す。#5665 がこの分岐ごと速度フィルタを + // 外したのは、当時の基準がEMA後の座標で追従遅れが変位へ乗っていたうえ、誤棄却からの + // 脱出口(MAX_CONSECUTIVE_SPEED_REJECTIONS / STALE_REFERENCE_MS)も無く、一度弾き始めると + // 位置が永久に凍結したためで、フィルタが不要と判断されたわけではない。基準は生座標へ + // 移り(lastRawLocationAtom)、脱出口も揃った現在は、測位ノイズぶんを差し引いた妥当性検査を + // 掛けられる。掛けないと、地下で基地局測位が数km離れた駅へ張り付いたときに、その座標が + // 検査を一切受けずにlocationAtomへ入る。 if (skipSmoothing) { + if ( + rawPrev != null && + isImplausibleBySpeed( + location, + rawPrev, + // ノイズで説明が付く量は両測位の精度の和とみなす + usableAccuracy(rawPrev.coords.accuracy) + usableAccuracy(newAccuracy) + ) + ) { + handleSpeedRejection(location, updatedHistory); + return; + } store.set(locationAtom, location); + // 速度フィルタの基準としては維持する。維持しないと次の測位を検査する相手が無く、 + // 地下にいるあいだフィルタが一度も働かない。 + store.set(lastRawLocationAtom, location); + // EMAの基準にはしない。地上復帰後の最初の測位はノイジーなこの座標と混ぜず、 + // 基準が無い場合の経路(resyncLocationReference)で張り直させる。 + store.set(lastFilteredLocationAtom, null); store.set(accuracyHistoryAtom, updatedHistory); + consecutiveSpeedRejections = 0; return; } @@ -291,32 +413,10 @@ export const setLocation = (location: Location.LocationObject) => { // (下のSTALE_REFERENCE_MS判定)だけで、変位÷経過時間という速度の妥当性検査は // 経過時間が延びても成立するため。ここを飛ばすと、間隔が空いた直後の1点に限って // ワープ対策が無効になる。 - const dt = (location.timestamp - rawPrev.timestamp) / 1000; // 秒 - if (dt > 0) { - const dist = getDistance( - { - latitude: rawPrev.coords.latitude, - longitude: rawPrev.coords.longitude, - }, - { - latitude: location.coords.latitude, - longitude: location.coords.longitude, - } - ); - const speed = dist / dt; - - // 物理的にありえない速度の場合は座標を棄却し、前回値を維持する - if (speed > MAX_PLAUSIBLE_SPEED) { - consecutiveSpeedRejections += 1; - // 棄却が続くのは基準側が誤っている可能性が高い。位置が凍結したまま - // 復帰できなくなるのを避けるため、上限に達したら基準を張り直す。 - if (consecutiveSpeedRejections >= MAX_CONSECUTIVE_SPEED_REJECTIONS) { - resyncLocationReference(location, updatedHistory); - return; - } - store.set(accuracyHistoryAtom, updatedHistory); - return; - } + // 物理的にありえない速度の場合は座標を棄却し、前回値を維持する + if (isImplausibleBySpeed(location, rawPrev, 0)) { + handleSpeedRejection(location, updatedHistory); + return; } consecutiveSpeedRejections = 0; From 36bc8b487fb6e15593fda1d9fd373137f411e0d9 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:21:14 +0000 Subject: [PATCH 2/4] =?UTF-8?q?EMA=E3=81=AE=E5=9F=BA=E6=BA=96=E3=81=8C?= =?UTF-8?q?=E7=84=A1=E3=81=84=E3=81=BE=E3=81=BE=E6=9C=AC=E7=B5=8C=E8=B7=AF?= =?UTF-8?q?=E3=81=B8=E7=A7=BB=E3=81=A3=E3=81=9F=E6=B8=AC=E4=BD=8D=E3=82=82?= =?UTF-8?q?=E9=80=9F=E5=BA=A6=E3=83=95=E3=82=A3=E3=83=AB=E3=82=BF=E3=81=A7?= =?UTF-8?q?=E6=A4=9C=E6=9F=BB=E3=81=99=E3=82=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 地下鉄分岐がlastFilteredLocationAtomをnullへ落とすため、精度履歴が安定して skipSmoothingが偽になった瞬間、filteredPrevがnullでrawPrevだけが残る状態になる。 この状態は「基準が無い」経路(resyncLocationReference)へ入り、届いた測位が 無検査で受理されていた。地下鉄分岐で棄却が続いている最中でもここへ移れるため、 連続棄却の上限(MAX_CONSECUTIVE_SPEED_REJECTIONS)も回避される。 精度20mの測位を受理したあと異常測位を3件送ると、accuracyHistoryAtomが4件に達して isAccuracyStableが真になり、3件目の異常測位がそのままlocationAtomへ入る。 既存のテストは精度300mを使っており精度履歴が安定しないため、この遷移を踏んでいなかった。 rawPrevの有無とfilteredPrevの有無を別の分岐に分け、filteredPrevだけが無い場合は 速度フィルタを通してからスナップする。基準が地下鉄分岐由来のノイジーな座標で ありうるので、控除量は地下鉄分岐と同じ「両測位の精度の和」を使う。控除の計算は 両分岐で共有する。 CodeRabbitの指摘(#6981)への対応。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sfi8S4Yvob2sEzib4VUUBs --- .../atoms/location.subwaySpeedBound.test.ts | 14 +++++++++ src/store/atoms/location.ts | 31 ++++++++++++++----- 2 files changed, 37 insertions(+), 8 deletions(-) diff --git a/src/store/atoms/location.subwaySpeedBound.test.ts b/src/store/atoms/location.subwaySpeedBound.test.ts index 9a90944cf..630060611 100644 --- a/src/store/atoms/location.subwaySpeedBound.test.ts +++ b/src/store/atoms/location.subwaySpeedBound.test.ts @@ -145,6 +145,20 @@ describe('地下鉄分岐の速度フィルタ', () => { expect(currentLatLon()).toEqual([shinegota.lat + 0.001, shinegota.lon]); }); + it('精度履歴が安定して本経路へ移った瞬間の測位も検査される', () => { + // 地下鉄分岐はEMAの基準を残さないので、精度履歴が安定して本経路へ移ると + // 「基準が無い」経路へ入る。ここを素通りさせると、棄却が続いている最中に + // 異常測位が無検査で受理され、連続棄却の上限も回避される。 + setLocation(makeLocation(shinegota.lat, shinegota.lon, T0, 20)); + // 履歴が4件に届くまでは地下鉄分岐。速度フィルタが棄却する + setLocation(makeLocation(hikarigaoka.lat, hikarigaoka.lon, T0 + 2_000, 20)); + setLocation(makeLocation(hikarigaoka.lat, hikarigaoka.lon, T0 + 4_000, 20)); + // 4件目で isAccuracyStable が真になり、本経路(filteredPrev == null)へ移る + setLocation(makeLocation(hikarigaoka.lat, hikarigaoka.lon, T0 + 6_000, 20)); + + expect(currentLatLon()).toEqual([shinegota.lat, shinegota.lon]); + }); + it('地上へ戻った最初の測位はEMAを掛けずに基準を張り直す', () => { // 地下鉄分岐はEMAの基準(lastFilteredLocationAtom)を残さないので、地上復帰後の // 1件目はノイジーな地下の座標と混ざらず、生の座標がそのまま入る。 diff --git a/src/store/atoms/location.ts b/src/store/atoms/location.ts index e92f85b47..654d4f4b2 100644 --- a/src/store/atoms/location.ts +++ b/src/store/atoms/location.ts @@ -360,6 +360,12 @@ export const setLocation = (location: Location.LocationObject) => { const skipSmoothing = currentLineType === LineType.Subway && !isAccuracyStable(updatedHistory); + // 変位のうち測位ノイズで説明が付く量。基準側と今回の精度の和で見積もる。 + // 平滑化を通さない経路(地下鉄分岐と、その直後にEMA基準が無いまま本経路へ移った場合)は + // 生のノイズをそのまま相手にするため、この控除が要る。 + const noiseMarginMeters = + usableAccuracy(rawPrev?.coords.accuracy) + usableAccuracy(newAccuracy); + // ETAが許す進行量を超えた測位は、どちらの経路へも通さない if (isImplausibleByEta(location)) { store.set(accuracyHistoryAtom, updatedHistory); @@ -378,12 +384,7 @@ export const setLocation = (location: Location.LocationObject) => { if (skipSmoothing) { if ( rawPrev != null && - isImplausibleBySpeed( - location, - rawPrev, - // ノイズで説明が付く量は両測位の精度の和とみなす - usableAccuracy(rawPrev.coords.accuracy) + usableAccuracy(newAccuracy) - ) + isImplausibleBySpeed(location, rawPrev, noiseMarginMeters) ) { handleSpeedRejection(location, updatedHistory); return; @@ -400,8 +401,22 @@ export const setLocation = (location: Location.LocationObject) => { return; } - // 基準が無い場合(初回起動時や地下鉄→地上の復帰直後) - if (filteredPrev == null || rawPrev == null) { + // 基準が無い場合(初回起動時) + if (rawPrev == null) { + resyncLocationReference(location, updatedHistory); + return; + } + + // 速度フィルタの基準はあるがEMAの基準が無い場合(地下鉄分岐からの復帰直後)。 + // 平滑化はできないので生の測位へスナップするが、妥当性の検査は通す。 + // 素通りさせると、地下鉄分岐で棄却が続いている最中に精度履歴が安定して本経路へ + // 移った瞬間、その測位が無検査で受理され、連続棄却の上限(#6899)も回避される。 + // 基準が地下鉄分岐由来のノイジーな座標でありうるので、控除は地下鉄分岐と同じにする。 + if (filteredPrev == null) { + if (isImplausibleBySpeed(location, rawPrev, noiseMarginMeters)) { + handleSpeedRejection(location, updatedHistory); + return; + } resyncLocationReference(location, updatedHistory); return; } From 05a306faba45ce515200f1d9cc95a6a425b250f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:37:34 +0000 Subject: [PATCH 3/4] =?UTF-8?q?DevOverlay=E3=81=AB=E8=A8=BA=E6=96=AD?= =?UTF-8?q?=E6=83=85=E5=A0=B1=E3=82=92=E3=82=AF=E3=83=AA=E3=83=83=E3=83=97?= =?UTF-8?q?=E3=83=9C=E3=83=BC=E3=83=89=E3=81=B8=E3=82=B3=E3=83=94=E3=83=BC?= =?UTF-8?q?=E3=81=99=E3=82=8B=E3=83=9C=E3=82=BF=E3=83=B3=E3=82=92=E8=BF=BD?= =?UTF-8?q?=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 地下のワープ調査で必要になる値を、画面から読み上げる代わりに丸ごと持ち出せるようにする。 テレメトリは本番で無効、かつ送っているのはlocationAtom(フィルタ後)だけなので、 生の精度・実効設定を取り出す手段が無かった。 ヘッダーのステータスピル横にCOPYボタンを置き、押すと整形済みJSONをクリップボードへ載せる。 含めるのは、フィルタ前後の測位(座標・精度・速度・タイムスタンプ)、精度履歴、ETAの フェーズとアンカー、次駅と距離、ビルド情報、そして実効設定(max_permit_accuracy / eta_assist_enabled / オートモード / テレメトリ / バックグラウンド測位)。設定が分からないと 同じ測位でも挙動を説明できないため、座標と必ずセットで持ち出す。 JSONの組み立てはsrc/utils/devDiagnosticsSnapshot.tsへ純関数として切り出した (DevOverlay.tsxが既に1100行あるため)。タイムスタンプを持たない測位が届いても 例外を出さないよう、ISO文字列化はnullへ倒す。診断情報の持ち出しで落ちては本末転倒なため。 クリップボードはreact-native coreのClipboardを使う。core から切り出され将来削除が 予告されている非推奨APIだが、expo-clipboardの追加はネイティブモジュールの追加になり Devクライアントのリビルドとロックファイルの更新を伴うため、まずは依存を増やさない。 呼び出しをsrc/utils/clipboard.tsの1関数へ閉じてあるので、移行時はそこだけ差し替えればよい。 パネルのPanResponderはcaptureを使っていないため子のPressableが先にタッチを取り、 展開/折りたたみのトグルとは競合しない。折りたたみ中は上に載るcollapsedOverlayが タッチを受けるので押せない。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sfi8S4Yvob2sEzib4VUUBs --- src/components/DevOverlay.test.tsx | 52 +++++++- src/components/DevOverlay.tsx | 89 +++++++++++++ src/utils/clipboard.ts | 16 +++ src/utils/devDiagnosticsSnapshot.test.ts | 152 +++++++++++++++++++++++ src/utils/devDiagnosticsSnapshot.ts | 129 +++++++++++++++++++ 5 files changed, 437 insertions(+), 1 deletion(-) create mode 100644 src/utils/clipboard.ts create mode 100644 src/utils/devDiagnosticsSnapshot.test.ts create mode 100644 src/utils/devDiagnosticsSnapshot.ts diff --git a/src/components/DevOverlay.test.tsx b/src/components/DevOverlay.test.tsx index e4d2fb9b0..60a0e6524 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,47 @@ describe('DevOverlay', () => { }); }); + describe('診断情報のコピー', () => { + it('ボタンを押すと診断情報をクリップボードへ載せる', () => { + const { getByTestId } = render(); + + fireEvent.press(getByTestId('dev-overlay-copy-button')); + + 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表示になり、一定時間で戻る', () => { + jest.useFakeTimers(); + try { + const { getByTestId, getByText, queryByText } = render(); + expect(getByText('COPY')).toBeTruthy(); + + fireEvent.press(getByTestId('dev-overlay-copy-button')); + expect(getByText('COPIED')).toBeTruthy(); + + act(() => { + jest.advanceTimersByTime(1500); + }); + expect(queryByText('COPIED')).toBeNull(); + expect(getByText('COPY')).toBeTruthy(); + } finally { + jest.useRealTimers(); + } + }); + }); + 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..6099404d0 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,55 @@ 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 = () => { + 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, + }) + ); + setHasCopied(true); + if (copiedTimerRef.current !== null) { + clearTimeout(copiedTimerRef.current); + } + 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 +920,25 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { value={backgroundValue} style={statusPillStyle} /> + {/* パネルのPanResponderはcaptureを使っていないため、子のPressableが + 先にタッチを取る。展開/折りたたみのトグルとは競合しない。 + 折りたたみ中は上に載るcollapsedOverlayがタッチを受けるので押せない。 */} + [ + 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..d34cdd017 --- /dev/null +++ b/src/utils/clipboard.ts @@ -0,0 +1,16 @@ +import { Clipboard } from 'react-native'; + +/** + * 文字列をクリップボードへ載せる。 + * + * react-native の Clipboard は core から切り出され「将来のリリースで削除する」と + * 予告されている(参照するとその旨の警告が出る)。本来は expo-clipboard へ移すべきだが、 + * ネイティブモジュールの追加になり、Devクライアントのリビルドとロックファイルの更新を伴う。 + * まずは依存を増やさずに済むこちらを使う。呼び出し側をこの1か所に閉じてあるので、 + * expo-clipboard を入れるときはこの関数の中だけを差し替えればよい。 + * + * 用途は DevOverlay の診断情報の持ち出しに限る(本番の画面からは呼ばない)。 + */ +export const copyTextToClipboard = (text: string): void => { + Clipboard.setString(text); +}; 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); From 9546096f06d1c8554f7e943b5f885f9719d8f320 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 15 Sep 2026 13:51:26 +0000 Subject: [PATCH 4/4] =?UTF-8?q?DevOverlay=E3=81=AE=E8=A8=BA=E6=96=AD?= =?UTF-8?q?=E6=83=85=E5=A0=B1=E3=82=B3=E3=83=94=E3=83=BC=E3=83=9C=E3=82=BF?= =?UTF-8?q?=E3=83=B3=E3=82=92=E6=9C=AC=E3=83=96=E3=83=A9=E3=83=B3=E3=83=81?= =?UTF-8?q?=E3=81=8B=E3=82=89=E5=A4=96=E3=81=97=E5=88=A5PR=E3=81=B8?= =?UTF-8?q?=E5=88=86=E9=9B=A2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 05a306f の差し戻し。測位フィルタの変更とは別件のため、 feature/devoverlay-copy-diagnostics へ切り出して別PRとした。 履歴を書き換えずに外すため、force pushではなくrevertで戻す。 Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01Sfi8S4Yvob2sEzib4VUUBs --- src/components/DevOverlay.test.tsx | 52 +------- src/components/DevOverlay.tsx | 89 ------------- src/utils/clipboard.ts | 16 --- src/utils/devDiagnosticsSnapshot.test.ts | 152 ----------------------- src/utils/devDiagnosticsSnapshot.ts | 129 ------------------- 5 files changed, 1 insertion(+), 437 deletions(-) delete mode 100644 src/utils/clipboard.ts delete mode 100644 src/utils/devDiagnosticsSnapshot.test.ts delete mode 100644 src/utils/devDiagnosticsSnapshot.ts diff --git a/src/components/DevOverlay.test.tsx b/src/components/DevOverlay.test.tsx index 60a0e6524..e4d2fb9b0 100644 --- a/src/components/DevOverlay.test.tsx +++ b/src/components/DevOverlay.test.tsx @@ -1,4 +1,4 @@ -import { act, fireEvent, render } from '@testing-library/react-native'; +import { act, render } from '@testing-library/react-native'; import * as Application from 'expo-application'; import { useAtomValue } from 'jotai'; import { Dimensions, StyleSheet } from 'react-native'; @@ -55,14 +55,8 @@ 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 @@ -78,9 +72,6 @@ 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'); @@ -300,47 +291,6 @@ describe('DevOverlay', () => { }); }); - describe('診断情報のコピー', () => { - it('ボタンを押すと診断情報をクリップボードへ載せる', () => { - const { getByTestId } = render(); - - fireEvent.press(getByTestId('dev-overlay-copy-button')); - - 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表示になり、一定時間で戻る', () => { - jest.useFakeTimers(); - try { - const { getByTestId, getByText, queryByText } = render(); - expect(getByText('COPY')).toBeTruthy(); - - fireEvent.press(getByTestId('dev-overlay-copy-button')); - expect(getByText('COPIED')).toBeTruthy(); - - act(() => { - jest.advanceTimersByTime(1500); - }); - expect(queryByText('COPIED')).toBeNull(); - expect(getByText('COPY')).toBeTruthy(); - } finally { - jest.useRealTimers(); - } - }); - }); - 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 6099404d0..7a67f909d 100644 --- a/src/components/DevOverlay.tsx +++ b/src/components/DevOverlay.tsx @@ -7,8 +7,6 @@ import { Animated, Easing, PanResponder, - Platform, - Pressable, type StyleProp, StyleSheet, type TextStyle, @@ -31,14 +29,11 @@ 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'; @@ -50,9 +45,6 @@ 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)'; @@ -171,19 +163,6 @@ 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, @@ -523,55 +502,6 @@ 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 = () => { - 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, - }) - ); - setHasCopied(true); - if (copiedTimerRef.current !== null) { - clearTimeout(copiedTimerRef.current); - } - copiedTimerRef.current = setTimeout(() => { - copiedTimerRef.current = null; - setHasCopied(false); - }, COPIED_FEEDBACK_DURATION_MS); - }; // ETA推定フェーズ(RUNNING/APPROACHING/DWELLING)を表示。フェーズ未推定時は IDLE。 const etaFallbackValue = etaPhase?.kind ?? 'IDLE'; // 推定対象の駅ID(走行/接近中は目標駅、停車中は当該駅)。 @@ -920,25 +850,6 @@ const DevOverlay: React.FC = ({ unrotated = false }) => { value={backgroundValue} style={statusPillStyle} /> - {/* パネルのPanResponderはcaptureを使っていないため、子のPressableが - 先にタッチを取る。展開/折りたたみのトグルとは競合しない。 - 折りたたみ中は上に載るcollapsedOverlayがタッチを受けるので押せない。 */} - [ - styles.copyButton, - statusPillStyle, - pressed && styles.copyButtonPressed, - ]} - > - DIAGNOSTICS - - {hasCopied ? 'COPIED' : 'COPY'} - - diff --git a/src/utils/clipboard.ts b/src/utils/clipboard.ts deleted file mode 100644 index d34cdd017..000000000 --- a/src/utils/clipboard.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Clipboard } from 'react-native'; - -/** - * 文字列をクリップボードへ載せる。 - * - * react-native の Clipboard は core から切り出され「将来のリリースで削除する」と - * 予告されている(参照するとその旨の警告が出る)。本来は expo-clipboard へ移すべきだが、 - * ネイティブモジュールの追加になり、Devクライアントのリビルドとロックファイルの更新を伴う。 - * まずは依存を増やさずに済むこちらを使う。呼び出し側をこの1か所に閉じてあるので、 - * expo-clipboard を入れるときはこの関数の中だけを差し替えればよい。 - * - * 用途は DevOverlay の診断情報の持ち出しに限る(本番の画面からは呼ばない)。 - */ -export const copyTextToClipboard = (text: string): void => { - Clipboard.setString(text); -}; diff --git a/src/utils/devDiagnosticsSnapshot.test.ts b/src/utils/devDiagnosticsSnapshot.test.ts deleted file mode 100644 index 9cd9ca299..000000000 --- a/src/utils/devDiagnosticsSnapshot.test.ts +++ /dev/null @@ -1,152 +0,0 @@ -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 deleted file mode 100644 index b1e3f64f5..000000000 --- a/src/utils/devDiagnosticsSnapshot.ts +++ /dev/null @@ -1,129 +0,0 @@ -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);