Render smoother findmydevice history paths

This commit is contained in:
seo
2026-06-17 19:42:59 +09:00
parent 8bbe43b8c4
commit 938868ea64
3 changed files with 173 additions and 79 deletions
+5 -5
View File
@@ -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 저장소와 개인 접근 환경에서만 운영합니다.
+36 -74
View File
@@ -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() {
<span>${t.historyDuration}</span><strong>${formatDuration(Math.round(stats.duration))}</strong>
<span>${t.historyAverageSpeed}</span><strong>${formatSpeed(stats.averageSpeed)}</strong>
<span>${t.historyMaxGap}</span><strong>${formatDuration(Math.round(stats.maxGap))}</strong>
<span>${t.historyPoints}</span><strong>${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.homeRadius}</span><strong>50m</strong>
<span>${t.homeStay}</span><strong>${home ? formatDuration(Math.round(home.homeSeconds)) : '-'}</strong>
@@ -1567,12 +1572,6 @@ function renderHistorySummary() {
<span>${t.lastReturn}</span><strong>${home?.lastReturn ? formatDateTime(home.lastReturn) : '-'}</strong>
<span>${t.currentPlaceStatus}</span><strong>${home?.status || '-'}</strong>
</div>
<div class="speed-legend">
${[0, 3, 6, 12, 35, 80, 130].map(kmh => {
const bucket = speedBucket(kmh);
return `<span><i style="--legend-color:${bucket.color}"></i>${bucket.label}</span>`;
}).join('')}
</div>
${recentSessions.length ? `
<div class="away-session-list">
<strong>${t.awaySessionTitle}</strong>
@@ -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',
+132
View File
@@ -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);
?>