Render smoother findmydevice history paths
This commit is contained in:
@@ -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
@@ -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
@@ -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);
|
||||
?>
|
||||
|
||||
Reference in New Issue
Block a user