Add home and away map analytics
This commit is contained in:
@@ -16,7 +16,8 @@ Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태,
|
||||
- 지도 타입 전환, 교통/거리뷰 레이어 토글
|
||||
- 이동 기록 요약, 경로 전체 보기, 경로 재생, 시간 슬라이더
|
||||
- 속도 기반 경로 색상, GPS 튐 보정 토글, 체류 지점 자동 표시
|
||||
- 현재 위치 기준 500m/1km 반경 표시와 선택 지점 Naver 지도 열기
|
||||
- 현재 위치 기준 집 50m, 500m/1km 반경 표시와 선택 지점 Naver 지도 열기
|
||||
- 집 체류 합산, 외출/복귀 세션, 마지막 복귀 시각과 현재 상태 표시
|
||||
- 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시
|
||||
- 네트워크, 기기, 화면/오디오, 건강/활동, 저장공간/데이터, 알림/통화, 센서 정보를 섹션별로 표시
|
||||
- 자동 위치 갱신 웹훅 제어
|
||||
@@ -47,13 +48,15 @@ Naver Map에는 전체 경로선을 먼저 그리고 시작/종료 마커와 최
|
||||
|
||||
지도 기능은 NAVER 지도 API v3의 기본 컨트롤, 지도 유형, `TrafficLayer`, `StreetLayer`, `Polyline`, `PointingIcon`, `Circle`, `fitBounds`를 활용합니다. 지도 아래 도구 패널에서 일반/위성 지도 전환, 교통 레이어, 거리뷰 가능 구역 레이어, 경로 전체 보기, 경로 재생, GPS 튐 보정, Naver 지도 열기를 제어합니다.
|
||||
|
||||
히스토리 요약 패널은 이동 거리, 기록 시간, 평균 속도, 최대 기록 공백, 지점 수, 체류 지점 수, 현재 위치 반경 정보를 표시합니다. 경로선 클릭으로 요약 패널을 숨기거나 다시 볼 수 있고, 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다.
|
||||
히스토리 요약 패널은 이동 거리, 기록 시간, 평균 속도, 최대 기록 공백, 지점 수, 체류 지점 수, 집 반경, 집 체류 시간, 외출 시간, 외출 횟수, 마지막 복귀 시각과 현재 상태를 표시합니다. 경로선 클릭으로 요약 패널을 숨기거나 다시 볼 수 있고, 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다.
|
||||
|
||||
경로선은 구간별 속도에 따라 정지, 도보, 차량, 고속 이동 색상으로 나뉘어 표시됩니다. 2km 이상 순간 이동하면서 180km/h를 넘는 GPS 튐은 기본적으로 제외하며, 지도 아래 `GPS 튐 보정` 버튼으로 원본 경로와 보정 경로를 전환할 수 있습니다.
|
||||
경로선은 구간별 속도에 따라 정지, 도보, 빠른 도보, 저속 이동, 시내 차량, 고속 차량, 비정상 고속 색상으로 나뉘어 표시됩니다. 2km 이상 순간 이동하면서 180km/h를 넘는 GPS 튐은 기본적으로 제외하며, 지도 아래 `GPS 튐 보정` 버튼으로 원본 경로와 보정 경로를 전환할 수 있습니다.
|
||||
|
||||
집 판정은 현재 위치를 중심점으로 한 50m 반경을 기준으로 합니다. 50~90m 구간은 집 근처/불안정 구간으로 두고, 집 밖 상태가 5분 이상 지속될 때만 외출 세션으로 확정합니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다.
|
||||
|
||||
기록 시간 슬라이더를 움직이면 선택 마커가 해당 시점으로 이동하고, 선택 시각과 추정 속도를 지도 아래에 표시합니다. 경로 재생도 같은 선택 마커를 사용합니다. 80m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하며, 클릭 시 체류 시간과 시작/종료 시각을 확인할 수 있습니다.
|
||||
|
||||
라이브 위치에는 GPS 정확도 원 외에 500m/1km 현재 위치 반경 원을 함께 표시합니다. `Naver 지도` 버튼은 히스토리 선택 지점 또는 현재 위치를 Naver 지도에서 바로 엽니다.
|
||||
라이브 위치에는 집 50m 원, GPS 정확도 원, 500m/1km 현재 위치 반경 원을 함께 표시합니다. `Naver 지도` 버튼은 히스토리 선택 지점 또는 현재 위치를 Naver 지도에서 바로 엽니다.
|
||||
|
||||
## 센서 표시 정책
|
||||
|
||||
|
||||
+190
-23
@@ -188,8 +188,26 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
|
||||
speedLegend: "속도",
|
||||
speedStill: "정지",
|
||||
speedWalk: "도보",
|
||||
speedFastWalk: "빠른 도보",
|
||||
speedSlowRide: "저속 이동",
|
||||
speedCityDrive: "시내 차량",
|
||||
speedHighway: "고속 차량",
|
||||
speedExtreme: "비정상 고속",
|
||||
speedDrive: "차량",
|
||||
currentRadius: "현재 반경",
|
||||
homeRadius: "집 반경",
|
||||
homeStay: "집 체류",
|
||||
awayTime: "외출 시간",
|
||||
awaySessions: "외출 횟수",
|
||||
lastReturn: "마지막 복귀",
|
||||
currentPlaceStatus: "현재 상태",
|
||||
atHome: "집",
|
||||
awayNow: "외출 중",
|
||||
nearHome: "집 근처",
|
||||
unstableLocation: "판정 불안정",
|
||||
departure: "출발",
|
||||
returnHome: "복귀",
|
||||
awaySessionTitle: "외출 기록",
|
||||
openNaverMap: "Naver 지도",
|
||||
selectedPoint: "선택 지점",
|
||||
},
|
||||
@@ -381,8 +399,26 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
|
||||
speedLegend: "Speed",
|
||||
speedStill: "Still",
|
||||
speedWalk: "Walk",
|
||||
speedFastWalk: "Fast Walk",
|
||||
speedSlowRide: "Slow Ride",
|
||||
speedCityDrive: "City Drive",
|
||||
speedHighway: "Highway",
|
||||
speedExtreme: "Extreme",
|
||||
speedDrive: "Drive",
|
||||
currentRadius: "Current Radius",
|
||||
homeRadius: "Home Radius",
|
||||
homeStay: "Home Stay",
|
||||
awayTime: "Away Time",
|
||||
awaySessions: "Away Trips",
|
||||
lastReturn: "Last Return",
|
||||
currentPlaceStatus: "Current Status",
|
||||
atHome: "Home",
|
||||
awayNow: "Away",
|
||||
nearHome: "Near Home",
|
||||
unstableLocation: "Unstable",
|
||||
departure: "Departure",
|
||||
returnHome: "Return",
|
||||
awaySessionTitle: "Away Sessions",
|
||||
openNaverMap: "Naver Map",
|
||||
selectedPoint: "Selected Point",
|
||||
},
|
||||
@@ -402,10 +438,13 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
|
||||
playbackTimer: null,
|
||||
playbackIndex: 0,
|
||||
rangeCircles: [],
|
||||
homeCircle: null,
|
||||
homeCenter: null,
|
||||
rawHistoryPoints: [],
|
||||
historyPoints: [],
|
||||
historyStats: null,
|
||||
stayPoints: [],
|
||||
homeStats: null,
|
||||
filterOutliers: true,
|
||||
layers: {},
|
||||
layerActive: {},
|
||||
@@ -1094,6 +1133,7 @@ function initMap(latlng, accuracy = 50) {
|
||||
|
||||
createMainMarker(latlng);
|
||||
createAccuracyCircle(latlng, accuracy);
|
||||
updateHomeCircle();
|
||||
initMapLayers();
|
||||
ensureMapTools();
|
||||
}
|
||||
@@ -1279,6 +1319,17 @@ function formatSpeed(kmh) {
|
||||
return `${kmh.toFixed(kmh >= 10 ? 1 : 2)}km/h`;
|
||||
}
|
||||
|
||||
function speedBucket(kmh) {
|
||||
const t = translations[langCode] || translations.ko;
|
||||
if (!Number.isFinite(kmh) || kmh < 1) return { key: 'still', label: t.speedStill, color: '#8e8e93' };
|
||||
if (kmh < 5) return { key: 'walk', label: t.speedWalk, color: '#34c759' };
|
||||
if (kmh < 8) return { key: 'fastWalk', label: t.speedFastWalk, color: '#00c7be' };
|
||||
if (kmh < 20) return { key: 'slowRide', label: t.speedSlowRide, color: '#5ac8fa' };
|
||||
if (kmh < 60) return { key: 'cityDrive', label: t.speedCityDrive, color: '#ff9500' };
|
||||
if (kmh < 110) return { key: 'highway', label: t.speedHighway, color: '#ff3b30' };
|
||||
return { key: 'extreme', label: t.speedExtreme, color: '#af52de' };
|
||||
}
|
||||
|
||||
function formatDateTime(value) {
|
||||
const ts = toTimestamp(value);
|
||||
if (!ts) return '-';
|
||||
@@ -1291,17 +1342,11 @@ function formatDateTime(value) {
|
||||
}
|
||||
|
||||
function speedColor(kmh) {
|
||||
if (!Number.isFinite(kmh) || kmh < 2) return '#8e8e93';
|
||||
if (kmh < 8) return '#34c759';
|
||||
if (kmh < 35) return '#ff9500';
|
||||
return '#ff3b30';
|
||||
return speedBucket(kmh).color;
|
||||
}
|
||||
|
||||
function speedLabel(kmh) {
|
||||
const t = translations[langCode] || translations.ko;
|
||||
if (!Number.isFinite(kmh) || kmh < 2) return t.speedStill;
|
||||
if (kmh < 8) return t.speedWalk;
|
||||
return t.speedDrive;
|
||||
return speedBucket(kmh).label;
|
||||
}
|
||||
|
||||
function filterHistoryOutliers(points) {
|
||||
@@ -1353,6 +1398,107 @@ function buildStayPoints(points) {
|
||||
return stays;
|
||||
}
|
||||
|
||||
function updateHomeCenter(lat, lng) {
|
||||
if (!hasValidCoords(lat, lng)) return;
|
||||
mapState.homeCenter = {
|
||||
lat: Number(lat),
|
||||
lng: Number(lng),
|
||||
latlng: new naver.maps.LatLng(Number(lat), Number(lng))
|
||||
};
|
||||
updateHomeCircle();
|
||||
}
|
||||
|
||||
function updateHomeCircle() {
|
||||
if (!mapState.map || !mapState.homeCenter) return;
|
||||
if (!mapState.homeCircle) {
|
||||
mapState.homeCircle = new naver.maps.Circle({
|
||||
map: mapState.map,
|
||||
center: mapState.homeCenter.latlng,
|
||||
radius: 50,
|
||||
strokeColor: '#5856d6',
|
||||
strokeOpacity: 0.8,
|
||||
strokeWeight: 2,
|
||||
fillColor: '#5856d6',
|
||||
fillOpacity: 0.08
|
||||
});
|
||||
} else {
|
||||
mapState.homeCircle.setCenter(mapState.homeCenter.latlng);
|
||||
}
|
||||
}
|
||||
|
||||
function distanceFromHome(point) {
|
||||
if (!mapState.homeCenter || !point) return Infinity;
|
||||
return distanceMeters(mapState.homeCenter, point);
|
||||
}
|
||||
|
||||
function classifyHomePoint(point) {
|
||||
const distance = distanceFromHome(point);
|
||||
if (distance <= 50) return 'home';
|
||||
if (distance <= 90) return 'near';
|
||||
return 'away';
|
||||
}
|
||||
|
||||
function buildHomeStats(points) {
|
||||
if (!mapState.homeCenter || !Array.isArray(points) || points.length < 2) return null;
|
||||
const t = translations[langCode] || translations.ko;
|
||||
const minAwaySeconds = 5 * 60;
|
||||
let homeSeconds = 0;
|
||||
let unstableSeconds = 0;
|
||||
let currentAway = null;
|
||||
let lastReturn = null;
|
||||
const sessions = [];
|
||||
const closeAwaySession = (returnTime = null) => {
|
||||
if (!currentAway) return;
|
||||
const endTime = returnTime || points.at(-1).time;
|
||||
currentAway.returnTime = returnTime;
|
||||
currentAway.duration = Math.max(0, (toTimestamp(endTime) - toTimestamp(currentAway.departureTime)) / 1000);
|
||||
if (currentAway.duration >= minAwaySeconds) {
|
||||
sessions.push(currentAway);
|
||||
if (returnTime) lastReturn = returnTime;
|
||||
}
|
||||
currentAway = null;
|
||||
};
|
||||
|
||||
for (let i = 1; i < points.length; i++) {
|
||||
const prev = points[i - 1];
|
||||
const cur = points[i];
|
||||
const duration = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
|
||||
if (!duration) continue;
|
||||
|
||||
const prevState = classifyHomePoint(prev);
|
||||
const curState = classifyHomePoint(cur);
|
||||
const state = prevState === curState ? curState : (curState === 'home' ? 'home' : (curState === 'away' ? 'away' : 'near'));
|
||||
|
||||
if (state === 'home') {
|
||||
homeSeconds += duration;
|
||||
closeAwaySession(cur.time);
|
||||
} else if (state === 'away') {
|
||||
if (!currentAway) {
|
||||
currentAway = { departureTime: prev.time, returnTime: null, duration: 0 };
|
||||
}
|
||||
} else {
|
||||
unstableSeconds += duration;
|
||||
}
|
||||
}
|
||||
|
||||
if (currentAway) {
|
||||
closeAwaySession(null);
|
||||
}
|
||||
|
||||
const lastState = classifyHomePoint(points.at(-1));
|
||||
const status = lastState === 'home' ? t.atHome : (lastState === 'away' ? t.awayNow : t.nearHome);
|
||||
const awaySeconds = sessions.reduce((sum, session) => sum + session.duration, 0);
|
||||
|
||||
return {
|
||||
homeSeconds,
|
||||
awaySeconds,
|
||||
unstableSeconds,
|
||||
sessions,
|
||||
lastReturn,
|
||||
status,
|
||||
};
|
||||
}
|
||||
|
||||
function openPointInNaverMap(point) {
|
||||
if (!point || !hasValidCoords(point.lat, point.lng)) return;
|
||||
const lat = Number(point.lat);
|
||||
@@ -1385,6 +1531,7 @@ function buildHistoryStats(points) {
|
||||
points: points.length,
|
||||
averageSpeed: duration > 0 ? distance / duration * 3.6 : 0,
|
||||
stops: mapState.stayPoints?.length || 0,
|
||||
home: mapState.homeStats,
|
||||
startedAt: points[0].time,
|
||||
endedAt: points.at(-1).time,
|
||||
};
|
||||
@@ -1400,6 +1547,9 @@ function renderHistorySummary() {
|
||||
return;
|
||||
}
|
||||
const t = translations[langCode] || translations.ko;
|
||||
const home = stats.home;
|
||||
const sessions = home?.sessions || [];
|
||||
const recentSessions = sessions.slice(-3).reverse();
|
||||
panel.hidden = false;
|
||||
panel.innerHTML = `
|
||||
<div class="history-summary-title">${t.historySummary}</div>
|
||||
@@ -1410,14 +1560,27 @@ function renderHistorySummary() {
|
||||
<span>${t.historyMaxGap}</span><strong>${formatDuration(Math.round(stats.maxGap))}</strong>
|
||||
<span>${t.historyPoints}</span><strong>${formatNumber(stats.points)}</strong>
|
||||
<span>${t.historyStops}</span><strong>${formatNumber(stats.stops || 0)}</strong>
|
||||
<span>${t.currentRadius}</span><strong>500m / 1km</strong>
|
||||
<span>${t.homeRadius}</span><strong>50m</strong>
|
||||
<span>${t.homeStay}</span><strong>${home ? formatDuration(Math.round(home.homeSeconds)) : '-'}</strong>
|
||||
<span>${t.awayTime}</span><strong>${home ? formatDuration(Math.round(home.awaySeconds)) : '-'}</strong>
|
||||
<span>${t.awaySessions}</span><strong>${home ? formatNumber(sessions.length) : '-'}</strong>
|
||||
<span>${t.lastReturn}</span><strong>${home?.lastReturn ? formatDateTime(home.lastReturn) : '-'}</strong>
|
||||
<span>${t.currentPlaceStatus}</span><strong>${home?.status || '-'}</strong>
|
||||
</div>
|
||||
<div class="speed-legend">
|
||||
<span><i style="--legend-color:#8e8e93"></i>${t.speedStill}</span>
|
||||
<span><i style="--legend-color:#34c759"></i>${t.speedWalk}</span>
|
||||
<span><i style="--legend-color:#ff9500"></i>${t.speedDrive}</span>
|
||||
<span><i style="--legend-color:#ff3b30"></i>35km/h+</span>
|
||||
${[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>
|
||||
${recentSessions.map(session => `
|
||||
<span>${t.departure} ${formatDateTime(session.departureTime)} · ${session.returnTime ? `${t.returnHome} ${formatDateTime(session.returnTime)}` : t.awayNow} · ${formatDuration(Math.round(session.duration))}</span>
|
||||
`).join('')}
|
||||
</div>
|
||||
` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -1742,6 +1905,7 @@ function resetHistoryLayer() {
|
||||
mapState.historyPoints = [];
|
||||
mapState.historyStats = null;
|
||||
mapState.stayPoints = [];
|
||||
mapState.homeStats = null;
|
||||
renderHistorySummary();
|
||||
updateTimelinePanel();
|
||||
mapState.initialInfoOpened = false;
|
||||
@@ -1778,6 +1942,7 @@ function renderHistoryPoints(rawPoints) {
|
||||
mapState.playbackIndex = 0;
|
||||
drawSpeedTrack(points);
|
||||
mapState.stayPoints = buildStayPoints(points);
|
||||
mapState.homeStats = buildHomeStats(points);
|
||||
mapState.historyStats = buildHistoryStats(points);
|
||||
renderHistorySummary();
|
||||
updateTimelinePanel();
|
||||
@@ -1884,7 +2049,7 @@ function renderHistoryPoints(rawPoints) {
|
||||
}
|
||||
}
|
||||
|
||||
function updateDOMWithData(data) {
|
||||
function updateDOMWithData(data) {
|
||||
// ⚑ 히스토리 모드: 값은 "-"로, 지도엔 히스토리만 반영
|
||||
if (typeof _overrideRange !== 'undefined' && _overrideRange) {
|
||||
resetDisplayValues();
|
||||
@@ -1896,12 +2061,13 @@ function updateDOMWithData(data) {
|
||||
: (data.latitude && data.longitude ? new naver.maps.LatLng(data.latitude, data.longitude) : null);
|
||||
|
||||
// 지도 초기화 (최초 1회)
|
||||
if (!mapState.map && baseLatLng) {
|
||||
initMap(baseLatLng, data.accuracy || 50);
|
||||
}
|
||||
|
||||
// 히스토리 궤적/포인트 업데이트
|
||||
if (data.history?.length > 0) {
|
||||
if (!mapState.map && baseLatLng) {
|
||||
initMap(baseLatLng, data.accuracy || 50);
|
||||
}
|
||||
updateHomeCenter(data.latitude, data.longitude);
|
||||
|
||||
// 히스토리 궤적/포인트 업데이트
|
||||
if (data.history?.length > 0) {
|
||||
updateHistoryMarkers(data.history);
|
||||
}
|
||||
|
||||
@@ -1913,9 +2079,10 @@ function updateDOMWithData(data) {
|
||||
}
|
||||
|
||||
// ── 라이브 모드 기존 로직 ──
|
||||
const latlng = new naver.maps.LatLng(data.latitude, data.longitude);
|
||||
|
||||
// DOM 업데이트
|
||||
const latlng = new naver.maps.LatLng(data.latitude, data.longitude);
|
||||
updateHomeCenter(data.latitude, data.longitude);
|
||||
|
||||
// DOM 업데이트
|
||||
updateDOM(data);
|
||||
updateActivitySection(data);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user