From 938868ea64e5092f19fb2bf59dfe481768b7b127 Mon Sep 17 00:00:00 2001 From: seo Date: Wed, 17 Jun 2026 19:42:59 +0900 Subject: [PATCH] Render smoother findmydevice history paths --- README.md | 10 ++-- js/main.js | 110 ++++++++++++++----------------------------- php/api.php | 132 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 173 insertions(+), 79 deletions(-) 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); ?>