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 = ` +