Enhance Naver map history tools

This commit is contained in:
seo
2026-06-15 10:26:48 +09:00
parent f1f038ce23
commit 71e0a903dc
2 changed files with 371 additions and 29 deletions
+6
View File
@@ -13,6 +13,8 @@ Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태,
- 현재 위치 지도 표시
- GPS 정확도 원 표시
- 사용자 지정 기간 이동 경로 표시
- 지도 타입 전환, 교통/거리뷰 레이어 토글
- 이동 기록 요약, 경로 전체 보기, 경로 재생
- 배터리, 충전 상태, 배터리 건강, 온도, 전력, 활동 상태, 걸음 수, 이동 거리 표시
- 네트워크, 기기, 화면/오디오, 건강/활동, 저장공간/데이터, 알림/통화, 센서 정보를 섹션별로 표시
- 자동 위치 갱신 웹훅 제어
@@ -41,6 +43,10 @@ Home Assistant에 기록된 Galaxy Z Fold7의 위치, 배터리, 활동 상태,
Naver Map에는 전체 경로선을 먼저 그리고 시작/종료 마커와 최대 60개의 중간 포인트만 표시합니다. 기록 조회 시 지도는 경로 전체가 보이도록 자동으로 범위를 맞추며, 종료 지점 정보창을 먼저 열어 최근 위치를 바로 확인할 수 있게 합니다.
지도 기능은 NAVER 지도 API v3의 기본 컨트롤, 지도 유형, `TrafficLayer`, `StreetLayer`, `Polyline`, `PointingIcon`, `fitBounds`를 활용합니다. 화면 상단 지도 도구에서 일반/위성 지도 전환, 교통 레이어, 거리뷰 가능 구역 레이어, 경로 전체 보기, 경로 재생을 제어합니다.
히스토리 요약 패널은 이동 거리, 기록 시간, 평균 속도, 최대 기록 공백, 지점 수를 표시합니다. 경로선 클릭으로 요약 패널을 숨기거나 다시 볼 수 있고, 각 히스토리 마커 정보창에는 기록 시각과 시작점 기준 누적 거리를 함께 표시합니다.
## 센서 표시 정책
실제 Fold7에서 확인된 값 기준으로 의미 있는 섹션만 유지합니다. 현재 표시 대상은 배터리, 네트워크, 기기, 화면/오디오, 건강/활동, 저장공간/데이터, 알림/통화, 센서입니다.
+342 -6
View File
@@ -165,6 +165,22 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
history: "기록",
startTime: "시작",
endTime: "종료",
mapNormal: "일반",
mapSatellite: "위성",
mapTraffic: "교통",
mapStreet: "거리뷰",
mapFit: "전체",
mapPlay: "재생",
mapPause: "정지",
historySummary: "기록 요약",
historyDistance: "이동 거리",
historyDuration: "기록 시간",
historyPoints: "지점",
historyAverageSpeed: "평균 속도",
historyMaxGap: "최대 공백",
historyStart: "시작",
historyEnd: "종료",
historyPoint: "기록 지점",
},
en: {
blog: "Blog",
@@ -331,6 +347,22 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
history: "History",
startTime: "Start",
endTime: "End",
mapNormal: "Normal",
mapSatellite: "Satellite",
mapTraffic: "Traffic",
mapStreet: "Street",
mapFit: "Fit",
mapPlay: "Play",
mapPause: "Pause",
historySummary: "History Summary",
historyDistance: "Distance",
historyDuration: "Duration",
historyPoints: "Points",
historyAverageSpeed: "Avg Speed",
historyMaxGap: "Max Gap",
historyStart: "Start",
historyEnd: "End",
historyPoint: "History Point",
},
},
mapState = {
@@ -341,6 +373,13 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
smallMarkers: [],
historyStartMarker: null,
historyEndMarker: null,
playbackMarker: null,
playbackTimer: null,
historyPoints: [],
historyStats: null,
layers: {},
layerActive: {},
activeMapType: 'normal',
openInfoWindow: null,
mapClickHandlerAdded: false,
initialInfoOpened: false,
@@ -813,6 +852,9 @@ function applyLanguage(langCode) {
const label = row.querySelector('span');
if (label && key) label.textContent = t[key] || key;
});
updateMapToolLabels();
renderHistorySummary();
}
function setCookieHours(name, value, hours) {
@@ -1008,11 +1050,281 @@ function updateActivitySection(data) {
function initMap(latlng, accuracy = 50) {
mapState.map = new naver.maps.Map('map', {
center: latlng,
zoom: 12
zoom: 12,
zoomControl: true,
zoomControlOptions: {
position: naver.maps.Position.RIGHT_CENTER
},
scaleControl: true,
mapDataControl: false
});
createMainMarker(latlng);
createAccuracyCircle(latlng, accuracy);
initMapLayers();
ensureMapTools();
}
function initMapLayers() {
if (!mapState.map || mapState.layers.traffic) return;
mapState.layers.traffic = new naver.maps.TrafficLayer();
mapState.layers.street = new naver.maps.StreetLayer();
}
function ensureMapTools() {
const container = document.getElementById('mapContainer') || document.getElementById('map')?.parentElement;
if (!container || document.getElementById('mapTools')) return;
container.classList.add('has-map-tools');
const tools = document.createElement('div');
tools.id = 'mapTools';
tools.className = 'map-tools';
tools.innerHTML = `
<button type="button" class="map-tool active" data-map-action="normal"></button>
<button type="button" class="map-tool" data-map-action="satellite"></button>
<button type="button" class="map-tool" data-map-action="traffic"></button>
<button type="button" class="map-tool" data-map-action="street"></button>
<button type="button" class="map-tool" data-map-action="fit"></button>
<button type="button" class="map-tool" data-map-action="play"></button>
`;
container.appendChild(tools);
tools.addEventListener('click', event => {
const button = event.target.closest('[data-map-action]');
if (!button) return;
handleMapTool(button.dataset.mapAction);
});
const summary = document.createElement('div');
summary.id = 'historySummaryPanel';
summary.className = 'history-summary-panel';
summary.hidden = true;
container.appendChild(summary);
updateMapToolLabels();
}
function updateMapToolLabels() {
const t = translations[langCode] || translations.ko;
const labelMap = {
normal: t.mapNormal,
satellite: t.mapSatellite,
traffic: t.mapTraffic,
street: t.mapStreet,
fit: t.mapFit,
play: mapState.playbackTimer ? t.mapPause : t.mapPlay,
};
document.querySelectorAll('[data-map-action]').forEach(button => {
const action = button.dataset.mapAction;
button.textContent = labelMap[action] || action;
button.title = labelMap[action] || action;
});
}
function handleMapTool(action) {
if (!mapState.map) return;
if (action === 'normal' || action === 'satellite') {
mapState.activeMapType = action;
const mapTypes = naver.maps.MapTypeId || {};
const typeId = action === 'satellite'
? (mapTypes.HYBRID || mapTypes.SATELLITE || 'satellite')
: (mapTypes.NORMAL || 'normal');
mapState.map.setMapTypeId(typeId);
document.querySelectorAll('[data-map-action="normal"], [data-map-action="satellite"]').forEach(button => {
button.classList.toggle('active', button.dataset.mapAction === action);
});
return;
}
if (action === 'traffic' || action === 'street') {
const layer = mapState.layers[action];
if (!layer) return;
const nextActive = !mapState.layerActive[action];
layer.setMap(nextActive ? mapState.map : null);
mapState.layerActive[action] = nextActive;
document.querySelector(`[data-map-action="${action}"]`)?.classList.toggle('active', nextActive);
return;
}
if (action === 'fit') {
fitHistoryOrCurrentBounds();
return;
}
if (action === 'play') {
toggleHistoryPlayback();
}
}
function toTimestamp(value) {
if (value instanceof Date) return value.getTime();
if (typeof value === 'number' && isFinite(value)) return value < 2e10 ? value * 1000 : value;
const parsed = Date.parse(value);
return Number.isNaN(parsed) ? 0 : parsed;
}
function distanceMeters(a, b) {
const earth = 6371000;
const lat1 = Number(a.lat);
const lat2 = Number(b.lat);
const lng1 = Number(a.lng);
const lng2 = Number(b.lng);
if (![lat1, lat2, lng1, lng2].every(Number.isFinite)) return 0;
const dLat = (lat2 - lat1) * Math.PI / 180;
const dLng = (lng2 - lng1) * Math.PI / 180;
const rLat1 = lat1 * Math.PI / 180;
const rLat2 = lat2 * Math.PI / 180;
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rLat1) * Math.cos(rLat2) * Math.sin(dLng / 2) ** 2;
return 2 * earth * Math.asin(Math.min(1, Math.sqrt(h)));
}
function formatDistance(meters) {
if (!Number.isFinite(meters) || meters <= 0) return '-';
if (meters >= 1000) return `${(meters / 1000).toFixed(meters >= 10000 ? 1 : 2)}km`;
return `${Math.round(meters)}m`;
}
function formatDuration(seconds) {
if (!Number.isFinite(seconds) || seconds <= 0) return '-';
const days = Math.floor(seconds / 86400);
seconds %= 86400;
const hours = Math.floor(seconds / 3600);
seconds %= 3600;
const minutes = Math.floor(seconds / 60);
const parts = [];
if (langCode === 'ko') {
if (days) parts.push(`${days}`);
if (hours) parts.push(`${hours}시간`);
if (minutes) parts.push(`${minutes}`);
} else {
if (days) parts.push(`${days}d`);
if (hours) parts.push(`${hours}h`);
if (minutes) parts.push(`${minutes}m`);
}
return parts.join(' ') || (langCode === 'ko' ? '1분 미만' : '<1m');
}
function formatSpeed(kmh) {
if (!Number.isFinite(kmh) || kmh <= 0) return '-';
return `${kmh.toFixed(kmh >= 10 ? 1 : 2)}km/h`;
}
function buildHistoryStats(points) {
if (!Array.isArray(points) || points.length < 2) return null;
let distance = 0;
let maxGap = 0;
for (let i = 1; i < points.length; i++) {
distance += distanceMeters(points[i - 1], points[i]);
const gap = Math.max(0, (toTimestamp(points[i].time) - toTimestamp(points[i - 1].time)) / 1000);
maxGap = Math.max(maxGap, gap);
}
const startedAt = toTimestamp(points[0].time);
const endedAt = toTimestamp(points.at(-1).time);
const duration = Math.max(0, (endedAt - startedAt) / 1000);
return {
distance,
duration,
maxGap,
points: points.length,
averageSpeed: duration > 0 ? distance / duration * 3.6 : 0,
startedAt: points[0].time,
endedAt: points.at(-1).time,
};
}
function renderHistorySummary() {
const panel = document.getElementById('historySummaryPanel');
if (!panel) return;
const stats = mapState.historyStats;
if (!stats) {
panel.hidden = true;
panel.innerHTML = '';
return;
}
const t = translations[langCode] || translations.ko;
panel.hidden = false;
panel.innerHTML = `
<div class="history-summary-title">${t.historySummary}</div>
<div class="history-summary-grid">
<span>${t.historyDistance}</span><strong>${formatDistance(stats.distance)}</strong>
<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>
</div>
`;
}
function buildHistoryInfoContent(point, type, label) {
const t = translations[langCode] || translations.ko;
const title = label || t.historyPoint;
const index = mapState.historyPoints.findIndex(item => item.time === point.time && item.lat === point.lat && item.lng === point.lng);
let fromStart = 0;
for (let i = 1; i <= index; i++) {
fromStart += distanceMeters(mapState.historyPoints[i - 1], mapState.historyPoints[i]);
}
const typeClass = type === 'start' ? 'start' : (type === 'end' ? 'end' : 'point');
return `
<div class="history-info-window history-info-${typeClass}">
<strong>${title}</strong>
<span>${getRelativeTime(point.time)}</span>
<span>${t.historyDistance}: ${index > 0 ? formatDistance(fromStart) : '-'}</span>
</div>
`;
}
function fitHistoryOrCurrentBounds() {
if (!mapState.map) return;
if (mapState.historyPoints?.length) {
const coords = mapState.historyPoints.map(point => point.latlng);
if (coords.length === 1) {
mapState.map.panTo(coords[0]);
return;
}
mapState.map.fitBounds(coords, { top: 40, right: 28, bottom: 120, left: 28, maxZoom: 16 });
return;
}
if (hasValidCoords(lastLat, lastLng)) {
mapState.map.panTo(new naver.maps.LatLng(lastLat, lastLng));
}
}
function stopHistoryPlayback() {
if (mapState.playbackTimer) {
clearInterval(mapState.playbackTimer);
mapState.playbackTimer = null;
}
updateMapToolLabels();
}
function toggleHistoryPlayback() {
if (!mapState.map || !mapState.historyPoints?.length) return;
if (mapState.playbackTimer) {
stopHistoryPlayback();
return;
}
const points = mapState.historyPoints;
let index = 0;
if (!mapState.playbackMarker) {
mapState.playbackMarker = new naver.maps.Marker({
position: points[0].latlng,
map: mapState.map,
zIndex: 120,
icon: {
content: '<div class="history-playback-marker"></div>',
anchor: new naver.maps.Point(11, 11)
}
});
} else {
mapState.playbackMarker.setMap(mapState.map);
mapState.playbackMarker.setPosition(points[0].latlng);
}
mapState.playbackTimer = setInterval(() => {
const point = points[index];
mapState.playbackMarker.setPosition(point.latlng);
if (index % 8 === 0) mapState.map.panTo(point.latlng);
index += 1;
if (index >= points.length) stopHistoryPlayback();
}, Math.max(120, Math.min(450, 10000 / points.length)));
updateMapToolLabels();
}
function createMainMarker(latlng) {
@@ -1093,6 +1405,7 @@ function bounceMarker(marker) {
}
function resetHistoryLayer() {
stopHistoryPlayback();
if (mapState.trackLine) {
mapState.trackLine.setMap(null);
mapState.trackLine = null;
@@ -1113,6 +1426,12 @@ function resetHistoryLayer() {
mapState.openInfoWindow.close();
mapState.openInfoWindow = null;
}
if (mapState.playbackMarker) {
mapState.playbackMarker.setMap(null);
}
mapState.historyPoints = [];
mapState.historyStats = null;
renderHistorySummary();
mapState.initialInfoOpened = false;
mapState.historyBoundsFitted = false;
}
@@ -1136,6 +1455,7 @@ function updateHistoryMarkers(history) {
.map(pos => ({ ...pos, latlng: new naver.maps.LatLng(pos.lat, pos.lng) }));
if (!points.length) return;
mapState.historyPoints = points;
if (mapState.trackLine) mapState.trackLine.setMap(null);
mapState.trackLine = new naver.maps.Polyline({
@@ -1143,7 +1463,25 @@ function updateHistoryMarkers(history) {
path: points.map(pos => pos.latlng),
strokeColor: '#007AFF',
strokeOpacity: 0.85,
strokeWeight: 4
strokeWeight: 4,
strokeLineCap: 'round',
strokeLineJoin: 'round',
endIcon: naver.maps.PointingIcon?.BLOCK_ARROW,
endIconSize: 16,
clickable: true
});
mapState.historyStats = buildHistoryStats(points);
if (mapState.trackLine.getDistance && mapState.historyStats) {
const lineDistance = mapState.trackLine.getDistance();
if (Number.isFinite(lineDistance) && lineDistance > 0) {
mapState.historyStats.distance = lineDistance;
mapState.historyStats.averageSpeed = mapState.historyStats.duration > 0 ? lineDistance / mapState.historyStats.duration * 3.6 : 0;
}
}
renderHistorySummary();
naver.maps.Event.addListener(mapState.trackLine, 'click', () => {
const panel = document.getElementById('historySummaryPanel');
if (panel) panel.hidden = !panel.hidden;
});
if (mapState.smallMarkers?.length) {
@@ -1168,7 +1506,7 @@ function updateHistoryMarkers(history) {
});
marker.userTime = pos.time;
marker.infoWindow = new naver.maps.InfoWindow({
content: `<div style="font-size:12px;padding:6px 8px;line-height:1.4;">${label ? `${label}<br>` : ''}${getRelativeTime(pos.time)}</div>`
content: buildHistoryInfoContent(pos, type, label)
});
naver.maps.Event.addListener(marker, "click", () => {
if (mapState.openInfoWindow && mapState.openInfoWindow !== marker.infoWindow) {
@@ -1198,9 +1536,7 @@ function updateHistoryMarkers(history) {
});
if (!mapState.historyBoundsFitted) {
const bounds = new naver.maps.LatLngBounds(first.latlng, first.latlng);
points.forEach(pos => bounds.extend(pos.latlng));
mapState.map.fitBounds(bounds);
fitHistoryOrCurrentBounds();
mapState.historyBoundsFitted = true;
}