diff --git a/README.md b/README.md index 9d36f85..5ecda1a 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ PWA standalone으로 진입한 경우에는 설치형 앱 사용성을 우선해 위치 갱신 명령은 `/custom/common/config.php`의 `findmydevice_location_refresh` 설정을 사용합니다. `seoul`, `main` Home Assistant 프로필의 `notify.mobile_app_seocaegeonyi_z_fold7` 서비스로 Companion App notification command를 보냅니다. 자동 업데이트는 서울 집과 목포 집 150m 반경 밖이고 충전 중이거나 `Niro` 블루투스가 연결되어 있을 때만 Android high accuracy mode를 5초 간격으로 설정하고 `force_on`, `request_location_update`, `command_update_sensors`를 호출합니다. 화면이 열려 있으면 5초마다 위치/센서 갱신 명령을 반복하고, HA 자동화도 30초마다 같은 조건을 확인합니다. 집 안이거나 충전 중도 아니고 `Niro`도 연결되어 있지 않으면 자동 명령은 `force_off`로 고정밀 위치 갱신을 끕니다. -집 판정 중심은 `/custom/common/config.php`의 `findmydevice_home` 설정을 사용합니다. 현재 서울 집 좌표는 `37.558924572072, 127.20413804054`, 반경은 `150m`입니다. 목포 집은 Home Assistant `zone.home` 기준인 `34.80634217996409, 126.38442277908327`, 반경 `150m`를 고정밀 위치 갱신 제외 구역으로 사용합니다. API 응답의 `home` 값을 브라우저가 사용하므로 설정 변경 시 집 원, 체류/외출 판정, GPS 튐 흡수 기준이 같은 좌표로 적용됩니다. +집 판정 중심은 `/custom/common/config.php`의 `findmydevice_quiet_zones` 설정을 사용합니다. 현재 서울 집 좌표는 `37.558924572072, 127.20413804054`, 목포 집 좌표는 `34.80634217996409, 126.38442277908327`이며 둘 다 반경은 `150m`입니다. API 응답의 `homes` 값을 브라우저가 사용하므로 서울 집과 목포 집 모두 집 원, 체류/외출 판정, GPS 튐 흡수 기준에 포함됩니다. ## 히스토리와 지도 @@ -70,9 +70,9 @@ Naver Map에는 단일 Polyline 중심의 부드러운 경로선을 먼저 그 경로선은 기본값으로 서버가 내려준 병합 지점을 전부 사용해 표시합니다. `GPS 튐 보정`을 켜면 집 중심 1.2km 안의 장기 체류성 흔들림은 집 위치로 흡수하고, 집 주변 보정 지점은 30분 단위로 줄여 지도 낙서를 줄입니다. 이전/다음 지점이 가까운데 중간 지점만 250m 이상 튀고 왕복 속도가 80km/h를 넘는 단발 스파이크, 500m 이상 220km/h 초과 이동, 2km 이상 120km/h 초과 이동, 정확도 300m 초과 지점은 제외합니다. 30분 이상 기록 공백은 선을 끊고 이동 거리 합산에서도 제외합니다. 지도 아래 `GPS 튐 보정` 버튼으로 전체 지점 경로와 보정 경로를 전환할 수 있습니다. -집 판정은 설정된 서울 집 좌표를 중심점으로 한 150m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있으면 집 주변 1.2km 안의 지점은 집 주변 GPS 흔들림으로 흡수하고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다. +집 판정은 설정된 서울 집과 목포 집 좌표를 중심점으로 한 150m 반경을 기준으로 합니다. GPS 튐 보정이 켜져 있으면 두 집 주변 1.2km 안의 지점은 가까운 집 위치로 흡수하고, 외출은 연속된 외부 지점이 5분 이상 지속될 때만 세션으로 확정합니다. 외부 지점 사이의 공백이 2시간을 넘으면 해당 구간은 알 수 없는 구간으로 보고 외출 지속시간에 합산하지 않습니다. 여러 번 나갔다 돌아온 경우 집 체류 시간은 합산하고, 외출은 출발/복귀 단위의 세션으로 따로 보여줍니다. -기록 시간 슬라이더를 움직이면 지도 아래에는 `월. 일. 시간` 형식의 선택 시각만 간결하게 표시합니다. 경로 재생은 얼굴 아이콘 마커만 사용하며, 재생 중에는 현재 위치 얼굴과 정확도 원을 숨깁니다. 재생 버튼은 지점 수에 따라 의미 있는 배속만 남겨 `2x`, `5x`, `10x`, `20x`, `50x`, `100x`, `500x` 순서로 돌고, 각 배속에서 최소 80단계 이상 보여줄 수 없으면 해당 배속은 건너뜁니다. 80m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하며, 클릭 시 체류 시간과 시작/종료 시각을 확인할 수 있습니다. +기록 시간 슬라이더를 움직이면 지도 아래에는 `월. 일. 시간` 형식의 선택 시각만 간결하게 표시합니다. 경로 재생은 얼굴 아이콘 마커만 사용하며, 재생 중에는 현재 위치 얼굴과 정확도 원을 숨깁니다. 재생 버튼은 지점 수에 따라 의미 있는 배속만 남겨 `2x`, `5x`, `10x`, `20x`, `50x`, `100x`, `500x` 순서로 돌고, 각 배속에서 최소 80단계 이상 보여줄 수 없으면 해당 배속은 건너뜁니다. 100m 반경 안에서 10분 이상 머문 구간은 체류 지점 마커로 묶어 표시하되, 서울 집과 목포 집 안의 체류는 계산하거나 표시하지 않습니다. 클릭 시 체류 시간과 시작/종료 시각을 확인할 수 있습니다. 재생 중 주기적 위치 갱신으로 히스토리 경로가 다시 그려져도 현재 재생 위치를 유지합니다. 재생 타이머는 시작 당시 지점 목록에 고정하지 않고 최신 히스토리 지점 목록을 기준으로 계속 진행합니다. diff --git a/js/main.js b/js/main.js index 9b992d6..e4a9e94 100644 --- a/js/main.js +++ b/js/main.js @@ -443,7 +443,9 @@ const deviceLang = (navigator.language || navigator.userLanguage || "en").starts playbackSpeedOptions: [1], rangeCircles: [], homeCircle: null, + homeCircles: [], homeCenter: null, + homeCenters: [], rawHistoryPoints: [], historyPoints: [], historyRawCount: null, @@ -1413,7 +1415,7 @@ function initMap(latlng, accuracy = 50) { createMainMarker(latlng); createAccuracyCircle(latlng, accuracy); - updateHomeCircle(); + updateHomeCircles(); ensureMapTools(); } @@ -1605,30 +1607,35 @@ function speedLabel(kmh) { } const HOME_RADIUS_M = 50; -const HOME_NEAR_RADIUS_M = 90; const HOME_JITTER_ABSORB_RADIUS_M = 1200; const HOME_JITTER_SAMPLE_SECONDS = 30 * 60; const TRACK_MAX_GAP_SECONDS = 30 * 60; const AWAY_MAX_GAP_SECONDS = 2 * 60 * 60; -function homeConfigFromData(data) { - const home = data?.home || {}; - const radius = Number(home.radius_m || HOME_RADIUS_M); +function normalizeHomeConfig(home) { + const radius = Number(home?.radius_m || HOME_RADIUS_M); return { - lat: Number(home.latitude), - lng: Number(home.longitude), + name: String(home?.name || ''), + label: String(home?.label || home?.name || ''), + lat: Number(home?.latitude), + lng: Number(home?.longitude), radius: Number.isFinite(radius) && radius > 0 ? radius : HOME_RADIUS_M, }; } -function homeRadiusMeters() { - return mapState.homeCenter?.radius || HOME_RADIUS_M; +function homeConfigsFromData(data) { + const homes = Array.isArray(data?.homes) && data.homes.length + ? data.homes + : (data?.home ? [data.home] : []); + return homes + .map(normalizeHomeConfig) + .filter(home => hasValidCoords(home.lat, home.lng)); } function updateConfiguredHome(data) { - const home = homeConfigFromData(data); - if (hasValidCoords(home.lat, home.lng)) { - updateHomeCenter(home.lat, home.lng, home.radius); + const homes = homeConfigsFromData(data); + if (homes.length) { + updateHomeCenters(homes); return true; } return false; @@ -1645,10 +1652,11 @@ function pointWithLatLng(point, lat, lng, extra = {}) { } function normalizeHomeJitterPoint(point) { - if (!mapState.filterOutliers || !mapState.homeCenter || !point) return point; - const distance = distanceFromHome(point); + if (!mapState.filterOutliers || !mapState.homeCenters?.length || !point) return point; + const nearest = nearestHome(point); + const distance = nearest.distance; if (distance > HOME_JITTER_ABSORB_RADIUS_M) return point; - return pointWithLatLng(point, mapState.homeCenter.lat, mapState.homeCenter.lng, { + return pointWithLatLng(point, nearest.home.lat, nearest.home.lng, { homeJitter: true, originalLat: point.lat, originalLng: point.lng, @@ -1699,7 +1707,7 @@ function filterHistoryOutliers(points) { function buildStayPoints(points) { const stays = []; let start = 0; - const radius = 80; + const radius = 100; for (let i = 1; i <= points.length; i++) { const base = points[start]; @@ -1713,6 +1721,10 @@ function buildStayPoints(points) { 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; + if (isHomePoint({ lat, lng })) { + start = Math.max(0, i - 1); + continue; + } stays.push({ lat, lng, @@ -1729,51 +1741,80 @@ function buildStayPoints(points) { return stays; } -function updateHomeCenter(lat, lng, radius = HOME_RADIUS_M) { - if (!hasValidCoords(lat, lng)) return; - mapState.homeCenter = { - lat: Number(lat), - lng: Number(lng), - latlng: new naver.maps.LatLng(Number(lat), Number(lng)), - radius: Math.max(1, Number(radius) || HOME_RADIUS_M) - }; - updateHomeCircle(); +function updateHomeCenters(homes) { + mapState.homeCenters = homes.map(home => ({ + ...home, + latlng: new naver.maps.LatLng(Number(home.lat), Number(home.lng)), + radius: Math.max(1, Number(home.radius) || HOME_RADIUS_M) + })); + mapState.homeCenter = mapState.homeCenters[0] || null; + updateHomeCircles(); } -function updateHomeCircle() { - if (!mapState.map || !mapState.homeCenter) return; - if (!mapState.homeCircle) { - mapState.homeCircle = new naver.maps.Circle({ - map: mapState.map, - center: mapState.homeCenter.latlng, - radius: homeRadiusMeters(), - strokeColor: '#5856d6', - strokeOpacity: 0.8, - strokeWeight: 2, - fillColor: '#5856d6', - fillOpacity: 0.08 - }); - } else { - mapState.homeCircle.setCenter(mapState.homeCenter.latlng); - mapState.homeCircle.setRadius(homeRadiusMeters()); +function updateHomeCenter(lat, lng, radius = HOME_RADIUS_M) { + if (!hasValidCoords(lat, lng)) return; + updateHomeCenters([{ lat: Number(lat), lng: Number(lng), radius }]); +} + +function updateHomeCircles() { + if (!mapState.map || !mapState.homeCenters?.length) return; + if (mapState.homeCircle && !mapState.homeCircles.includes(mapState.homeCircle)) { + mapState.homeCircle.setMap(null); + mapState.homeCircle = null; } + while (mapState.homeCircles.length > mapState.homeCenters.length) { + mapState.homeCircles.pop()?.setMap?.(null); + } + mapState.homeCenters.forEach((home, index) => { + let circle = mapState.homeCircles[index]; + if (!circle) { + circle = new naver.maps.Circle({ + map: mapState.map, + center: home.latlng, + radius: home.radius, + strokeColor: '#5856d6', + strokeOpacity: 0.8, + strokeWeight: 2, + fillColor: '#5856d6', + fillOpacity: 0.08 + }); + mapState.homeCircles[index] = circle; + } else { + circle.setMap(mapState.map); + circle.setCenter(home.latlng); + circle.setRadius(home.radius); + } + }); + mapState.homeCircle = mapState.homeCircles[0] || null; +} + +function nearestHome(point) { + if (!mapState.homeCenters?.length || !point) return { home: null, distance: Infinity }; + return mapState.homeCenters.reduce((nearest, home) => { + const distance = distanceMeters(home, point); + return distance < nearest.distance ? { home, distance } : nearest; + }, { home: null, distance: Infinity }); +} + +function isHomePoint(point) { + const nearest = nearestHome(point); + return !!nearest.home && nearest.distance <= nearest.home.radius; } function distanceFromHome(point) { - if (!mapState.homeCenter || !point) return Infinity; - return distanceMeters(mapState.homeCenter, point); + return nearestHome(point).distance; } function classifyHomePoint(point) { - const distance = distanceFromHome(point); - if (distance <= homeRadiusMeters()) return 'home'; - if (mapState.filterOutliers && distance <= HOME_JITTER_ABSORB_RADIUS_M) return 'home'; - if (distance <= HOME_NEAR_RADIUS_M) return 'near'; + const nearest = nearestHome(point); + if (!nearest.home) return 'away'; + if (nearest.distance <= nearest.home.radius) return 'home'; + if (mapState.filterOutliers && nearest.distance <= HOME_JITTER_ABSORB_RADIUS_M) return 'home'; return 'away'; } function buildHomeStats(points) { - if (!mapState.homeCenter || !Array.isArray(points) || points.length < 2) return null; + if (!mapState.homeCenters?.length || !Array.isArray(points) || points.length < 2) return null; const t = translations[langCode] || translations.ko; const minAwaySeconds = 5 * 60; let homeSeconds = 0; diff --git a/php/api.php b/php/api.php index cae4c7f..1ca2acd 100644 --- a/php/api.php +++ b/php/api.php @@ -355,7 +355,24 @@ $findmydeviceHome = [ 'latitude' => isset($findmydeviceHomeConfig['latitude']) ? (float)$findmydeviceHomeConfig['latitude'] : null, 'longitude' => isset($findmydeviceHomeConfig['longitude']) ? (float)$findmydeviceHomeConfig['longitude'] : null, 'radius_m' => max(1, (int)($findmydeviceHomeConfig['radius_m'] ?? 50)), + 'label' => '서울 집', ]; +$findmydeviceHomes = []; +foreach ((array)($config['findmydevice_quiet_zones'] ?? []) as $zone) { + if (!is_array($zone) || !isset($zone['latitude'], $zone['longitude'])) { + continue; + } + $findmydeviceHomes[] = [ + 'name' => (string)($zone['name'] ?? ''), + 'label' => (string)($zone['label'] ?? $zone['name'] ?? '집'), + 'latitude' => (float)$zone['latitude'], + 'longitude' => (float)$zone['longitude'], + 'radius_m' => max(1, (int)($zone['radius_m'] ?? $findmydeviceHome['radius_m'])), + ]; +} +if (!$findmydeviceHomes && isset($findmydeviceHome['latitude'], $findmydeviceHome['longitude'])) { + $findmydeviceHomes[] = $findmydeviceHome; +} function isMeaningfulHaValue($state): bool { @@ -896,6 +913,7 @@ if (isset($_GET['hashonly']) && $_GET['hashonly'] == '1') { 'highAccuracyMode' => $highAccuracyMode, 'sensors' => $sensorSections, 'home' => $findmydeviceHome, + 'homes' => $findmydeviceHomes, ]); echo json_encode(['hash' => hash('sha256', $raw)]); @@ -921,6 +939,7 @@ echo json_encode([ 'highAccuracyMode' => $highAccuracyMode, 'sensors' => $sensorSections, 'home' => $findmydeviceHome, + 'homes' => $findmydeviceHomes, 'history' => $includeHistory ? $path : null, 'historyRawCount' => $includeHistory ? $historyRawCount : null, 'historyRenderCount' => $includeHistory ? count($path) : null,