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 지도 열기
|
- 현재 위치 기준 집 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 지도 열기를 제어합니다.
|
지도 기능은 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분 이상 지속될 때만 외출 세션으로 확정합니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다.
|
집 판정은 현재 위치를 중심점으로 한 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 저장소와 개인 접근 환경에서만 운영합니다.
|
이 프로젝트는 위치, 주소, 배터리, 활동 기록을 포함하므로 private 저장소와 개인 접근 환경에서만 운영합니다.
|
||||||
|
|||||||
+22
-60
@@ -442,6 +442,8 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
|
|||||||
homeCenter: null,
|
homeCenter: null,
|
||||||
rawHistoryPoints: [],
|
rawHistoryPoints: [],
|
||||||
historyPoints: [],
|
historyPoints: [],
|
||||||
|
historyRawCount: null,
|
||||||
|
historyRenderCount: null,
|
||||||
historyStats: null,
|
historyStats: null,
|
||||||
stayPoints: [],
|
stayPoints: [],
|
||||||
homeStats: null,
|
homeStats: null,
|
||||||
@@ -748,6 +750,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const data = await fetchDeviceData(true, shouldRequestHistory ? range : null);
|
const data = await fetchDeviceData(true, shouldRequestHistory ? range : null);
|
||||||
updateDOMWithData(data);
|
updateDOMWithData(data);
|
||||||
if (!shouldUseHistory && data.history?.length > 0) {
|
if (!shouldUseHistory && data.history?.length > 0) {
|
||||||
|
applyHistoryCounts(data);
|
||||||
updateHistoryMarkers(data.history);
|
updateHistoryMarkers(data.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1529,6 +1532,8 @@ function buildHistoryStats(points) {
|
|||||||
duration,
|
duration,
|
||||||
maxGap,
|
maxGap,
|
||||||
points: points.length,
|
points: points.length,
|
||||||
|
rawPoints: mapState.historyRawCount || points.length,
|
||||||
|
renderPoints: mapState.historyRenderCount || points.length,
|
||||||
averageSpeed: duration > 0 ? distance / duration * 3.6 : 0,
|
averageSpeed: duration > 0 ? distance / duration * 3.6 : 0,
|
||||||
stops: mapState.stayPoints?.length || 0,
|
stops: mapState.stayPoints?.length || 0,
|
||||||
home: mapState.homeStats,
|
home: mapState.homeStats,
|
||||||
@@ -1558,7 +1563,7 @@ function renderHistorySummary() {
|
|||||||
<span>${t.historyDuration}</span><strong>${formatDuration(Math.round(stats.duration))}</strong>
|
<span>${t.historyDuration}</span><strong>${formatDuration(Math.round(stats.duration))}</strong>
|
||||||
<span>${t.historyAverageSpeed}</span><strong>${formatSpeed(stats.averageSpeed)}</strong>
|
<span>${t.historyAverageSpeed}</span><strong>${formatSpeed(stats.averageSpeed)}</strong>
|
||||||
<span>${t.historyMaxGap}</span><strong>${formatDuration(Math.round(stats.maxGap))}</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.historyStops}</span><strong>${formatNumber(stats.stops || 0)}</strong>
|
||||||
<span>${t.homeRadius}</span><strong>50m</strong>
|
<span>${t.homeRadius}</span><strong>50m</strong>
|
||||||
<span>${t.homeStay}</span><strong>${home ? formatDuration(Math.round(home.homeSeconds)) : '-'}</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.lastReturn}</span><strong>${home?.lastReturn ? formatDateTime(home.lastReturn) : '-'}</strong>
|
||||||
<span>${t.currentPlaceStatus}</span><strong>${home?.status || '-'}</strong>
|
<span>${t.currentPlaceStatus}</span><strong>${home?.status || '-'}</strong>
|
||||||
</div>
|
</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 ? `
|
${recentSessions.length ? `
|
||||||
<div class="away-session-list">
|
<div class="away-session-list">
|
||||||
<strong>${t.awaySessionTitle}</strong>
|
<strong>${t.awaySessionTitle}</strong>
|
||||||
@@ -1688,31 +1687,25 @@ function drawSpeedTrack(points) {
|
|||||||
clearTrackLines();
|
clearTrackLines();
|
||||||
if (!points?.length || points.length < 2) return;
|
if (!points?.length || points.length < 2) return;
|
||||||
|
|
||||||
for (let i = 1; i < points.length; i++) {
|
const path = points.map(point => point.latlng);
|
||||||
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({
|
const line = new naver.maps.Polyline({
|
||||||
map: mapState.map,
|
map: mapState.map,
|
||||||
path: [prev.latlng, cur.latlng],
|
path,
|
||||||
strokeColor: speedColor(kmh),
|
strokeColor: '#007aff',
|
||||||
strokeOpacity: 0.88,
|
strokeOpacity: 0.86,
|
||||||
strokeWeight: 4,
|
strokeWeight: 5,
|
||||||
strokeLineCap: 'round',
|
strokeLineCap: 'round',
|
||||||
strokeLineJoin: 'round',
|
strokeLineJoin: 'round',
|
||||||
endIcon: i === points.length - 1 ? naver.maps.PointingIcon?.BLOCK_ARROW : undefined,
|
endIcon: naver.maps.PointingIcon?.BLOCK_ARROW,
|
||||||
endIconSize: 16,
|
endIconSize: 16,
|
||||||
clickable: true
|
clickable: true
|
||||||
});
|
});
|
||||||
line.segmentSpeed = kmh;
|
|
||||||
naver.maps.Event.addListener(line, 'click', () => {
|
naver.maps.Event.addListener(line, 'click', () => {
|
||||||
const panel = document.getElementById('historySummaryPanel');
|
const panel = document.getElementById('historySummaryPanel');
|
||||||
if (panel) panel.hidden = !panel.hidden;
|
if (panel) panel.hidden = !panel.hidden;
|
||||||
});
|
});
|
||||||
mapState.trackLines.push(line);
|
mapState.trackLines.push(line);
|
||||||
}
|
mapState.trackLine = line;
|
||||||
mapState.trackLine = mapState.trackLines.at(-1) || null;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateTimelinePanel() {
|
function updateTimelinePanel() {
|
||||||
@@ -1903,6 +1896,8 @@ function resetHistoryLayer() {
|
|||||||
}
|
}
|
||||||
mapState.rawHistoryPoints = [];
|
mapState.rawHistoryPoints = [];
|
||||||
mapState.historyPoints = [];
|
mapState.historyPoints = [];
|
||||||
|
mapState.historyRawCount = null;
|
||||||
|
mapState.historyRenderCount = null;
|
||||||
mapState.historyStats = null;
|
mapState.historyStats = null;
|
||||||
mapState.stayPoints = [];
|
mapState.stayPoints = [];
|
||||||
mapState.homeStats = null;
|
mapState.homeStats = null;
|
||||||
@@ -1935,6 +1930,12 @@ function updateHistoryMarkers(history) {
|
|||||||
renderHistoryPoints(points);
|
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) {
|
function renderHistoryPoints(rawPoints) {
|
||||||
if (!mapState.map || !rawPoints?.length) return;
|
if (!mapState.map || !rawPoints?.length) return;
|
||||||
const points = filterHistoryOutliers(rawPoints);
|
const points = filterHistoryOutliers(rawPoints);
|
||||||
@@ -1997,13 +1998,6 @@ function renderHistoryPoints(rawPoints) {
|
|||||||
mapState.historyStartMarker = makeMarker(first, 'start');
|
mapState.historyStartMarker = makeMarker(first, 'start');
|
||||||
mapState.historyEndMarker = makeMarker(last, 'end');
|
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 => {
|
mapState.stayPoints.forEach(stay => {
|
||||||
const marker = new naver.maps.Marker({
|
const marker = new naver.maps.Marker({
|
||||||
position: stay.latlng,
|
position: stay.latlng,
|
||||||
@@ -2068,6 +2062,7 @@ function updateDOMWithData(data) {
|
|||||||
|
|
||||||
// 히스토리 궤적/포인트 업데이트
|
// 히스토리 궤적/포인트 업데이트
|
||||||
if (data.history?.length > 0) {
|
if (data.history?.length > 0) {
|
||||||
|
applyHistoryCounts(data);
|
||||||
updateHistoryMarkers(data.history);
|
updateHistoryMarkers(data.history);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2252,39 +2247,6 @@ function hasValidCoords(lat, lng) {
|
|||||||
typeof lng === 'number' && isFinite(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) {
|
function callDeviceWebhook(name, keepalive = false) {
|
||||||
return fetch('https://chaegeon.com/custom/findmydevice/php/api.php?action=webhook', {
|
return fetch('https://chaegeon.com/custom/findmydevice/php/api.php?action=webhook', {
|
||||||
method: 'POST',
|
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';
|
$lang = $_GET['lang'] ?? 'en';
|
||||||
|
|
||||||
$activityMap = [
|
$activityMap = [
|
||||||
@@ -503,5 +633,7 @@ echo json_encode([
|
|||||||
'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null,
|
'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null,
|
||||||
'sensors' => $sensorSections,
|
'sensors' => $sensorSections,
|
||||||
'history' => $includeHistory ? $path : null,
|
'history' => $includeHistory ? $path : null,
|
||||||
|
'historyRawCount' => $includeHistory ? $historyRawCount : null,
|
||||||
|
'historyRenderCount' => $includeHistory ? count($path) : null,
|
||||||
], JSON_UNESCAPED_UNICODE);
|
], JSON_UNESCAPED_UNICODE);
|
||||||
?>
|
?>
|
||||||
|
|||||||
Reference in New Issue
Block a user