diff --git a/README.md b/README.md index 20dc380..430ea78 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,11 @@ PWA standalone으로 진입한 경우에는 설치형 앱 사용성을 우선해 ## 히스토리와 지도 -실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 자동 위치 이동이 켜져 있어도 새 위치를 받을 때마다 경로선, 종료 지점, 기록 요약을 같은 히스토리 기준으로 다시 그립니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 기간이 길어도 Home Assistant 히스토리 API를 실행한 뒤, 브라우저 렌더링용으로 경로를 부드럽게 단순화해 응답합니다. +실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 자동 위치 이동이 켜져 있어도 새 위치를 받을 때마다 경로선, 종료 지점, 기록 요약을 같은 히스토리 기준으로 다시 그립니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 Home Assistant 히스토리 API를 실행한 뒤 시간과 좌표가 같은 중복 지점만 제거하고, 설정 시간 안의 병합 지점을 전부 응답합니다. 상태 조회는 `seoul`, `main` Home Assistant의 `/states`를 모두 읽어 entity별로 병합합니다. 한쪽에만 의미 있는 값이 있으면 해당 값을 사용하고, 양쪽 모두 값이 있으면 더 최신 `last_updated` 또는 `last_changed` 값을 사용합니다. 선택된 상태의 attributes에 빈 값이 있으면 다른 쪽 attributes로 채워 빈칸을 줄입니다. -히스토리 조회도 `seoul`, `main` Home Assistant history를 모두 읽습니다. 서버는 시간과 좌표를 기준으로 중복 지점을 제거하고 시간순으로 병합한 뒤 기존 경로 단순화와 smoothing을 적용합니다. 응답에는 병합 전 출처별 지점 수인 `historySourceCounts`도 포함합니다. +히스토리 조회도 `seoul`, `main` Home Assistant history를 모두 읽습니다. 서버는 시간과 좌표를 기준으로 중복 지점을 제거하고 시간순으로 병합합니다. 응답에는 병합 전 출처별 지점 수인 `historySourceCounts`도 포함합니다. Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그리고 시작/종료 마커와 체류 지점 마커를 표시합니다. 중간 점 마커는 긴 기간 조회에서 브라우저 부하가 커지므로 표시하지 않습니다. 기록 조회 시 지도는 경로 전체가 보이도록 자동으로 범위를 맞추며, 종료 지점 정보창을 먼저 열어 최근 위치를 바로 확인할 수 있게 합니다. @@ -67,7 +67,7 @@ Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그 히스토리 요약 패널은 이동 거리, 기록 시간, 지도에 실제로 그려진 지점 수를 기본으로 보여줍니다. 체류 지점, 현재 상태, 외출 시간, 외출 횟수, 마지막 복귀 시각은 값이 있을 때만 보여주므로 빈값이나 판단 가치가 낮은 항목은 화면에 남지 않습니다. 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다. -경로선은 Douglas-Peucker 단순화와 가벼운 좌표 smoothing을 거쳐 긴 기간에서도 완만한 단일 선으로 표시합니다. `GPS 튐 보정`이 켜져 있으면 집 중심 1.2km 안의 장기 체류성 흔들림은 집 위치로 흡수하고, 집 주변 보정 지점은 30분 단위로 줄여 지도 낙서를 줄입니다. 이전/다음 지점이 가까운데 중간 지점만 250m 이상 튀고 왕복 속도가 80km/h를 넘는 단발 스파이크, 500m 이상 220km/h 초과 이동, 2km 이상 120km/h 초과 이동, 정확도 300m 초과 지점은 제외합니다. 30분 이상 기록 공백은 선을 끊고 이동 거리 합산에서도 제외합니다. 지도 아래 `GPS 튐 보정` 버튼으로 원본 렌더링 경로와 보정 경로를 전환할 수 있습니다. +경로선은 기본값으로 서버가 내려준 병합 지점을 전부 사용해 표시합니다. `GPS 튐 보정`을 켜면 집 중심 1.2km 안의 장기 체류성 흔들림은 집 위치로 흡수하고, 집 주변 보정 지점은 30분 단위로 줄여 지도 낙서를 줄입니다. 이전/다음 지점이 가까운데 중간 지점만 250m 이상 튀고 왕복 속도가 80km/h를 넘는 단발 스파이크, 500m 이상 220km/h 초과 이동, 2km 이상 120km/h 초과 이동, 정확도 300m 초과 지점은 제외합니다. 30분 이상 기록 공백은 선을 끊고 이동 거리 합산에서도 제외합니다. 지도 아래 `GPS 튐 보정` 버튼으로 전체 지점 경로와 보정 경로를 전환할 수 있습니다. 집 판정은 설정된 집 좌표를 중심점으로 한 50m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있으면 집 주변 1.2km 안의 지점은 집 주변 GPS 흔들림으로 흡수하고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다. @@ -85,7 +85,7 @@ Android Companion App의 실제 상태값은 API에서 한국어/영어 표시 ## 운영 메모 -긴 기간 히스토리 조회는 Home Assistant recorder 데이터량과 웹서버/프록시 timeout 영향을 받을 수 있습니다. findmydevice API는 Home Assistant 쿼리를 실행한 뒤 원본 지점 수와 렌더링 지점 수를 함께 내려주며, 브라우저에는 부드러운 지도 표시용 경로를 출력합니다. +긴 기간 히스토리 조회는 Home Assistant recorder 데이터량과 웹서버/프록시 timeout, 브라우저 렌더링 부하 영향을 받을 수 있습니다. findmydevice API는 Home Assistant 쿼리를 실행한 뒤 병합 지점 전체를 내려주며, 원본 지점 수와 응답 지점 수를 함께 내려줍니다. Companion App의 `request_location_update`는 앱이 실행 중이거나 백그라운드에 있을 때 위치 보고를 시도합니다. Android에서는 위치 권한, 백그라운드 실행 허용, Location Sensors의 Background Location, Single Accurate Location, High Accuracy Mode 설정이 실제 갱신 빈도에 영향을 줍니다. diff --git a/js/main.js b/js/main.js index 8d3b9fe..9c79b89 100644 --- a/js/main.js +++ b/js/main.js @@ -449,7 +449,7 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts historyStats: null, stayPoints: [], homeStats: null, - filterOutliers: true, + filterOutliers: false, openInfoWindow: null, mapClickHandlerAdded: false, initialInfoOpened: false, diff --git a/php/api.php b/php/api.php index a388e84..4524edd 100644 --- a/php/api.php +++ b/php/api.php @@ -428,137 +428,7 @@ if ($historySets) { }); } -function pathDistanceM(array $a, array $b): float -{ - $earth = 6371000; - $lat1 = deg2rad((float)$a['lat']); - $lat2 = deg2rad((float)$b['lat']); - $dLat = $lat2 - $lat1; - $dLng = deg2rad((float)$b['lng'] - (float)$a['lng']); - $h = sin($dLat / 2) ** 2 + cos($lat1) * cos($lat2) * sin($dLng / 2) ** 2; - return 2 * $earth * asin(min(1, sqrt($h))); -} - -function pointLineDistanceM(array $point, array $start, array $end): float -{ - $latScale = 111320; - $lngScale = 111320 * cos(deg2rad((float)$point['lat'])); - $px = (float)$point['lng'] * $lngScale; - $py = (float)$point['lat'] * $latScale; - $sx = (float)$start['lng'] * $lngScale; - $sy = (float)$start['lat'] * $latScale; - $ex = (float)$end['lng'] * $lngScale; - $ey = (float)$end['lat'] * $latScale; - $dx = $ex - $sx; - $dy = $ey - $sy; - - if ($dx == 0.0 && $dy == 0.0) { - return sqrt(($px - $sx) ** 2 + ($py - $sy) ** 2); - } - - $t = max(0, min(1, (($px - $sx) * $dx + ($py - $sy) * $dy) / ($dx ** 2 + $dy ** 2))); - $cx = $sx + $t * $dx; - $cy = $sy + $t * $dy; - - return sqrt(($px - $cx) ** 2 + ($py - $cy) ** 2); -} - -function douglasPeucker(array $points, float $epsilon): array -{ - $count = count($points); - if ($count <= 2) { - return $points; - } - - $maxDistance = 0.0; - $index = 0; - $last = $count - 1; - for ($i = 1; $i < $last; $i++) { - $distance = pointLineDistanceM($points[$i], $points[0], $points[$last]); - if ($distance > $maxDistance) { - $index = $i; - $maxDistance = $distance; - } - } - - if ($maxDistance > $epsilon) { - $left = douglasPeucker(array_slice($points, 0, $index + 1), $epsilon); - $right = douglasPeucker(array_slice($points, $index), $epsilon); - return array_merge(array_slice($left, 0, -1), $right); - } - - return [$points[0], $points[$last]]; -} - -function smoothPath(array $path): array -{ - $count = count($path); - if ($count < 5) { - return $path; - } - - $smoothed = []; - for ($i = 0; $i < $count; $i++) { - if ($i === 0 || $i === $count - 1) { - $smoothed[] = $path[$i]; - continue; - } - - $prev = $path[$i - 1]; - $cur = $path[$i]; - $next = $path[$i + 1]; - $smoothed[] = [ - 'lat' => ((float)$prev['lat'] + ((float)$cur['lat'] * 2) + (float)$next['lat']) / 4, - 'lng' => ((float)$prev['lng'] + ((float)$cur['lng'] * 2) + (float)$next['lng']) / 4, - 'time' => $cur['time'], - 'accuracy' => $cur['accuracy'] ?? null, - 'source' => $cur['source'] ?? null, - ]; - } - - return $smoothed; -} - -function renderPath(array $path, int $renderLimit = 1200): array -{ - $count = count($path); - if ($count <= 2) { - return $path; - } - - $filtered = []; - $last = null; - foreach ($path as $point) { - if ($last === null || pathDistanceM($last, $point) >= 8) { - $filtered[] = $point; - $last = $point; - } - } - if (count($filtered) < 2) { - $filtered = [$path[0], $path[$count - 1]]; - } - - $epsilon = 6.0; - $render = $filtered; - do { - $render = douglasPeucker($filtered, $epsilon); - $epsilon *= 1.45; - } while (count($render) > $renderLimit && $epsilon < 500); - - if (count($render) > $renderLimit) { - $sampled = []; - $lastIndex = count($render) - 1; - for ($i = 0; $i < $renderLimit; $i++) { - $sampled[] = $render[(int)round($i * $lastIndex / max(1, $renderLimit - 1))]; - } - $render = $sampled; - } - - return smoothPath($render); -} - $historyRawCount = count($path); -$path = renderPath($path); $lang = $_GET['lang'] ?? 'en';