From 71e0a903dc86a10182c26a9d5c51510bd2ac5aca Mon Sep 17 00:00:00 2001 From: seo Date: Mon, 15 Jun 2026 10:26:48 +0900 Subject: [PATCH] Enhance Naver map history tools --- README.md | 6 + js/main.js | 394 +++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 371 insertions(+), 29 deletions(-) diff --git a/README.md b/README.md index 7a30bc4..4a67749 100644 --- a/README.md +++ b/README.md @@ -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에서 확인된 값 기준으로 의미 있는 섹션만 유지합니다. 현재 표시 대상은 배터리, 네트워크, 기기, 화면/오디오, 건강/활동, 저장공간/데이터, 알림/통화, 센서입니다. diff --git a/js/main.js b/js/main.js index a7423fd..49dd31a 100644 --- a/js/main.js +++ b/js/main.js @@ -163,9 +163,25 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts editPage: "페이지 수정", pwa: "눌러 홈 화면에 추가해 바로 보기", history: "기록", - startTime: "시작", - endTime: "종료", - }, + startTime: "시작", + endTime: "종료", + mapNormal: "일반", + mapSatellite: "위성", + mapTraffic: "교통", + mapStreet: "거리뷰", + mapFit: "전체", + mapPlay: "재생", + mapPause: "정지", + historySummary: "기록 요약", + historyDistance: "이동 거리", + historyDuration: "기록 시간", + historyPoints: "지점", + historyAverageSpeed: "평균 속도", + historyMaxGap: "최대 공백", + historyStart: "시작", + historyEnd: "종료", + historyPoint: "기록 지점", + }, en: { blog: "Blog", title: "Find My Device", @@ -328,19 +344,42 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts accentColor: "Accent Color", editPage: "Edit Page", pwa: "Add to Home Screen for quick access!", - history: "History", - startTime: "Start", - endTime: "End", - }, + 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 = { map: null, - marker: null, - circle: null, + marker: null, + circle: null, trackLine: null, smallMarkers: [], historyStartMarker: null, historyEndMarker: null, + playbackMarker: null, + playbackTimer: null, + historyPoints: [], + historyStats: null, + layers: {}, + layerActive: {}, + activeMapType: 'normal', openInfoWindow: null, mapClickHandlerAdded: false, initialInfoOpened: false, @@ -757,8 +796,8 @@ function getRelativeTime(isoTime) { : `${diffDays} day${diffDays > 1 ? 's' : ''} ago ${p} ${h12}:${m}:${s}`; } -function applyLanguage(langCode) { - document.title = langCode === 'ko' ? '나의 찾기' : 'Find My Device'; +function applyLanguage(langCode) { + document.title = langCode === 'ko' ? '나의 찾기' : 'Find My Device'; const t = translations[langCode] || translations.ko, titleMap = { titleSettings: t.settings, titleLocation: t.location, titleUpdate: t.update, titleBattery: t.battery, titleActivity: t.activity, titleEtc: t.etc, titleHistory: t.history }, @@ -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) { @@ -1005,15 +1047,285 @@ function updateActivitySection(data) { } } -function initMap(latlng, accuracy = 50) { - mapState.map = new naver.maps.Map('map', { - center: latlng, - zoom: 12 - }); - - createMainMarker(latlng); - createAccuracyCircle(latlng, accuracy); -} +function initMap(latlng, accuracy = 50) { + mapState.map = new naver.maps.Map('map', { + center: latlng, + 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 = ` + + + + + + + `; + 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 = ` +
${t.historySummary}
+
+ ${t.historyDistance}${formatDistance(stats.distance)} + ${t.historyDuration}${formatDuration(Math.round(stats.duration))} + ${t.historyAverageSpeed}${formatSpeed(stats.averageSpeed)} + ${t.historyMaxGap}${formatDuration(Math.round(stats.maxGap))} + ${t.historyPoints}${formatNumber(stats.points)} +
+ `; +} + +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 ` +
+ ${title} + ${getRelativeTime(point.time)} + ${t.historyDistance}: ${index > 0 ? formatDistance(fromStart) : '-'} +
+ `; +} + +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: '
', + 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) { mapState.marker = new naver.maps.Marker({ @@ -1092,10 +1404,11 @@ function bounceMarker(marker) { }, 30); } -function resetHistoryLayer() { - if (mapState.trackLine) { - mapState.trackLine.setMap(null); - mapState.trackLine = null; +function resetHistoryLayer() { + stopHistoryPlayback(); + if (mapState.trackLine) { + mapState.trackLine.setMap(null); + mapState.trackLine = null; } if (mapState.smallMarkers?.length) { mapState.smallMarkers.forEach(m => m.setMap?.(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: `
${label ? `${label}
` : ''}${getRelativeTime(pos.time)}
` + 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; }