Enhance find my device map controls

This commit is contained in:
seo
2026-06-17 15:17:20 +09:00
parent 6c30305845
commit 84ffd0f01e
2 changed files with 379 additions and 46 deletions
+368 -43
View File
@@ -181,6 +181,17 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
historyStart: "시작",
historyEnd: "종료",
historyPoint: "기록 지점",
historyStay: "체류",
historyStops: "체류 지점",
historyFiltered: "GPS 튐 보정",
historyTimeline: "시간 위치",
speedLegend: "속도",
speedStill: "정지",
speedWalk: "도보",
speedDrive: "차량",
currentRadius: "현재 반경",
openNaverMap: "Naver 지도",
selectedPoint: "선택 지점",
},
en: {
blog: "Blog",
@@ -363,6 +374,17 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
historyStart: "Start",
historyEnd: "End",
historyPoint: "History Point",
historyStay: "Stay",
historyStops: "Stops",
historyFiltered: "GPS Filter",
historyTimeline: "Timeline",
speedLegend: "Speed",
speedStill: "Still",
speedWalk: "Walk",
speedDrive: "Drive",
currentRadius: "Current Radius",
openNaverMap: "Naver Map",
selectedPoint: "Selected Point",
},
},
mapState = {
@@ -370,13 +392,21 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts
marker: null,
circle: null,
trackLine: null,
trackLines: [],
smallMarkers: [],
stayMarkers: [],
historyStartMarker: null,
historyEndMarker: null,
playbackMarker: null,
selectedMarker: null,
playbackTimer: null,
playbackIndex: 0,
rangeCircles: [],
rawHistoryPoints: [],
historyPoints: [],
historyStats: null,
stayPoints: [],
filterOutliers: true,
layers: {},
layerActive: {},
activeMapType: 'normal',
@@ -1079,6 +1109,11 @@ function ensureMapTools() {
if (!container || document.getElementById('mapTools')) return;
container.classList.add('has-map-tools');
const panel = document.createElement('div');
panel.id = 'mapControlPanel';
panel.className = 'map-control-panel settingSection';
container.insertAdjacentElement('afterend', panel);
const tools = document.createElement('div');
tools.id = 'mapTools';
tools.className = 'map-tools';
@@ -1089,8 +1124,10 @@ function ensureMapTools() {
<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>
<button type="button" class="map-tool active" data-map-action="filter"></button>
<button type="button" class="map-tool" data-map-action="naver"></button>
`;
container.appendChild(tools);
panel.appendChild(tools);
tools.addEventListener('click', event => {
const button = event.target.closest('[data-map-action]');
@@ -1102,7 +1139,24 @@ function ensureMapTools() {
summary.id = 'historySummaryPanel';
summary.className = 'history-summary-panel';
summary.hidden = true;
container.appendChild(summary);
panel.appendChild(summary);
const timeline = document.createElement('div');
timeline.id = 'historyTimelinePanel';
timeline.className = 'history-timeline-panel';
timeline.hidden = true;
timeline.innerHTML = `
<div class="history-timeline-head">
<strong id="historyTimelineTitle"></strong>
<span id="historyTimelineValue">-</span>
</div>
<input id="historyTimelineSlider" type="range" min="0" max="0" value="0" step="1">
`;
panel.appendChild(timeline);
timeline.querySelector('#historyTimelineSlider')?.addEventListener('input', event => {
moveSelectedHistoryPoint(Number(event.target.value), true);
});
updateMapToolLabels();
}
@@ -1115,12 +1169,17 @@ function updateMapToolLabels() {
street: t.mapStreet,
fit: t.mapFit,
play: mapState.playbackTimer ? t.mapPause : t.mapPlay,
filter: t.historyFiltered,
naver: t.openNaverMap,
};
document.querySelectorAll('[data-map-action]').forEach(button => {
const action = button.dataset.mapAction;
button.textContent = labelMap[action] || action;
button.title = labelMap[action] || action;
});
document.querySelector('[data-map-action="filter"]')?.classList.toggle('active', mapState.filterOutliers);
const timelineTitle = document.getElementById('historyTimelineTitle');
if (timelineTitle) timelineTitle.textContent = t.historyTimeline;
}
function handleMapTool(action) {
@@ -1152,6 +1211,18 @@ function handleMapTool(action) {
}
if (action === 'play') {
toggleHistoryPlayback();
return;
}
if (action === 'filter') {
mapState.filterOutliers = !mapState.filterOutliers;
if (mapState.rawHistoryPoints?.length) {
renderHistoryPoints(mapState.rawHistoryPoints);
}
updateMapToolLabels();
return;
}
if (action === 'naver') {
openSelectedPointInNaverMap();
}
}
@@ -1208,6 +1279,93 @@ function formatSpeed(kmh) {
return `${kmh.toFixed(kmh >= 10 ? 1 : 2)}km/h`;
}
function formatDateTime(value) {
const ts = toTimestamp(value);
if (!ts) return '-';
return new Date(ts).toLocaleString(langCode === 'ko' ? 'ko-KR' : 'en-US', {
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
});
}
function speedColor(kmh) {
if (!Number.isFinite(kmh) || kmh < 2) return '#8e8e93';
if (kmh < 8) return '#34c759';
if (kmh < 35) return '#ff9500';
return '#ff3b30';
}
function speedLabel(kmh) {
const t = translations[langCode] || translations.ko;
if (!Number.isFinite(kmh) || kmh < 2) return t.speedStill;
if (kmh < 8) return t.speedWalk;
return t.speedDrive;
}
function filterHistoryOutliers(points) {
if (!mapState.filterOutliers || points.length < 3) return points;
const filtered = [points[0]];
for (let i = 1; i < points.length; i++) {
const prev = filtered[filtered.length - 1];
const cur = points[i];
const seconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
const distance = distanceMeters(prev, cur);
const kmh = distance / seconds * 3.6;
if (distance > 2000 && kmh > 180) continue;
filtered.push(cur);
}
return filtered.length >= 2 ? filtered : points;
}
function buildStayPoints(points) {
const stays = [];
let start = 0;
const radius = 80;
const minSeconds = 10 * 60;
for (let i = 1; i <= points.length; i++) {
const base = points[start];
const current = points[i];
const outside = !current || distanceMeters(base, current) > radius;
if (!outside) continue;
const end = points[Math.max(start, i - 1)];
const duration = (toTimestamp(end.time) - toTimestamp(base.time)) / 1000;
if (duration >= minSeconds) {
const slice = points.slice(start, i);
const lat = slice.reduce((sum, p) => sum + Number(p.lat), 0) / slice.length;
const lng = slice.reduce((sum, p) => sum + Number(p.lng), 0) / slice.length;
stays.push({
lat,
lng,
latlng: new naver.maps.LatLng(lat, lng),
startTime: base.time,
endTime: end.time,
duration,
points: slice.length,
});
}
start = Math.max(0, i - 1);
}
return stays;
}
function openPointInNaverMap(point) {
if (!point || !hasValidCoords(point.lat, point.lng)) return;
const lat = Number(point.lat);
const lng = Number(point.lng);
window.open(`https://map.naver.com/v5/search/${lat},${lng}`, '_blank', 'noopener');
}
function openSelectedPointInNaverMap() {
const point = mapState.historyPoints?.[mapState.playbackIndex] ||
(hasValidCoords(lastLat, lastLng) ? { lat: lastLat, lng: lastLng } : null);
openPointInNaverMap(point);
}
function buildHistoryStats(points) {
if (!Array.isArray(points) || points.length < 2) return null;
let distance = 0;
@@ -1226,6 +1384,7 @@ function buildHistoryStats(points) {
maxGap,
points: points.length,
averageSpeed: duration > 0 ? distance / duration * 3.6 : 0,
stops: mapState.stayPoints?.length || 0,
startedAt: points[0].time,
endedAt: points.at(-1).time,
};
@@ -1250,6 +1409,14 @@ function renderHistorySummary() {
<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>
<span>${t.historyStops}</span><strong>${formatNumber(stats.stops || 0)}</strong>
<span>${t.currentRadius}</span><strong>500m / 1km</strong>
</div>
<div class="speed-legend">
<span><i style="--legend-color:#8e8e93"></i>${t.speedStill}</span>
<span><i style="--legend-color:#34c759"></i>${t.speedWalk}</span>
<span><i style="--legend-color:#ff9500"></i>${t.speedDrive}</span>
<span><i style="--legend-color:#ff3b30"></i>35km/h+</span>
</div>
`;
}
@@ -1266,8 +1433,21 @@ function buildHistoryInfoContent(point, type, label) {
return `
<div class="history-info-window history-info-${typeClass}">
<strong>${title}</strong>
<span>${getRelativeTime(point.time)}</span>
<span>${formatDateTime(point.time)} · ${getRelativeTime(point.time)}</span>
<span>${t.historyDistance}: ${index > 0 ? formatDistance(fromStart) : '-'}</span>
<span>${Number(point.lat).toFixed(6)}, ${Number(point.lng).toFixed(6)}</span>
</div>
`;
}
function buildStayInfoContent(stay) {
const t = translations[langCode] || translations.ko;
return `
<div class="history-info-window history-info-stay">
<strong>${t.historyStay}</strong>
<span>${formatDuration(Math.round(stay.duration))}</span>
<span>${formatDateTime(stay.startTime)} - ${formatDateTime(stay.endTime)}</span>
<span>${Number(stay.lat).toFixed(6)}, ${Number(stay.lng).toFixed(6)}</span>
</div>
`;
}
@@ -1322,13 +1502,109 @@ function toggleHistoryPlayback() {
mapState.playbackTimer = setInterval(() => {
const point = points[index];
mapState.playbackMarker.setPosition(point.latlng);
moveSelectedHistoryPoint(index, false);
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 clearTrackLines() {
if (mapState.trackLine) {
mapState.trackLine.setMap(null);
mapState.trackLine = null;
}
if (mapState.trackLines?.length) {
mapState.trackLines.forEach(line => line.setMap?.(null));
mapState.trackLines = [];
}
}
function drawSpeedTrack(points) {
clearTrackLines();
if (!points?.length || points.length < 2) return;
for (let i = 1; i < points.length; i++) {
const prev = points[i - 1];
const cur = points[i];
const seconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000);
const kmh = distanceMeters(prev, cur) / seconds * 3.6;
const line = new naver.maps.Polyline({
map: mapState.map,
path: [prev.latlng, cur.latlng],
strokeColor: speedColor(kmh),
strokeOpacity: 0.88,
strokeWeight: 4,
strokeLineCap: 'round',
strokeLineJoin: 'round',
endIcon: i === points.length - 1 ? naver.maps.PointingIcon?.BLOCK_ARROW : undefined,
endIconSize: 16,
clickable: true
});
line.segmentSpeed = kmh;
naver.maps.Event.addListener(line, 'click', () => {
const panel = document.getElementById('historySummaryPanel');
if (panel) panel.hidden = !panel.hidden;
});
mapState.trackLines.push(line);
}
mapState.trackLine = mapState.trackLines.at(-1) || null;
}
function updateTimelinePanel() {
const panel = document.getElementById('historyTimelinePanel');
const slider = document.getElementById('historyTimelineSlider');
if (!panel || !slider) return;
const points = mapState.historyPoints || [];
if (!points.length) {
panel.hidden = true;
return;
}
panel.hidden = false;
slider.max = String(points.length - 1);
slider.value = String(Math.min(mapState.playbackIndex || 0, points.length - 1));
moveSelectedHistoryPoint(Number(slider.value), false, false);
}
function moveSelectedHistoryPoint(index, pan = false, syncSlider = true) {
const points = mapState.historyPoints || [];
if (!points.length) return;
const safeIndex = Math.max(0, Math.min(points.length - 1, Number.isFinite(index) ? index : 0));
const point = points[safeIndex];
mapState.playbackIndex = safeIndex;
if (!mapState.selectedMarker) {
mapState.selectedMarker = new naver.maps.Marker({
position: point.latlng,
map: mapState.map,
zIndex: 115,
icon: {
content: '<div class="history-selected-marker"></div>',
anchor: new naver.maps.Point(10, 10)
}
});
} else {
mapState.selectedMarker.setMap(mapState.map);
mapState.selectedMarker.setPosition(point.latlng);
}
if (mapState.playbackMarker) {
mapState.playbackMarker.setPosition(point.latlng);
}
if (pan) mapState.map.panTo(point.latlng);
const value = document.getElementById('historyTimelineValue');
if (value) {
const prev = points[Math.max(0, safeIndex - 1)];
const seconds = safeIndex > 0 ? Math.max(1, (toTimestamp(point.time) - toTimestamp(prev.time)) / 1000) : 0;
const kmh = safeIndex > 0 ? distanceMeters(prev, point) / seconds * 3.6 : 0;
value.textContent = `${formatDateTime(point.time)} · ${speedLabel(kmh)} ${formatSpeed(kmh)}`;
}
if (syncSlider) {
const slider = document.getElementById('historyTimelineSlider');
if (slider) slider.value = String(safeIndex);
}
}
function createMainMarker(latlng) {
mapState.marker = new naver.maps.Marker({
@@ -1343,18 +1619,44 @@ function createMainMarker(latlng) {
applyMarkerEffects(mapState.marker);
}
function createAccuracyCircle(latlng, accuracy = 50) {
mapState.circle = new naver.maps.Circle({
map: mapState.map,
center: latlng,
function createAccuracyCircle(latlng, accuracy = 50) {
mapState.circle = new naver.maps.Circle({
map: mapState.map,
center: latlng,
radius: Math.max(accuracy, 10),
strokeColor: '#007AFF',
strokeOpacity: 0.8,
strokeWeight: 2,
fillColor: '#007AFF',
fillOpacity: 0.2
});
}
fillOpacity: 0.2
});
updateCurrentRangeCircles(latlng);
}
function updateCurrentRangeCircles(latlng) {
if (!mapState.map || !latlng) return;
const ranges = [
{ radius: 500, strokeColor: '#34c759', fillColor: '#34c759' },
{ radius: 1000, strokeColor: '#ff9500', fillColor: '#ff9500' },
];
ranges.forEach((range, index) => {
if (!mapState.rangeCircles[index]) {
mapState.rangeCircles[index] = new naver.maps.Circle({
map: mapState.map,
center: latlng,
radius: range.radius,
strokeColor: range.strokeColor,
strokeOpacity: 0.45,
strokeWeight: 1,
fillColor: range.fillColor,
fillOpacity: 0.035
});
} else {
mapState.rangeCircles[index].setCenter(latlng);
}
});
}
function applyMarkerEffects(marker) {
naver.maps.Event.addListener(marker, 'mouseover', () => {
@@ -1409,14 +1711,15 @@ function bounceMarker(marker) {
function resetHistoryLayer() {
stopHistoryPlayback();
if (mapState.trackLine) {
mapState.trackLine.setMap(null);
mapState.trackLine = null;
}
clearTrackLines();
if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(m => m.setMap?.(null));
mapState.smallMarkers = [];
}
if (mapState.stayMarkers?.length) {
mapState.stayMarkers.forEach(m => m.setMap?.(null));
mapState.stayMarkers = [];
}
if (mapState.historyStartMarker) {
mapState.historyStartMarker.setMap(null);
mapState.historyStartMarker = null;
@@ -1432,9 +1735,15 @@ function resetHistoryLayer() {
if (mapState.playbackMarker) {
mapState.playbackMarker.setMap(null);
}
if (mapState.selectedMarker) {
mapState.selectedMarker.setMap(null);
}
mapState.rawHistoryPoints = [];
mapState.historyPoints = [];
mapState.historyStats = null;
mapState.stayPoints = [];
renderHistorySummary();
updateTimelinePanel();
mapState.initialInfoOpened = false;
mapState.historyBoundsFitted = false;
}
@@ -1458,39 +1767,29 @@ function updateHistoryMarkers(history) {
.map(pos => ({ ...pos, latlng: new naver.maps.LatLng(pos.lat, pos.lng) }));
if (!points.length) return;
mapState.historyPoints = points;
mapState.rawHistoryPoints = points;
renderHistoryPoints(points);
}
if (mapState.trackLine) mapState.trackLine.setMap(null);
mapState.trackLine = new naver.maps.Polyline({
map: mapState.map,
path: points.map(pos => pos.latlng),
strokeColor: '#007AFF',
strokeOpacity: 0.85,
strokeWeight: 4,
strokeLineCap: 'round',
strokeLineJoin: 'round',
endIcon: naver.maps.PointingIcon?.BLOCK_ARROW,
endIconSize: 16,
clickable: true
});
function renderHistoryPoints(rawPoints) {
if (!mapState.map || !rawPoints?.length) return;
const points = filterHistoryOutliers(rawPoints);
mapState.historyPoints = points;
mapState.playbackIndex = 0;
drawSpeedTrack(points);
mapState.stayPoints = buildStayPoints(points);
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;
});
updateTimelinePanel();
if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(marker => marker.setMap(null));
mapState.smallMarkers = [];
}
if (mapState.stayMarkers?.length) {
mapState.stayMarkers.forEach(marker => marker.setMap(null));
mapState.stayMarkers = [];
}
if (mapState.historyStartMarker) mapState.historyStartMarker.setMap(null);
if (mapState.historyEndMarker) mapState.historyEndMarker.setMap(null);
@@ -1521,6 +1820,8 @@ function updateHistoryMarkers(history) {
} else {
marker.infoWindow.open(mapState.map, marker);
mapState.openInfoWindow = marker.infoWindow;
const idx = mapState.historyPoints.findIndex(item => item.time === pos.time && item.lat === pos.lat && item.lng === pos.lng);
if (idx >= 0) moveSelectedHistoryPoint(idx, false);
}
});
return marker;
@@ -1538,6 +1839,29 @@ function updateHistoryMarkers(history) {
mapState.smallMarkers.push(makeMarker(pos));
});
mapState.stayPoints.forEach(stay => {
const marker = new naver.maps.Marker({
position: stay.latlng,
map: mapState.map,
zIndex: 80,
icon: {
content: `<div class="history-marker history-marker-stay">${formatDuration(Math.round(stay.duration))}</div>`,
anchor: new naver.maps.Point(26, 14)
}
});
marker.infoWindow = new naver.maps.InfoWindow({
content: buildStayInfoContent(stay)
});
naver.maps.Event.addListener(marker, "click", () => {
if (mapState.openInfoWindow && mapState.openInfoWindow !== marker.infoWindow) {
mapState.openInfoWindow.close();
}
marker.infoWindow.open(mapState.map, marker);
mapState.openInfoWindow = marker.infoWindow;
});
mapState.stayMarkers.push(marker);
});
if (!mapState.historyBoundsFitted) {
fitHistoryOrCurrentBounds();
mapState.historyBoundsFitted = true;
@@ -1601,9 +1925,10 @@ function updateDOMWithData(data) {
}
// 메인 마커 / 정확도 원 업데이트
mapState.marker.setPosition(latlng);
mapState.circle.setCenter(latlng);
animateCircleRadius(Math.max(data.accuracy || 50, 10));
mapState.marker.setPosition(latlng);
mapState.circle.setCenter(latlng);
animateCircleRadius(Math.max(data.accuracy || 50, 10));
updateCurrentRangeCircles(latlng);
// autoPosition
const isChecked = document.getElementById('autoPositionSwitch').checked;