From fa8159db1171d1fef1f02b61f3c902f2b281050b Mon Sep 17 00:00:00 2001 From: seo Date: Sun, 12 Jul 2026 16:27:09 +0900 Subject: [PATCH] =?UTF-8?q?=EC=B2=B4=EB=A5=98=20=EB=A7=88=EC=BB=A4=20?= =?UTF-8?q?=EC=A4=8C=EB=B3=84=20=EB=AC=B6=EC=9D=8C=EA=B3=BC=20=EC=9C=84?= =?UTF-8?q?=EC=B9=98=20=EA=B0=B1=EC=8B=A0=20=EC=88=9C=EC=84=9C=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 2 +- js/main.js | 126 ++++++++++++++++++++++++++++++++++++----------------- 2 files changed, 88 insertions(+), 40 deletions(-) diff --git a/README.md b/README.md index 58eaac9..15a598d 100644 --- a/README.md +++ b/README.md @@ -72,7 +72,7 @@ Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그 집 판정은 설정된 서울 집과 목포 집 좌표를 중심점으로 한 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 안에 다시 들어오면 초상화 위를 가리지 않는 커스텀 배지로 누적 체류중 시간을 표시하되, 지도를 크게 축소한 상태에서는 배지를 숨깁니다. 재생 중 주기적 위치 갱신으로 히스토리 경로가 다시 그려져도 현재 재생 위치를 유지합니다. 재생 타이머는 시작 당시 지점 목록에 고정하지 않고 최신 히스토리 지점 목록을 기준으로 계속 진행합니다. diff --git a/js/main.js b/js/main.js index 2d91103..0a8bd37 100644 --- a/js/main.js +++ b/js/main.js @@ -488,6 +488,7 @@ const CURRENT_STAY_BADGE_WIDTH = 140; const CURRENT_STAY_BADGE_HEIGHT = 28; const CURRENT_STAY_BADGE_GAP = 8; const CURRENT_STAY_MIN_ZOOM = 13; +const STAY_CLUSTER_MIN_ZOOM = 13; const FALLBACK_BACK_TARGET = 'https://chaegeon.com/'; const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1'; @@ -1426,6 +1427,7 @@ function initMap(latlng, accuracy = 50) { createAccuracyCircle(latlng, accuracy); updateHomeCircles(); naver.maps.Event.addListener(mapState.map, 'zoom_changed', () => { + renderStayMarkers(); if (mapState.map.getZoom() < CURRENT_STAY_MIN_ZOOM) { stopCurrentStayBadge(); setStayMarkersVisibleForCurrentPoint( @@ -2285,8 +2287,8 @@ function setStayMarkersVisibleForCurrentPoint(point) { if (!mapState.stayMarkers?.length) return; const shouldHideCurrent = point && !_overrideRange && !mapState.playbackTimer && hasValidCoords(point.lat, point.lng); mapState.stayMarkers.forEach(marker => { - const stay = marker.stayPoint; - const hide = shouldHideCurrent && stay && distanceMeters(stay, point) <= STAY_GROUP_RADIUS_M; + const stays = marker.stayPoints || (marker.stayPoint ? [marker.stayPoint] : []); + const hide = shouldHideCurrent && stays.some(stay => distanceMeters(stay, point) <= STAY_GROUP_RADIUS_M); marker.setMap(hide ? null : mapState.map); }); } @@ -2527,6 +2529,82 @@ function applyHistoryCounts(data) { 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: `
${label}
`, + 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) { if (!mapState.map || !rawPoints?.length) return; const keepPlaybackIndex = !!mapState.playbackTimer; @@ -2547,10 +2625,6 @@ function renderHistoryPoints(rawPoints) { 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); mapState.historyStartMarker = null; @@ -2560,32 +2634,7 @@ function renderHistoryPoints(rawPoints) { mapState.historyEndMarker = null; } - mapState.stayPoints.forEach(stay => { - const marker = new naver.maps.Marker({ - position: stay.latlng, - map: mapState.map, - zIndex: 80, - icon: { - content: `
${formatDuration(Math.round(stay.duration))}
`, - 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 - ); + renderStayMarkers(); if (!mapState.historyBoundsFitted) { fitHistoryOrCurrentBounds(); @@ -2650,13 +2699,6 @@ function updateDOMWithData(data) { initMap(latlng, data.accuracy); } - // 라이브 모드에서도 최신 24시간 기록을 계속 반영한다. - // 현재 위치 자동 이동은 지도 중심만 바꾸므로, 경로와 종료 마커는 여기서 별도로 갱신한다. - if (data.history?.length > 0) { - applyHistoryCounts(data); - updateHistoryMarkers(data.history); - } - // 메인 마커 / 정확도 원 업데이트 const isPlaybackActive = !!mapState.playbackTimer; setCurrentLocationVisible(!isPlaybackActive); @@ -2679,6 +2721,12 @@ function updateDOMWithData(data) { latlng, time: data.updated || new Date().toISOString(), }; + + // 라이브 위치 마커를 먼저 최신화한 뒤 같은 기준으로 경로와 체류 마커를 다시 그린다. + if (data.history?.length > 0) { + applyHistoryCounts(data); + updateHistoryMarkers(data.history); + } setStayMarkersVisibleForCurrentPoint(currentPoint); updateCurrentStayBadge(currentPoint); }