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
+365 -29
View File
@@ -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 = `
<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) {
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: `<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;
}