Harden findmydevice GPS jitter filtering

This commit is contained in:
seo
2026-06-18 04:24:31 +09:00
parent 08bd2ab4a4
commit 25fe661cc1
3 changed files with 117 additions and 27 deletions
+2 -2
View File
@@ -55,9 +55,9 @@ Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그
히스토리 요약 패널은 이동 거리, 기록 시간, 평균 속도, 최대 기록 공백, 지점 수, 체류 지점 수, 집 반경, 집 체류 시간, 외출 시간, 외출 횟수, 마지막 복귀 시각과 현재 상태를 표시합니다. 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다. 히스토리 요약 패널은 이동 거리, 기록 시간, 평균 속도, 최대 기록 공백, 지점 수, 체류 지점 수, 집 반경, 집 체류 시간, 외출 시간, 외출 횟수, 마지막 복귀 시각과 현재 상태를 표시합니다. 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다.
경로선은 Douglas-Peucker 단순화와 가벼운 좌표 smoothing을 거쳐 긴 기간에서도 완만한 단일 선으로 표시합니다. 2km 이상 순간 이동하면서 180km/h를 넘는 GPS 튐은 기본적으로 제외하며, 지도 아래 `GPS 튐 보정` 버튼으로 원본 렌더링 경로와 보정 경로를 전환할 수 있습니다. 경로선은 Douglas-Peucker 단순화와 가벼운 좌표 smoothing을 거쳐 긴 기간에서도 완만한 단일 선으로 표시합니다. `GPS 튐 보정`이 켜져 있으면 집 중심 1.2km 안의 장기 체류성 흔들림은 집 위치로 흡수하고, 집 주변 보정 지점은 30분 단위로 줄여 지도 낙서를 줄입니다. 이전/다음 지점이 가까운데 중간 지점만 250m 이상 튀고 왕복 속도가 80km/h를 넘는 단발 스파이크, 500m 이상 220km/h 초과 이동, 2km 이상 120km/h 초과 이동, 정확도 300m 초과 지점은 제외합니다. 30분 이상 기록 공백은 선을 끊고 이동 거리 합산에서도 제외합니다. 지도 아래 `GPS 튐 보정` 버튼으로 원본 렌더링 경로와 보정 경로를 전환할 수 있습니다.
집 판정은 현재 위치를 중심점으로 한 50m 반경을 기준으로 합니다. 50~90m 구간은 집 근처/불안정 구간으로 두고, 집 밖 상태가 5분 이상 지속될 때만 외출 세션으로 확정합니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다. 집 판정은 현재 위치를 중심점으로 한 50m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있으면 집 주변 1.2km 안의 지점은 집 주변 GPS 흔들림으로 흡수하고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다.
기록 시간 슬라이더를 움직이면 선택 마커가 해당 시점으로 이동하고, 선택 시각과 추정 속도를 지도 아래에 표시합니다. 경로 재생도 같은 선택 마커를 사용합니다. 80m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하며, 클릭 시 체류 시간과 시작/종료 시각을 확인할 수 있습니다. 기록 시간 슬라이더를 움직이면 선택 마커가 해당 시점으로 이동하고, 선택 시각과 추정 속도를 지도 아래에 표시합니다. 경로 재생도 같은 선택 마커를 사용합니다. 80m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하며, 클릭 시 체류 시간과 시작/종료 시각을 확인할 수 있습니다.
+95 -14
View File
@@ -1305,16 +1305,70 @@ function speedLabel(kmh) {
return speedBucket(kmh).label; return speedBucket(kmh).label;
} }
const HOME_RADIUS_M = 50;
const HOME_NEAR_RADIUS_M = 90;
const HOME_JITTER_ABSORB_RADIUS_M = 1200;
const HOME_JITTER_SAMPLE_SECONDS = 30 * 60;
const TRACK_MAX_GAP_SECONDS = 30 * 60;
const AWAY_MAX_GAP_SECONDS = 2 * 60 * 60;
function pointWithLatLng(point, lat, lng, extra = {}) {
return {
...point,
...extra,
lat,
lng,
latlng: new naver.maps.LatLng(lat, lng)
};
}
function normalizeHomeJitterPoint(point) {
if (!mapState.filterOutliers || !mapState.homeCenter || !point) return point;
const distance = distanceFromHome(point);
if (distance > HOME_JITTER_ABSORB_RADIUS_M) return point;
return pointWithLatLng(point, mapState.homeCenter.lat, mapState.homeCenter.lng, {
homeJitter: true,
originalLat: point.lat,
originalLng: point.lng,
originalDistanceFromHome: distance,
});
}
function isSinglePointSpike(prev, cur, next) {
if (!prev || !cur || !next) return false;
const prevNext = distanceMeters(prev, next);
if (prevNext > 180) return false;
const inDistance = distanceMeters(prev, cur);
const outDistance = distanceMeters(cur, next);
if (inDistance < 250 || outDistance < 250) return false;
const inSeconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
const outSeconds = Math.max(1, (toTimestamp(next.time) - toTimestamp(cur.time)) / 1000);
const inKmh = inDistance / inSeconds * 3.6;
const outKmh = outDistance / outSeconds * 3.6;
return inKmh > 80 && outKmh > 80;
}
function filterHistoryOutliers(points) { function filterHistoryOutliers(points) {
if (!mapState.filterOutliers || points.length < 3) return points; if (!mapState.filterOutliers || points.length < 3) return points;
const filtered = [points[0]]; const filtered = [normalizeHomeJitterPoint(points[0])];
for (let i = 1; i < points.length; i++) { for (let i = 1; i < points.length; i++) {
if (i < points.length - 1 && isSinglePointSpike(points[i - 1], points[i], points[i + 1])) {
continue;
}
const prev = filtered[filtered.length - 1]; const prev = filtered[filtered.length - 1];
const cur = points[i]; const cur = normalizeHomeJitterPoint(points[i]);
const seconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000); const seconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
const distance = distanceMeters(prev, cur); const distance = distanceMeters(prev, cur);
const kmh = distance / seconds * 3.6; const kmh = distance / seconds * 3.6;
if (distance > 2000 && kmh > 180) continue; if (distance > 500 && kmh > 220) continue;
if (distance > 2000 && kmh > 120) continue;
if (cur.accuracy && Number(cur.accuracy) > 300 && distance > 300) continue;
if (cur.homeJitter && prev.homeJitter && seconds < HOME_JITTER_SAMPLE_SECONDS) {
continue;
}
filtered.push(cur); filtered.push(cur);
} }
return filtered.length >= 2 ? filtered : points; return filtered.length >= 2 ? filtered : points;
@@ -1389,8 +1443,9 @@ function distanceFromHome(point) {
function classifyHomePoint(point) { function classifyHomePoint(point) {
const distance = distanceFromHome(point); const distance = distanceFromHome(point);
if (distance <= 50) return 'home'; if (distance <= HOME_RADIUS_M) return 'home';
if (distance <= 90) return 'near'; if (mapState.filterOutliers && distance <= HOME_JITTER_ABSORB_RADIUS_M) return 'home';
if (distance <= HOME_NEAR_RADIUS_M) return 'near';
return 'away'; return 'away';
} }
@@ -1405,9 +1460,7 @@ function buildHomeStats(points) {
const sessions = []; const sessions = [];
const closeAwaySession = (returnTime = null) => { const closeAwaySession = (returnTime = null) => {
if (!currentAway) return; if (!currentAway) return;
const endTime = returnTime || points.at(-1).time;
currentAway.returnTime = returnTime; currentAway.returnTime = returnTime;
currentAway.duration = Math.max(0, (toTimestamp(endTime) - toTimestamp(currentAway.departureTime)) / 1000);
if (currentAway.duration >= minAwaySeconds) { if (currentAway.duration >= minAwaySeconds) {
sessions.push(currentAway); sessions.push(currentAway);
if (returnTime) lastReturn = returnTime; if (returnTime) lastReturn = returnTime;
@@ -1420,19 +1473,29 @@ function buildHomeStats(points) {
const cur = points[i]; const cur = points[i];
const duration = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000); const duration = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
if (!duration) continue; if (!duration) continue;
const isLargeGap = duration > AWAY_MAX_GAP_SECONDS;
const prevState = classifyHomePoint(prev); const prevState = classifyHomePoint(prev);
const curState = classifyHomePoint(cur); const curState = classifyHomePoint(cur);
const state = prevState === curState ? curState : (curState === 'home' ? 'home' : (curState === 'away' ? 'away' : 'near')); const state = prevState === 'away' && curState === 'away'
? 'away'
: (prevState === 'home' || curState === 'home' ? 'home' : 'near');
if (state === 'home') { if (state === 'home') {
homeSeconds += duration; homeSeconds += duration;
closeAwaySession(cur.time); closeAwaySession(cur.time);
} else if (state === 'away') { } else if (state === 'away') {
if (isLargeGap) {
closeAwaySession(null);
unstableSeconds += duration;
continue;
}
if (!currentAway) { if (!currentAway) {
currentAway = { departureTime: prev.time, returnTime: null, duration: 0 }; currentAway = { departureTime: prev.time, returnTime: null, duration: 0 };
} }
currentAway.duration += duration;
} else { } else {
closeAwaySession(null);
unstableSeconds += duration; unstableSeconds += duration;
} }
} }
@@ -1473,9 +1536,11 @@ function buildHistoryStats(points) {
let distance = 0; let distance = 0;
let maxGap = 0; let maxGap = 0;
for (let i = 1; i < points.length; i++) { for (let i = 1; i < points.length; i++) {
distance += distanceMeters(points[i - 1], points[i]);
const gap = Math.max(0, (toTimestamp(points[i].time) - toTimestamp(points[i - 1].time)) / 1000); const gap = Math.max(0, (toTimestamp(points[i].time) - toTimestamp(points[i - 1].time)) / 1000);
maxGap = Math.max(maxGap, gap); maxGap = Math.max(maxGap, gap);
if (gap <= TRACK_MAX_GAP_SECONDS) {
distance += distanceMeters(points[i - 1], points[i]);
}
} }
const startedAt = toTimestamp(points[0].time); const startedAt = toTimestamp(points[0].time);
const endedAt = toTimestamp(points.at(-1).time); const endedAt = toTimestamp(points.at(-1).time);
@@ -1518,7 +1583,7 @@ function renderHistorySummary() {
<span>${t.historyMaxGap}</span><strong>${formatDuration(Math.round(stats.maxGap))}</strong> <span>${t.historyMaxGap}</span><strong>${formatDuration(Math.round(stats.maxGap))}</strong>
<span>${t.historyPoints}</span><strong>${stats.rawPoints && stats.rawPoints !== stats.renderPoints ? `${formatNumber(stats.renderPoints)} / ${formatNumber(stats.rawPoints)}` : formatNumber(stats.points)}</strong> <span>${t.historyPoints}</span><strong>${stats.rawPoints && stats.rawPoints !== stats.renderPoints ? `${formatNumber(stats.renderPoints)} / ${formatNumber(stats.rawPoints)}` : formatNumber(stats.points)}</strong>
<span>${t.historyStops}</span><strong>${formatNumber(stats.stops || 0)}</strong> <span>${t.historyStops}</span><strong>${formatNumber(stats.stops || 0)}</strong>
<span>${t.homeRadius}</span><strong>50m</strong> <span>${t.homeRadius}</span><strong>${mapState.filterOutliers ? '50m + GPS 1.2km' : '50m'}</strong>
<span>${t.homeStay}</span><strong>${home ? formatDuration(Math.round(home.homeSeconds)) : '-'}</strong> <span>${t.homeStay}</span><strong>${home ? formatDuration(Math.round(home.homeSeconds)) : '-'}</strong>
<span>${t.awayTime}</span><strong>${home ? formatDuration(Math.round(home.awaySeconds)) : '-'}</strong> <span>${t.awayTime}</span><strong>${home ? formatDuration(Math.round(home.awaySeconds)) : '-'}</strong>
<span>${t.awaySessions}</span><strong>${home ? formatNumber(sessions.length) : '-'}</strong> <span>${t.awaySessions}</span><strong>${home ? formatNumber(sessions.length) : '-'}</strong>
@@ -1640,21 +1705,37 @@ function drawSpeedTrack(points) {
clearTrackLines(); clearTrackLines();
if (!points?.length || points.length < 2) return; if (!points?.length || points.length < 2) return;
const path = points.map(point => point.latlng); const segments = [];
let segment = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const cur = points[i];
const gap = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
if (gap > TRACK_MAX_GAP_SECONDS) {
if (segment.length >= 2) segments.push(segment);
segment = [cur];
continue;
}
segment.push(cur);
}
if (segment.length >= 2) segments.push(segment);
segments.forEach((items, index) => {
const line = new naver.maps.Polyline({ const line = new naver.maps.Polyline({
map: mapState.map, map: mapState.map,
path, path: items.map(point => point.latlng),
strokeColor: '#007aff', strokeColor: '#007aff',
strokeOpacity: 0.86, strokeOpacity: 0.86,
strokeWeight: 5, strokeWeight: 5,
strokeLineCap: 'round', strokeLineCap: 'round',
strokeLineJoin: 'round', strokeLineJoin: 'round',
endIcon: naver.maps.PointingIcon?.BLOCK_ARROW, endIcon: index === segments.length - 1 ? naver.maps.PointingIcon?.BLOCK_ARROW : undefined,
endIconSize: 16, endIconSize: 16,
clickable: false clickable: false
}); });
mapState.trackLines.push(line); mapState.trackLines.push(line);
mapState.trackLine = line; if (!mapState.trackLine) mapState.trackLine = line;
});
} }
function updateTimelinePanel() { function updateTimelinePanel() {
+10 -1
View File
@@ -358,12 +358,19 @@ if ($historySets) {
foreach ($historyData[0] as $item) { foreach ($historyData[0] as $item) {
$lat = $item['attributes']['latitude'] ?? null; $lat = $item['attributes']['latitude'] ?? null;
$lng = $item['attributes']['longitude'] ?? null; $lng = $item['attributes']['longitude'] ?? null;
$accuracy = $item['attributes']['gps_accuracy'] ?? $item['attributes']['accuracy'] ?? null;
$time = $item['last_changed'] ?? null; $time = $item['last_changed'] ?? null;
if ($lat && $lng && $time) { if ($lat && $lng && $time) {
$timeKey = (string)(strtotime((string)$time) ?: $time); $timeKey = (string)(strtotime((string)$time) ?: $time);
$key = $timeKey . ':' . round((float)$lat, 5) . ':' . round((float)$lng, 5); $key = $timeKey . ':' . round((float)$lat, 5) . ':' . round((float)$lng, 5);
if (!isset($pathByKey[$key])) { if (!isset($pathByKey[$key])) {
$pathByKey[$key] = ['lat' => $lat, 'lng' => $lng, 'time' => $time, 'source' => $profile]; $pathByKey[$key] = [
'lat' => $lat,
'lng' => $lng,
'time' => $time,
'accuracy' => is_numeric($accuracy) ? (float)$accuracy : null,
'source' => $profile,
];
} }
} }
} }
@@ -457,6 +464,8 @@ function smoothPath(array $path): array
'lat' => ((float)$prev['lat'] + ((float)$cur['lat'] * 2) + (float)$next['lat']) / 4, 'lat' => ((float)$prev['lat'] + ((float)$cur['lat'] * 2) + (float)$next['lat']) / 4,
'lng' => ((float)$prev['lng'] + ((float)$cur['lng'] * 2) + (float)$next['lng']) / 4, 'lng' => ((float)$prev['lng'] + ((float)$cur['lng'] * 2) + (float)$next['lng']) / 4,
'time' => $cur['time'], 'time' => $cur['time'],
'accuracy' => $cur['accuracy'] ?? null,
'source' => $cur['source'] ?? null,
]; ];
} }