Enhance Naver map history tools
This commit is contained in:
+365
-29
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user