체류 마커 줌별 묶음과 위치 갱신 순서 정리

This commit is contained in:
seo
2026-07-12 16:27:09 +09:00
parent 534bc379b5
commit fa8159db11
2 changed files with 88 additions and 40 deletions
+1 -1
View File
@@ -72,7 +72,7 @@ Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그
집 판정은 설정된 서울 집과 목포 집 좌표를 중심점으로 한 150m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있으면 두 집 주변 1.2km 안의 지점은 가까운 집 위치로 흡수하고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다. 집 판정은 설정된 서울 집과 목포 집 좌표를 중심점으로 한 150m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있으면 두 집 주변 1.2km 안의 지점은 가까운 집 위치로 흡수하고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다.
기록 시간 슬라이더를 움직이면 지도 아래에는 `월. 일. 시간` 형식의 선택 시각만 간결하게 표시합니다. 경로 재생은 얼굴 아이콘 마커만 사용하며, 재생 중에는 현재 위치 얼굴과 정확도 원을 숨깁니다. 재생 버튼은 지점 수에 따라 의미 있는 배속만 남겨 `2x`, `5x`, `10x`, `20x`, `50x`, `100x`, `500x` 순서로 돌고, 각 배속에서 최소 80단계 이상 보여줄 수 없으면 해당 배속은 건너뜁니다. 100m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하되, 서울 집과 목포 집 안의 체류는 계산하거나 표시하지 않습니다. 같은 위치의 체류는 하나의 마커로 합산하고, 클릭 시 총 체류 시간과 방문별 시작/종료/체류 시간을 확인할 수 있습니다. 라이브 위치가 기존 체류 지점 100m 안에 다시 들어오면 초상화 위를 가리지 않는 커스텀 배지로 누적 체류중 시간을 표시하되, 지도를 크게 축소한 상태에서는 배지를 숨깁니다. 기록 시간 슬라이더를 움직이면 지도 아래에는 `월. 일. 시간` 형식의 선택 시각만 간결하게 표시합니다. 경로 재생은 얼굴 아이콘 마커만 사용하며, 재생 중에는 현재 위치 얼굴과 정확도 원을 숨깁니다. 재생 버튼은 지점 수에 따라 의미 있는 배속만 남겨 `2x`, `5x`, `10x`, `20x`, `50x`, `100x`, `500x` 순서로 돌고, 각 배속에서 최소 80단계 이상 보여줄 수 없으면 해당 배속은 건너뜁니다. 100m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하되, 서울 집과 목포 집 안의 체류는 계산하거나 표시하지 않습니다. 같은 위치의 체류는 하나의 마커로 합산하고, 클릭 시 총 체류 시간과 방문별 시작/종료/체류 시간을 확인할 수 있습니다. 지도를 축소하면 가까운 체류 지점을 `n개` 마커로 묶고, 확대하면 체류 장소와 시간이 다시 보입니다. 라이브 위치가 기존 체류 지점 100m 안에 다시 들어오면 초상화 위를 가리지 않는 커스텀 배지로 누적 체류중 시간을 표시하되, 지도를 크게 축소한 상태에서는 배지를 숨깁니다.
재생 중 주기적 위치 갱신으로 히스토리 경로가 다시 그려져도 현재 재생 위치를 유지합니다. 재생 타이머는 시작 당시 지점 목록에 고정하지 않고 최신 히스토리 지점 목록을 기준으로 계속 진행합니다. 재생 중 주기적 위치 갱신으로 히스토리 경로가 다시 그려져도 현재 재생 위치를 유지합니다. 재생 타이머는 시작 당시 지점 목록에 고정하지 않고 최신 히스토리 지점 목록을 기준으로 계속 진행합니다.
+87 -39
View File
@@ -488,6 +488,7 @@ const CURRENT_STAY_BADGE_WIDTH = 140;
const CURRENT_STAY_BADGE_HEIGHT = 28; const CURRENT_STAY_BADGE_HEIGHT = 28;
const CURRENT_STAY_BADGE_GAP = 8; const CURRENT_STAY_BADGE_GAP = 8;
const CURRENT_STAY_MIN_ZOOM = 13; const CURRENT_STAY_MIN_ZOOM = 13;
const STAY_CLUSTER_MIN_ZOOM = 13;
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/'; const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1'; const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1';
@@ -1426,6 +1427,7 @@ function initMap(latlng, accuracy = 50) {
createAccuracyCircle(latlng, accuracy); createAccuracyCircle(latlng, accuracy);
updateHomeCircles(); updateHomeCircles();
naver.maps.Event.addListener(mapState.map, 'zoom_changed', () => { naver.maps.Event.addListener(mapState.map, 'zoom_changed', () => {
renderStayMarkers();
if (mapState.map.getZoom() < CURRENT_STAY_MIN_ZOOM) { if (mapState.map.getZoom() < CURRENT_STAY_MIN_ZOOM) {
stopCurrentStayBadge(); stopCurrentStayBadge();
setStayMarkersVisibleForCurrentPoint( setStayMarkersVisibleForCurrentPoint(
@@ -2285,8 +2287,8 @@ function setStayMarkersVisibleForCurrentPoint(point) {
if (!mapState.stayMarkers?.length) return; if (!mapState.stayMarkers?.length) return;
const shouldHideCurrent = point && !_overrideRange && !mapState.playbackTimer && hasValidCoords(point.lat, point.lng); const shouldHideCurrent = point && !_overrideRange && !mapState.playbackTimer && hasValidCoords(point.lat, point.lng);
mapState.stayMarkers.forEach(marker => { mapState.stayMarkers.forEach(marker => {
const stay = marker.stayPoint; const stays = marker.stayPoints || (marker.stayPoint ? [marker.stayPoint] : []);
const hide = shouldHideCurrent && stay && distanceMeters(stay, point) <= STAY_GROUP_RADIUS_M; const hide = shouldHideCurrent && stays.some(stay => distanceMeters(stay, point) <= STAY_GROUP_RADIUS_M);
marker.setMap(hide ? null : mapState.map); marker.setMap(hide ? null : mapState.map);
}); });
} }
@@ -2527,6 +2529,82 @@ function applyHistoryCounts(data) {
mapState.historyRenderCount = Number.isFinite(Number(data?.historyRenderCount)) ? Number(data.historyRenderCount) : historyLength; mapState.historyRenderCount = Number.isFinite(Number(data?.historyRenderCount)) ? Number(data.historyRenderCount) : historyLength;
} }
function stayClusterRadiusForZoom(zoom) {
if (zoom >= STAY_CLUSTER_MIN_ZOOM) return 0;
if (zoom <= 8) return 70000;
if (zoom <= 10) return 25000;
return 6000;
}
function buildStayMarkerGroups(stays) {
const zoom = mapState.map?.getZoom?.() || 12;
const radius = stayClusterRadiusForZoom(zoom);
if (!radius) return stays.map(stay => ({ lat: stay.lat, lng: stay.lng, stays: [stay] }));
const groups = [];
stays.forEach(stay => {
const group = groups.find(item => distanceMeters(item, stay) <= radius);
if (group) {
const oldCount = group.stays.length;
const nextCount = oldCount + 1;
group.lat = ((group.lat * oldCount) + Number(stay.lat)) / nextCount;
group.lng = ((group.lng * oldCount) + Number(stay.lng)) / nextCount;
group.stays.push(stay);
} else {
groups.push({ lat: Number(stay.lat), lng: Number(stay.lng), stays: [stay] });
}
});
return groups;
}
function renderStayMarkers() {
if (!mapState.map) return;
if (mapState.stayMarkers?.length) {
mapState.stayMarkers.forEach(marker => marker.setMap(null));
mapState.stayMarkers = [];
}
if (!mapState.stayPoints?.length) return;
buildStayMarkerGroups(mapState.stayPoints).forEach(group => {
const isCluster = group.stays.length > 1;
const duration = group.stays.reduce((sum, stay) => sum + (Number(stay.duration) || 0), 0);
const label = isCluster ? `${formatNumber(group.stays.length)}` : formatDuration(Math.round(duration));
const marker = new naver.maps.Marker({
position: new naver.maps.LatLng(group.lat, group.lng),
map: mapState.map,
zIndex: isCluster ? 75 : 80,
icon: {
content: `<div class="history-marker history-marker-stay">${label}</div>`,
anchor: new naver.maps.Point(26, 14)
}
});
marker.stayPoints = group.stays;
if (isCluster) {
naver.maps.Event.addListener(marker, "click", () => {
mapState.map.panTo(marker.getPosition());
mapState.map.setZoom(Math.max(mapState.map.getZoom() + 2, STAY_CLUSTER_MIN_ZOOM));
});
} else {
const stay = group.stays[0];
marker.infoWindow = new naver.maps.InfoWindow({
content: buildStayInfoContent(stay)
});
marker.stayPoint = 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);
});
setStayMarkersVisibleForCurrentPoint(
hasValidCoords(lastLat, lastLng) ? { lat: lastLat, lng: lastLng } : null
);
}
function renderHistoryPoints(rawPoints) { function renderHistoryPoints(rawPoints) {
if (!mapState.map || !rawPoints?.length) return; if (!mapState.map || !rawPoints?.length) return;
const keepPlaybackIndex = !!mapState.playbackTimer; const keepPlaybackIndex = !!mapState.playbackTimer;
@@ -2547,10 +2625,6 @@ function renderHistoryPoints(rawPoints) {
mapState.smallMarkers.forEach(marker => marker.setMap(null)); mapState.smallMarkers.forEach(marker => marker.setMap(null));
mapState.smallMarkers = []; mapState.smallMarkers = [];
} }
if (mapState.stayMarkers?.length) {
mapState.stayMarkers.forEach(marker => marker.setMap(null));
mapState.stayMarkers = [];
}
if (mapState.historyStartMarker) { if (mapState.historyStartMarker) {
mapState.historyStartMarker.setMap(null); mapState.historyStartMarker.setMap(null);
mapState.historyStartMarker = null; mapState.historyStartMarker = null;
@@ -2560,32 +2634,7 @@ function renderHistoryPoints(rawPoints) {
mapState.historyEndMarker = null; mapState.historyEndMarker = null;
} }
mapState.stayPoints.forEach(stay => { renderStayMarkers();
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)
});
marker.stayPoint = 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);
});
setStayMarkersVisibleForCurrentPoint(
hasValidCoords(lastLat, lastLng) ? { lat: lastLat, lng: lastLng } : null
);
if (!mapState.historyBoundsFitted) { if (!mapState.historyBoundsFitted) {
fitHistoryOrCurrentBounds(); fitHistoryOrCurrentBounds();
@@ -2650,13 +2699,6 @@ function updateDOMWithData(data) {
initMap(latlng, data.accuracy); initMap(latlng, data.accuracy);
} }
// 라이브 모드에서도 최신 24시간 기록을 계속 반영한다.
// 현재 위치 자동 이동은 지도 중심만 바꾸므로, 경로와 종료 마커는 여기서 별도로 갱신한다.
if (data.history?.length > 0) {
applyHistoryCounts(data);
updateHistoryMarkers(data.history);
}
// 메인 마커 / 정확도 원 업데이트 // 메인 마커 / 정확도 원 업데이트
const isPlaybackActive = !!mapState.playbackTimer; const isPlaybackActive = !!mapState.playbackTimer;
setCurrentLocationVisible(!isPlaybackActive); setCurrentLocationVisible(!isPlaybackActive);
@@ -2679,6 +2721,12 @@ function updateDOMWithData(data) {
latlng, latlng,
time: data.updated || new Date().toISOString(), time: data.updated || new Date().toISOString(),
}; };
// 라이브 위치 마커를 먼저 최신화한 뒤 같은 기준으로 경로와 체류 마커를 다시 그린다.
if (data.history?.length > 0) {
applyHistoryCounts(data);
updateHistoryMarkers(data.history);
}
setStayMarkersVisibleForCurrentPoint(currentPoint); setStayMarkersVisibleForCurrentPoint(currentPoint);
updateCurrentStayBadge(currentPoint); updateCurrentStayBadge(currentPoint);
} }