diff --git a/README.md b/README.md
index 15a598d..2e7422a 100644
--- a/README.md
+++ b/README.md
@@ -62,11 +62,11 @@ PWA standalone으로 진입한 경우에는 설치형 앱 사용성을 우선해
히스토리 조회도 `seoul`, `main` Home Assistant history를 모두 읽습니다. 서버는 시간과 좌표를 기준으로 중복 지점을 제거하고 시간순으로 병합합니다. 응답에는 병합 전 출처별 지점 수인 `historySourceCounts`도 포함합니다.
-Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그리고 체류 지점 마커를 표시합니다. 시작/종료 마커와 자동 정보창은 표시하지 않습니다. 중간 점 마커는 긴 기간 조회에서 브라우저 부하가 커지므로 표시하지 않습니다. 기록 조회 시 지도는 경로 전체가 보이도록 자동으로 범위를 맞춥니다.
+Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그리고 체류 지점 마커를 표시합니다. 시작/종료 마커와 자동 정보창은 표시하지 않습니다. 중간 점 마커는 긴 기간 조회에서 브라우저 부하가 커지므로 표시하지 않습니다. 기록 조회 시 지도는 경로 전체가 보이도록 자동으로 범위를 맞춥니다. 지도 도구에서는 교통 상황, 지적편집도, 위성/일반 지도, 내 기록/체류 표시를 토글할 수 있습니다.
지도 기능은 NAVER 지도 API v3의 기본 컨트롤, `Polyline`, `Circle`, `fitBounds`를 활용합니다. 지도 아래 도구 패널에서 경로 전체 보기, 경로 재생, GPS 튐 보정, Naver 지도 열기를 제어합니다.
-히스토리 요약 패널은 이동 거리, 기록 시간, 지도에 실제로 그려진 지점 수를 기본으로 보여줍니다. 체류 지점, 현재 상태, 외출 시간, 외출 횟수, 마지막 복귀 시각은 값이 있을 때만 보여주므로 빈값이나 판단 가치가 낮은 항목은 화면에 남지 않습니다. 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다.
+히스토리 요약 패널은 이동 거리, 기록 시간, 지도에 실제로 그려진 지점 수를 기본으로 보여줍니다. 체류 지점, 현재 상태, 외출 시간, 외출 횟수, 마지막 복귀 시각은 값이 있을 때만 보여주므로 빈값이나 판단 가치가 낮은 항목은 화면에 남지 않습니다. 각 히스토리 마커 정보창에는 기록 시각, 시작점 기준 누적 거리, 좌표를 함께 표시합니다. Home Assistant 주소가 비어 있으면 브라우저에서 Naver reverse geocode를 시도하고, 실패하면 좌표를 현재 위치 값으로 표시합니다.
경로선은 기본값으로 서버가 내려준 병합 지점을 전부 사용해 표시합니다. `GPS 튐 보정`을 켜면 집 중심 1.2km 안의 장기 체류성 흔들림은 집 위치로 흡수하고, 집 주변 보정 지점은 30분 단위로 줄여 지도 낙서를 줄입니다. 이전/다음 지점이 가까운데 중간 지점만 250m 이상 튀고 왕복 속도가 80km/h를 넘는 단발 스파이크, 500m 이상 220km/h 초과 이동, 2km 이상 120km/h 초과 이동, 정확도 300m 초과 지점은 제외합니다. 30분 이상 기록 공백은 선을 끊고 이동 거리 합산에서도 제외합니다. 지도 아래 `GPS 튐 보정` 버튼으로 전체 지점 경로와 보정 경로를 전환할 수 있습니다.
diff --git a/js/main.js b/js/main.js
index 0a8bd37..b17d375 100644
--- a/js/main.js
+++ b/js/main.js
@@ -178,6 +178,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
mapFit: "전체",
mapPlay: "재생",
mapPause: "정지",
+ mapTraffic: "교통",
+ mapCadastral: "지적",
+ mapSatellite: "위성",
+ mapHistory: "기록",
historySummary: "기록 요약",
historyDistance: "이동 거리",
historyDuration: "기록 시간",
@@ -385,6 +389,10 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
mapFit: "Fit",
mapPlay: "Play",
mapPause: "Pause",
+ mapTraffic: "Traffic",
+ mapCadastral: "Cadastral",
+ mapSatellite: "Satellite",
+ mapHistory: "History",
historySummary: "History Summary",
historyDistance: "Distance",
historyDuration: "Duration",
@@ -445,6 +453,13 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
playbackSpeed: 1,
playbackSpeedOptions: [1],
rangeCircles: [],
+ layers: {},
+ mapLayerState: {
+ traffic: false,
+ cadastral: false,
+ satellite: false,
+ history: true,
+ },
homeCircle: null,
homeCircles: [],
homeCenter: null,
@@ -477,6 +492,7 @@ let langCode = localStorage.getItem("currentLang") || deviceLang,
radiusInterval,
_overrideRange,
wakeLock = null;
+const reverseGeocodeCache = new Map();
const PLAYBACK_SPEEDS = [2, 5, 10, 20, 50, 100, 500];
const PLAYBACK_MIN_VISIBLE_STEPS = 80;
@@ -1207,12 +1223,71 @@ function setCookieHours(name, value, hours) {
document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; expires=${d.toUTCString()}; path=/; SameSite=Lax`;
}
+function isUsefulAddress(value) {
+ const text = String(value || '').trim();
+ return !!text && text !== '-' && text.toLowerCase() !== 'not_home' && text.toUpperCase() !== 'N/A';
+}
+
+function formatLatLngAddress(lat, lng) {
+ return `${Number(lat).toFixed(5)}, ${Number(lng).toFixed(5)}`;
+}
+
+function setCurrentPositionText(value) {
+ const el = document.getElementById('currentPosition');
+ if (!el) return;
+ el.textContent = value || '-';
+ el.title = value || '';
+}
+
+function updateCurrentAddress(data) {
+ if (!hasValidCoords(data.latitude, data.longitude)) {
+ setCurrentPositionText(isUsefulAddress(data.address) ? data.address : '-');
+ return;
+ }
+ if (isUsefulAddress(data.address)) {
+ setCurrentPositionText(data.address);
+ return;
+ }
+
+ const lat = Number(data.latitude);
+ const lng = Number(data.longitude);
+ const fallback = formatLatLngAddress(lat, lng);
+ setCurrentPositionText(fallback);
+
+ if (!naver.maps.Service?.reverseGeocode) return;
+ const key = `${lat.toFixed(5)},${lng.toFixed(5)}`;
+ if (reverseGeocodeCache.has(key)) {
+ setCurrentPositionText(reverseGeocodeCache.get(key));
+ return;
+ }
+
+ naver.maps.Service.reverseGeocode({
+ coords: new naver.maps.LatLng(lat, lng),
+ orders: ([
+ naver.maps.Service.OrderType?.ROAD_ADDR,
+ naver.maps.Service.OrderType?.ADDR,
+ ].filter(Boolean).join(',') || 'roadaddr,addr')
+ }, (status, response) => {
+ const okStatus = naver.maps.Service.Status?.OK || 'OK';
+ if (status !== okStatus) return;
+ const address = response?.v2?.address;
+ const resolved = address?.roadAddress || address?.jibunAddress || '';
+ if (!resolved) return;
+ reverseGeocodeCache.set(key, resolved);
+ if (Math.abs(Number(lastLat) - lat) < 0.00001 && Math.abs(Number(lastLng) - lng) < 0.00001) {
+ setCurrentPositionText(resolved);
+ }
+ });
+}
+
function updateDOM(data) {
highAccuracyModeActive = !!data.highAccuracyMode;
syncUpdateBtn();
const textMap = {
- currentPosition: data.address,
+ currentPosition: isUsefulAddress(data.address)
+ ? data.address
+ : (hasValidCoords(data.latitude, data.longitude) ? formatLatLngAddress(data.latitude, data.longitude) : '-'),
lastUpdateTime: data.updated ? getRelativeTime(data.updated) : "-",
nowActivity: data.activity,
connectionType: data.connection,
@@ -1230,7 +1305,10 @@ function updateDOM(data) {
for (const [id, value] of Object.entries(textMap)) {
const el = document.getElementById(id);
- if (el) el.textContent = value || "-";
+ if (el) {
+ el.textContent = value || "-";
+ el.title = value || "";
+ }
}
toggleOptionalRow('WbatteryStatus', data.wstate);
@@ -1426,6 +1504,7 @@ function initMap(latlng, accuracy = 50) {
createMainMarker(latlng);
createAccuracyCircle(latlng, accuracy);
updateHomeCircles();
+ ensureMapLayers();
naver.maps.Event.addListener(mapState.map, 'zoom_changed', () => {
renderStayMarkers();
if (mapState.map.getZoom() < CURRENT_STAY_MIN_ZOOM) {
@@ -1445,6 +1524,12 @@ function initMap(latlng, accuracy = 50) {
ensureMapTools();
}
+function ensureMapLayers() {
+ if (!mapState.map || Object.keys(mapState.layers).length) return;
+ if (naver.maps.TrafficLayer) mapState.layers.traffic = new naver.maps.TrafficLayer();
+ if (naver.maps.CadastralLayer) mapState.layers.cadastral = new naver.maps.CadastralLayer();
+}
+
function ensureMapTools() {
const container = document.getElementById('mapContainer') || document.getElementById('map')?.parentElement;
if (!container || document.getElementById('mapTools')) return;
@@ -1461,6 +1546,10 @@ function ensureMapTools() {
tools.innerHTML = `
+
+
+
+
`;
@@ -1502,6 +1591,10 @@ function updateMapToolLabels() {
const labelMap = {
fit: t.mapFit,
play: mapState.playbackTimer ? `${t.mapPause} ${mapState.playbackSpeed}x` : t.mapPlay,
+ traffic: t.mapTraffic,
+ cadastral: t.mapCadastral,
+ satellite: t.mapSatellite,
+ history: t.mapHistory,
filter: t.historyFiltered,
naver: t.openNaverMap,
};
@@ -1511,10 +1604,67 @@ function updateMapToolLabels() {
button.title = labelMap[action] || action;
});
document.querySelector('[data-map-action="filter"]')?.classList.toggle('active', mapState.filterOutliers);
+ document.querySelector('[data-map-action="traffic"]')?.classList.toggle('active', mapState.mapLayerState.traffic);
+ document.querySelector('[data-map-action="cadastral"]')?.classList.toggle('active', mapState.mapLayerState.cadastral);
+ document.querySelector('[data-map-action="satellite"]')?.classList.toggle('active', mapState.mapLayerState.satellite);
+ document.querySelector('[data-map-action="history"]')?.classList.toggle('active', mapState.mapLayerState.history);
const timelineTitle = document.getElementById('historyTimelineTitle');
if (timelineTitle) timelineTitle.textContent = t.historyTimeline;
}
+function setHistoryLayerVisible(visible) {
+ const map = visible ? mapState.map : null;
+ mapState.trackLine?.setMap?.(map);
+ mapState.trackLines?.forEach(line => line.setMap?.(map));
+ mapState.smallMarkers?.forEach(marker => marker.setMap?.(map));
+ mapState.historyStartMarker?.setMap?.(map);
+ mapState.historyEndMarker?.setMap?.(map);
+ if (visible) {
+ renderStayMarkers();
+ } else {
+ mapState.stayMarkers?.forEach(marker => marker.setMap?.(null));
+ stopCurrentStayBadge();
+ }
+ renderHistorySummary();
+ updateTimelinePanel();
+}
+
+function toggleMapLayer(action) {
+ if (action === 'traffic') {
+ if (!mapState.layers.traffic) {
+ mapState.mapLayerState.traffic = false;
+ return true;
+ }
+ mapState.mapLayerState.traffic = !mapState.mapLayerState.traffic;
+ mapState.layers.traffic?.setMap(mapState.mapLayerState.traffic ? mapState.map : null);
+ return true;
+ }
+ if (action === 'cadastral') {
+ if (!mapState.layers.cadastral) {
+ mapState.mapLayerState.cadastral = false;
+ return true;
+ }
+ mapState.mapLayerState.cadastral = !mapState.mapLayerState.cadastral;
+ mapState.layers.cadastral?.setMap(mapState.mapLayerState.cadastral ? mapState.map : null);
+ return true;
+ }
+ if (action === 'satellite') {
+ mapState.mapLayerState.satellite = !mapState.mapLayerState.satellite;
+ if (naver.maps.MapTypeId) {
+ const hybridType = naver.maps.MapTypeId.HYBRID || 'hybrid';
+ const normalType = naver.maps.MapTypeId.NORMAL || 'normal';
+ mapState.map.setMapTypeId(mapState.mapLayerState.satellite ? hybridType : normalType);
+ }
+ return true;
+ }
+ if (action === 'history') {
+ mapState.mapLayerState.history = !mapState.mapLayerState.history;
+ setHistoryLayerVisible(mapState.mapLayerState.history);
+ return true;
+ }
+ return false;
+}
+
function handleMapTool(action) {
if (!mapState.map) return;
if (action === 'fit') {
@@ -1533,6 +1683,10 @@ function handleMapTool(action) {
updateMapToolLabels();
return;
}
+ if (toggleMapLayer(action)) {
+ updateMapToolLabels();
+ return;
+ }
if (action === 'naver') {
openSelectedPointInNaverMap();
}
@@ -2563,7 +2717,7 @@ function renderStayMarkers() {
mapState.stayMarkers.forEach(marker => marker.setMap(null));
mapState.stayMarkers = [];
}
- if (!mapState.stayPoints?.length) return;
+ if (!mapState.mapLayerState.history || !mapState.stayPoints?.length) return;
buildStayMarkerGroups(mapState.stayPoints).forEach(group => {
const isCluster = group.stays.length > 1;
@@ -2635,6 +2789,9 @@ function renderHistoryPoints(rawPoints) {
}
renderStayMarkers();
+ if (!mapState.mapLayerState.history) {
+ setHistoryLayerVisible(false);
+ }
if (!mapState.historyBoundsFitted) {
fitHistoryOrCurrentBounds();
@@ -2715,6 +2872,7 @@ function updateDOMWithData(data) {
lastLat = data.latitude;
lastLng = data.longitude;
lastUpdateISOTime = data.updated;
+ updateCurrentAddress(data);
const currentPoint = {
lat: data.latitude,
lng: data.longitude,