diff --git a/README.md b/README.md
index a9a5627..c1e35eb 100644
--- a/README.md
+++ b/README.md
@@ -15,7 +15,7 @@ Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태,
- 사용자 지정 기간 이동 경로 표시
- 지도 타입 전환, 교통/거리뷰 레이어 토글
- 이동 기록 요약, 경로 전체 보기, 경로 재생, 시간 슬라이더
-- 속도 기반 경로 색상, GPS 튐 보정 토글, 체류 지점 자동 표시
+- 부드러운 렌더링용 경로선, GPS 튐 보정 토글, 체류 지점 자동 표시
- 현재 위치 기준 집 50m, 500m/1km 반경 표시와 선택 지점 Naver 지도 열기
- 집 체류 합산, 외출/복귀 세션, 마지막 복귀 시각과 현재 상태 표시
- 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시
@@ -42,15 +42,15 @@ Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태,
## 히스토리와 지도
-실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 기간이 길어도 Home Assistant 히스토리 API를 실행하고, 반환된 위치 기록을 샘플링하거나 최대 개수로 자르지 않고 응답합니다.
+실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 기간이 길어도 Home Assistant 히스토리 API를 실행한 뒤, 브라우저 렌더링용으로 경로를 부드럽게 단순화해 응답합니다.
-Naver Map에는 응답받은 전체 경로선을 먼저 그리고 시작/종료 마커와 중간 포인트를 표시합니다. 기록 조회 시 지도는 경로 전체가 보이도록 자동으로 범위를 맞추며, 종료 지점 정보창을 먼저 열어 최근 위치를 바로 확인할 수 있게 합니다.
+Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그리고 시작/종료 마커와 체류 지점 마커를 표시합니다. 중간 점 마커는 긴 기간 조회에서 브라우저 부하가 커지므로 표시하지 않습니다. 기록 조회 시 지도는 경로 전체가 보이도록 자동으로 범위를 맞추며, 종료 지점 정보창을 먼저 열어 최근 위치를 바로 확인할 수 있게 합니다.
지도 기능은 NAVER 지도 API v3의 기본 컨트롤, 지도 유형, `TrafficLayer`, `StreetLayer`, `Polyline`, `PointingIcon`, `Circle`, `fitBounds`를 활용합니다. 지도 아래 도구 패널에서 일반/위성 지도 전환, 교통 레이어, 거리뷰 가능 구역 레이어, 경로 전체 보기, 경로 재생, GPS 튐 보정, Naver 지도 열기를 제어합니다.
히스토리 요약 패널은 이동 거리, 기록 시간, 평균 속도, 최대 기록 공백, 지점 수, 체류 지점 수, 집 반경, 집 체류 시간, 외출 시간, 외출 횟수, 마지막 복귀 시각과 현재 상태를 표시합니다. 경로선 클릭으로 요약 패널을 숨기거나 다시 볼 수 있고, 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다.
-경로선은 구간별 속도에 따라 정지, 도보, 빠른 도보, 저속 이동, 시내 차량, 고속 차량, 비정상 고속 색상으로 나뉘어 표시됩니다. 2km 이상 순간 이동하면서 180km/h를 넘는 GPS 튐은 기본적으로 제외하며, 지도 아래 `GPS 튐 보정` 버튼으로 원본 경로와 보정 경로를 전환할 수 있습니다.
+경로선은 Douglas-Peucker 단순화와 가벼운 좌표 smoothing을 거쳐 긴 기간에서도 완만한 단일 선으로 표시합니다. 2km 이상 순간 이동하면서 180km/h를 넘는 GPS 튐은 기본적으로 제외하며, 지도 아래 `GPS 튐 보정` 버튼으로 원본 렌더링 경로와 보정 경로를 전환할 수 있습니다.
집 판정은 현재 위치를 중심점으로 한 50m 반경을 기준으로 합니다. 50~90m 구간은 집 근처/불안정 구간으로 두고, 집 밖 상태가 5분 이상 지속될 때만 외출 세션으로 확정합니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다.
@@ -68,6 +68,6 @@ Android Companion App의 실제 상태값은 API에서 한국어/영어 표시
## 운영 메모
-긴 기간 히스토리 조회는 Home Assistant recorder 데이터량과 웹서버/프록시 timeout 영향을 받을 수 있습니다. findmydevice API는 자체 포인트 수 제한과 curl timeout을 걸지 않으므로, 가능한 범위에서는 Home Assistant가 반환한 전체 결과를 화면에 출력합니다.
+긴 기간 히스토리 조회는 Home Assistant recorder 데이터량과 웹서버/프록시 timeout 영향을 받을 수 있습니다. findmydevice API는 Home Assistant 쿼리를 실행한 뒤 원본 지점 수와 렌더링 지점 수를 함께 내려주며, 브라우저에는 부드러운 지도 표시용 경로를 출력합니다.
이 프로젝트는 위치, 주소, 배터리, 활동 기록을 포함하므로 private 저장소와 개인 접근 환경에서만 운영합니다.
diff --git a/js/main.js b/js/main.js
index dfbe440..414f14d 100644
--- a/js/main.js
+++ b/js/main.js
@@ -442,6 +442,8 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
homeCenter: null,
rawHistoryPoints: [],
historyPoints: [],
+ historyRawCount: null,
+ historyRenderCount: null,
historyStats: null,
stayPoints: [],
homeStats: null,
@@ -748,6 +750,7 @@ document.addEventListener('DOMContentLoaded', () => {
const data = await fetchDeviceData(true, shouldRequestHistory ? range : null);
updateDOMWithData(data);
if (!shouldUseHistory && data.history?.length > 0) {
+ applyHistoryCounts(data);
updateHistoryMarkers(data.history);
}
@@ -1529,6 +1532,8 @@ function buildHistoryStats(points) {
duration,
maxGap,
points: points.length,
+ rawPoints: mapState.historyRawCount || points.length,
+ renderPoints: mapState.historyRenderCount || points.length,
averageSpeed: duration > 0 ? distance / duration * 3.6 : 0,
stops: mapState.stayPoints?.length || 0,
home: mapState.homeStats,
@@ -1558,7 +1563,7 @@ function renderHistorySummary() {
${t.historyDuration}${formatDuration(Math.round(stats.duration))}
${t.historyAverageSpeed}${formatSpeed(stats.averageSpeed)}
${t.historyMaxGap}${formatDuration(Math.round(stats.maxGap))}
- ${t.historyPoints}${formatNumber(stats.points)}
+ ${t.historyPoints}${stats.rawPoints && stats.rawPoints !== stats.renderPoints ? `${formatNumber(stats.renderPoints)} / ${formatNumber(stats.rawPoints)}` : formatNumber(stats.points)}
${t.historyStops}${formatNumber(stats.stops || 0)}
${t.homeRadius}50m
${t.homeStay}${home ? formatDuration(Math.round(home.homeSeconds)) : '-'}
@@ -1567,12 +1572,6 @@ function renderHistorySummary() {
${t.lastReturn}${home?.lastReturn ? formatDateTime(home.lastReturn) : '-'}
${t.currentPlaceStatus}${home?.status || '-'}
-
- ${[0, 3, 6, 12, 35, 80, 130].map(kmh => {
- const bucket = speedBucket(kmh);
- return `${bucket.label}`;
- }).join('')}
-
${recentSessions.length ? `
${t.awaySessionTitle}
@@ -1688,31 +1687,25 @@ function drawSpeedTrack(points) {
clearTrackLines();
if (!points?.length || points.length < 2) return;
- for (let i = 1; i < points.length; i++) {
- const prev = points[i - 1];
- const cur = points[i];
- const seconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
- const kmh = distanceMeters(prev, cur) / seconds * 3.6;
- const line = new naver.maps.Polyline({
- map: mapState.map,
- path: [prev.latlng, cur.latlng],
- strokeColor: speedColor(kmh),
- strokeOpacity: 0.88,
- strokeWeight: 4,
- strokeLineCap: 'round',
- strokeLineJoin: 'round',
- endIcon: i === points.length - 1 ? naver.maps.PointingIcon?.BLOCK_ARROW : undefined,
- endIconSize: 16,
- clickable: true
- });
- line.segmentSpeed = kmh;
- naver.maps.Event.addListener(line, 'click', () => {
- const panel = document.getElementById('historySummaryPanel');
- if (panel) panel.hidden = !panel.hidden;
- });
- mapState.trackLines.push(line);
- }
- mapState.trackLine = mapState.trackLines.at(-1) || null;
+ const path = points.map(point => point.latlng);
+ const line = new naver.maps.Polyline({
+ map: mapState.map,
+ path,
+ strokeColor: '#007aff',
+ strokeOpacity: 0.86,
+ strokeWeight: 5,
+ strokeLineCap: 'round',
+ strokeLineJoin: 'round',
+ endIcon: naver.maps.PointingIcon?.BLOCK_ARROW,
+ endIconSize: 16,
+ clickable: true
+ });
+ naver.maps.Event.addListener(line, 'click', () => {
+ const panel = document.getElementById('historySummaryPanel');
+ if (panel) panel.hidden = !panel.hidden;
+ });
+ mapState.trackLines.push(line);
+ mapState.trackLine = line;
}
function updateTimelinePanel() {
@@ -1903,6 +1896,8 @@ function resetHistoryLayer() {
}
mapState.rawHistoryPoints = [];
mapState.historyPoints = [];
+ mapState.historyRawCount = null;
+ mapState.historyRenderCount = null;
mapState.historyStats = null;
mapState.stayPoints = [];
mapState.homeStats = null;
@@ -1935,6 +1930,12 @@ function updateHistoryMarkers(history) {
renderHistoryPoints(points);
}
+function applyHistoryCounts(data) {
+ const historyLength = Array.isArray(data?.history) ? data.history.length : 0;
+ mapState.historyRawCount = Number.isFinite(Number(data?.historyRawCount)) ? Number(data.historyRawCount) : historyLength;
+ mapState.historyRenderCount = Number.isFinite(Number(data?.historyRenderCount)) ? Number(data.historyRenderCount) : historyLength;
+}
+
function renderHistoryPoints(rawPoints) {
if (!mapState.map || !rawPoints?.length) return;
const points = filterHistoryOutliers(rawPoints);
@@ -1997,13 +1998,6 @@ function renderHistoryPoints(rawPoints) {
mapState.historyStartMarker = makeMarker(first, 'start');
mapState.historyEndMarker = makeMarker(last, 'end');
- const maxDots = 180;
- const step = Math.max(1, Math.ceil(points.length / maxDots));
- points.forEach((pos, idx) => {
- if (idx === 0 || idx === points.length - 1 || idx % step !== 0) return;
- mapState.smallMarkers.push(makeMarker(pos));
- });
-
mapState.stayPoints.forEach(stay => {
const marker = new naver.maps.Marker({
position: stay.latlng,
@@ -2068,8 +2062,9 @@ function updateDOMWithData(data) {
// 히스토리 궤적/포인트 업데이트
if (data.history?.length > 0) {
- updateHistoryMarkers(data.history);
- }
+ applyHistoryCounts(data);
+ updateHistoryMarkers(data.history);
+ }
// 상대시간 갱신 중단
lastUpdateISOTime = null;
@@ -2252,39 +2247,6 @@ function hasValidCoords(lat, lng) {
typeof lng === 'number' && isFinite(lng);
}
-function renderTrackFromDisplayedMarkers() {
- if (!mapState.map) return;
-
- // 시간 파싱 헬퍼(동일 로직)
- const toTime = (v) => {
- if (v instanceof Date) return v.getTime();
- if (typeof v === 'number' && isFinite(v)) {
- return v < 2e10 ? v * 1000 : v; // 초 → ms 보정
- }
- const t = Date.parse(v);
- return isNaN(t) ? 0 : t;
- };
-
- // 현재 표시된 스몰마커만 정렬 → 경로 생성
- const points = mapState.smallMarkers
- .slice()
- .sort((a, b) => toTime(a.userTime) - toTime(b.userTime))
- .map(m => m.getPosition());
-
- // 라인 갱신
- if (!mapState.trackLine) {
- mapState.trackLine = new naver.maps.Polyline({
- map: mapState.map,
- path: points,
- strokeColor: '#007AFF',
- strokeOpacity: 0.8,
- strokeWeight: 3
- });
- } else {
- mapState.trackLine.setPath(points);
- }
-}
-
function callDeviceWebhook(name, keepalive = false) {
return fetch('https://chaegeon.com/custom/findmydevice/php/api.php?action=webhook', {
method: 'POST',
diff --git a/php/api.php b/php/api.php
index f25d7eb..1ad2ddd 100644
--- a/php/api.php
+++ b/php/api.php
@@ -221,6 +221,136 @@ if (isset($historyData[0])) {
}
}
+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'],
+ ];
+ }
+
+ 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';
$activityMap = [
@@ -503,5 +633,7 @@ echo json_encode([
'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null,
'sensors' => $sensorSections,
'history' => $includeHistory ? $path : null,
+ 'historyRawCount' => $includeHistory ? $historyRawCount : null,
+ 'historyRenderCount' => $includeHistory ? count($path) : null,
], JSON_UNESCAPED_UNICODE);
?>