const deviceLang = (navigator.language || navigator.userLanguage || "en").startsWith("ko") ? "ko" : "en", translations = { ko: { blog: "블로그", title: "나의 찾기", keepScreenOn: "화면 켜짐 유지", settings: "설정", location: "위치", currentPosition: "현재 위치", gpsAccuracy: "GPS 정확도", moveToCurrent: "현재 위치로 이동하기", update: "업데이트", lastUpdate: "마지막 업데이트", updateTrigger: "업데이트 트리거", autoCurrentUpdate: "현재 위치 자동 이동", autoUpdate: "자동 업데이트", forceUpdate: "업데이트 하기", battery: "배터리", batteryState: "배터리 상태", batteryLevel: "배터리 잔량", watchbatteryState: "워치 배터리 상태", watchbatteryLevel: "워치 배터리 잔량", isCharging: "충전 여부", chargerType: "충전 방식", batteryHealth: "배터리 건강", batteryTemperature: "배터리 온도", batteryPower: "배터리 전력", remainingChargeTime: "충전 남은 시간", batteryCycleCount: "배터리 사이클", activity: "활동", currentActivity: "현재 활동", activityAccuracy: "활동 정확도", distance: "도보로 움직인 거리", steps: "걸음 수", ascended: "오른 계단", descended: "내려간 계단", etc: "기타", connection: "연결 방법", wifiConnection: "Wi-Fi", wifiIp: "Wi-Fi IP", wifiLinkSpeed: "Wi-Fi 속도", wifiFrequency: "Wi-Fi 주파수", wifiSignalStrength: "Wi-Fi 신호", mobileData: "모바일 데이터", phoneState: "전화 상태", interactive: "화면 활성", dozeMode: "Doze 모드", powerSave: "절전 모드", deviceLocked: "잠금 상태", network: "네트워크", device: "기기", displayAudio: "화면 및 오디오", health: "건강", wellness: "건강/활동", car: "차량", storageData: "저장공간 및 데이터", notifications: "알림", alerts: "알림/통화", environment: "센서", networkType: "연결 방법", wifiState: "Wi-Fi 상태", wifiBssid: "Wi-Fi BSSID", mobileDataRoaming: "데이터 로밍", publicIpAddress: "공인 IP", hotspotState: "핫스팟", ipv6Addresses: "IPv6 주소", nfcState: "NFC", sim1: "SIM 1", sim2: "SIM 2", signalStrengthSim1: "SIM 1 신호", signalStrengthSim2: "SIM 2 신호", dataNetworkTypeSim1: "SIM 1 데이터망", dataNetworkTypeSim2: "SIM 2 데이터망", osVersion: "OS 버전", securityPatch: "보안 패치", currentVersion: "앱 버전", lastReboot: "마지막 재부팅", currentTimeZone: "시간대", deviceSecure: "보안 설정", keyguardLocked: "키가드 잠김", keyguardSecure: "키가드 보안", workProfile: "업무 프로필", lastUsedApp: "마지막 사용 앱", appInactive: "앱 비활성", appStandbyBucket: "앱 대기 버킷", appImportance: "앱 중요도", appMemory: "앱 메모리", screenBrightness: "화면 밝기", screenOffTimeout: "화면 꺼짐 시간", screenOrientation: "화면 방향", screenRotation: "화면 회전", doNotDisturb: "방해 금지", ringerMode: "벨소리 모드", audioMode: "오디오 모드", headphones: "헤드폰", micMuted: "마이크 음소거", speakerphone: "스피커폰", musicActive: "미디어 재생", volumeAlarm: "알람 볼륨", volumeCall: "통화 볼륨", volumeMusic: "미디어 볼륨", volumeRinger: "벨소리 볼륨", volumeNotification: "알림 볼륨", volumeSystem: "시스템 볼륨", volumeDtmf: "DTMF 볼륨", volumeAccessibility: "접근성 볼륨", sleepConfidence: "수면 신뢰도", sleepSegment: "수면 구간", sleepDuration: "수면 시간", activeCaloriesBurned: "활동 칼로리", totalCaloriesBurned: "총 칼로리", basalBodyTemperature: "기초 체온", basalMetabolicRate: "기초대사량", bloodGlucose: "혈당", bodyFat: "체지방", bodyTemperature: "체온", bodyWaterMass: "체수분", boneMass: "골량", diastolicBloodPressure: "이완기 혈압", systolicBloodPressure: "수축기 혈압", heartRate: "심박수", heartRateVariability: "심박 변이도", restingHeartRate: "안정 심박수", oxygenSaturation: "산소포화도", respiratoryRate: "호흡수", vo2Max: "VO2 Max", weight: "체중", height: "키", dailyHydration: "일일 수분", dailyElevationGained: "일일 고도 상승", stepsSensor: "걸음 센서", androidAuto: "Android Auto", carName: "차량 이름", carBattery: "차량 배터리", carChargingStatus: "차량 충전 상태", carEvConnectorType: "차량 충전 커넥터", carFuel: "차량 연료", carFuelType: "차량 연료 종류", carOdometer: "차량 주행거리", carSpeed: "차량 속도", carRangeRemaining: "차량 잔여 주행거리", internalStorage: "내부 저장공간", externalStorage: "외부 저장공간", appRxGb: "앱 수신 데이터", appTxGb: "앱 송신 데이터", mobileRxGb: "모바일 수신 데이터", mobileTxGb: "모바일 송신 데이터", totalRxGb: "전체 수신 데이터", totalTxGb: "전체 송신 데이터", nextAlarm: "다음 알람", lastNotification: "마지막 알림", lastRemovedNotification: "마지막 제거 알림", activeNotificationCount: "활성 알림 수", mediaSession: "미디어 세션", lightSensor: "조도 센서", pressureSensor: "기압 센서", proximitySensor: "근접 센서", bluetoothConnection: "블루투스 연결", bluetoothState: "블루투스", bleTransmitter: "BLE 송신기", beaconMonitor: "비콘 모니터", accentColor: "강조 색상", editPage: "페이지 수정", pwa: "눌러 홈 화면에 추가해 바로 보기", history: "기록", startTime: "시작", endTime: "종료", mapFit: "전체", mapPlay: "재생", mapPause: "정지", historySummary: "기록 요약", historyDistance: "이동 거리", historyDuration: "기록 시간", historyPoints: "지점", historyAverageSpeed: "평균 속도", historyMaxGap: "최대 공백", historyStart: "시작", historyEnd: "종료", historyPoint: "기록 지점", historyStay: "체류", historyStops: "체류 지점", historyFiltered: "GPS 튐 보정", historyTimeline: "시간 위치", speedLegend: "속도", speedStill: "정지", speedWalk: "도보", speedFastWalk: "빠른 도보", speedSlowRide: "저속 이동", speedCityDrive: "시내 차량", speedHighway: "고속 차량", speedExtreme: "비정상 고속", speedDrive: "차량", currentRadius: "현재 반경", homeRadius: "집 반경", homeStay: "집 체류", awayTime: "외출 시간", awaySessions: "외출 횟수", lastReturn: "마지막 복귀", currentPlaceStatus: "현재 상태", atHome: "집", awayNow: "외출 중", nearHome: "집 근처", unstableLocation: "판정 불안정", departure: "출발", returnHome: "복귀", awaySessionTitle: "외출 기록", openNaverMap: "Naver 지도", selectedPoint: "선택 지점", }, en: { blog: "Blog", title: "Find My Device", keepScreenOn: "Keep Screen On", settings: "Settings", location: "Location", currentPosition: "Current Location", gpsAccuracy: "GPS Accuracy", moveToCurrent: "Move to Current Location", update: "Update", lastUpdate: "Last Update", updateTrigger: "Update Trigger", autoCurrentUpdate: "Current Location Auto Update", autoUpdate: "Auto Update", forceUpdate: "Force Update", battery: "Battery", batteryState: "Battery State", batteryLevel: "Battery Level", watchbatteryState: "Watch Battery State", watchbatteryLevel: "Watch Battery Level", isCharging: "Charging", chargerType: "Charger Type", batteryHealth: "Battery Health", batteryTemperature: "Battery Temp", batteryPower: "Battery Power", remainingChargeTime: "Remaining Charge", batteryCycleCount: "Battery Cycles", activity: "Activity", currentActivity: "Current Activity", activityAccuracy: "Activity Confidence", distance: "Distance walked", steps: "Steps", ascended: "Floors Ascended", descended: "Floors Descended", etc: "Others", connection: "Network Type", wifiConnection: "Wi-Fi", wifiIp: "Wi-Fi IP", wifiLinkSpeed: "Wi-Fi Speed", wifiFrequency: "Wi-Fi Frequency", wifiSignalStrength: "Wi-Fi Signal", mobileData: "Mobile Data", phoneState: "Phone State", interactive: "Interactive", dozeMode: "Doze Mode", powerSave: "Power Save", deviceLocked: "Device Locked", network: "Network", device: "Device", displayAudio: "Display & Audio", health: "Health", wellness: "Wellness", car: "Car", storageData: "Storage & Data", notifications: "Notifications", alerts: "Alerts & Calls", environment: "Sensors", networkType: "Connection Type", wifiState: "Wi-Fi State", wifiBssid: "Wi-Fi BSSID", mobileDataRoaming: "Data Roaming", publicIpAddress: "Public IP", hotspotState: "Hotspot", ipv6Addresses: "IPv6 Addresses", nfcState: "NFC", sim1: "SIM 1", sim2: "SIM 2", signalStrengthSim1: "SIM 1 Signal", signalStrengthSim2: "SIM 2 Signal", dataNetworkTypeSim1: "SIM 1 Data Network", dataNetworkTypeSim2: "SIM 2 Data Network", osVersion: "OS Version", securityPatch: "Security Patch", currentVersion: "App Version", lastReboot: "Last Reboot", currentTimeZone: "Time Zone", deviceSecure: "Device Secure", keyguardLocked: "Keyguard Locked", keyguardSecure: "Keyguard Secure", workProfile: "Work Profile", lastUsedApp: "Last Used App", appInactive: "App Inactive", appStandbyBucket: "App Standby Bucket", appImportance: "App Importance", appMemory: "App Memory", screenBrightness: "Screen Brightness", screenOffTimeout: "Screen Timeout", screenOrientation: "Screen Orientation", screenRotation: "Screen Rotation", doNotDisturb: "Do Not Disturb", ringerMode: "Ringer Mode", audioMode: "Audio Mode", headphones: "Headphones", micMuted: "Mic Muted", speakerphone: "Speakerphone", musicActive: "Music Active", volumeAlarm: "Alarm Volume", volumeCall: "Call Volume", volumeMusic: "Music Volume", volumeRinger: "Ringer Volume", volumeNotification: "Notification Volume", volumeSystem: "System Volume", volumeDtmf: "DTMF Volume", volumeAccessibility: "Accessibility Volume", sleepConfidence: "Sleep Confidence", sleepSegment: "Sleep Segment", sleepDuration: "Sleep Duration", activeCaloriesBurned: "Active Calories Burned", totalCaloriesBurned: "Total Calories Burned", basalBodyTemperature: "Basal Body Temperature", basalMetabolicRate: "Basal Metabolic Rate", bloodGlucose: "Blood Glucose", bodyFat: "Body Fat", bodyTemperature: "Body Temperature", bodyWaterMass: "Body Water Mass", boneMass: "Bone Mass", diastolicBloodPressure: "Diastolic Blood Pressure", systolicBloodPressure: "Systolic Blood Pressure", heartRate: "Heart Rate", heartRateVariability: "Heart Rate Variability", restingHeartRate: "Resting Heart Rate", oxygenSaturation: "Oxygen Saturation", respiratoryRate: "Respiratory Rate", vo2Max: "VO2 Max", weight: "Weight", height: "Height", dailyHydration: "Daily Hydration", dailyElevationGained: "Daily Elevation Gained", stepsSensor: "Step Sensor", androidAuto: "Android Auto", carName: "Car Name", carBattery: "Car Battery", carChargingStatus: "Car Charging Status", carEvConnectorType: "Car EV Connector", carFuel: "Car Fuel", carFuelType: "Car Fuel Type", carOdometer: "Car Odometer", carSpeed: "Car Speed", carRangeRemaining: "Car Range Remaining", internalStorage: "Internal Storage", externalStorage: "External Storage", appRxGb: "App RX Data", appTxGb: "App TX Data", mobileRxGb: "Mobile RX Data", mobileTxGb: "Mobile TX Data", totalRxGb: "Total RX Data", totalTxGb: "Total TX Data", nextAlarm: "Next Alarm", lastNotification: "Last Notification", lastRemovedNotification: "Last Removed Notification", activeNotificationCount: "Active Notifications", mediaSession: "Media Session", lightSensor: "Light Sensor", pressureSensor: "Pressure Sensor", proximitySensor: "Proximity Sensor", bluetoothConnection: "Bluetooth Connection", bluetoothState: "Bluetooth", bleTransmitter: "BLE Transmitter", beaconMonitor: "Beacon Monitor", accentColor: "Accent Color", editPage: "Edit Page", pwa: "Add to Home Screen for quick access!", history: "History", startTime: "Start", endTime: "End", 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", historyStay: "Stay", historyStops: "Stops", historyFiltered: "GPS Filter", historyTimeline: "Timeline", speedLegend: "Speed", speedStill: "Still", speedWalk: "Walk", speedFastWalk: "Fast Walk", speedSlowRide: "Slow Ride", speedCityDrive: "City Drive", speedHighway: "Highway", speedExtreme: "Extreme", speedDrive: "Drive", currentRadius: "Current Radius", homeRadius: "Home Radius", homeStay: "Home Stay", awayTime: "Away Time", awaySessions: "Away Trips", lastReturn: "Last Return", currentPlaceStatus: "Current Status", atHome: "Home", awayNow: "Away", nearHome: "Near Home", unstableLocation: "Unstable", departure: "Departure", returnHome: "Return", awaySessionTitle: "Away Sessions", openNaverMap: "Naver Map", selectedPoint: "Selected Point", }, }, mapState = { map: null, marker: null, circle: null, trackLine: null, trackLines: [], smallMarkers: [], stayMarkers: [], historyStartMarker: null, historyEndMarker: null, playbackMarker: null, selectedMarker: null, playbackTimer: null, playbackIndex: 0, rangeCircles: [], homeCircle: null, homeCenter: null, rawHistoryPoints: [], historyPoints: [], historyRawCount: null, historyRenderCount: null, historyStats: null, stayPoints: [], homeStats: null, filterOutliers: true, openInfoWindow: null, mapClickHandlerAdded: false, initialInfoOpened: false, historyBoundsFitted: false, }; let langCode = localStorage.getItem("currentLang") || deviceLang, lastAutoSwitchChecked, lastUpdateSwitchChecked, lastHash, lastLat, lastLng, lastUpdateISOTime, map, marker, circle, radiusInterval, _overrideRange, wakeLock = null; const FALLBACK_BACK_TARGET = 'https://chaegeon.com/'; const BACK_LINK_PATH_TITLE = { '/': '채건닷컴', '/home': '홈', '/blog': '블로그', }; const FINDMYDEVICE_AUTH_REQUIRED = location.pathname.startsWith('/findmydevice'); function isStandalonePwa() { return window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches; } let findmydeviceAuthenticated = !FINDMYDEVICE_AUTH_REQUIRED || isStandalonePwa(); function renderAuthErrorPage() { document.body.innerHTML = `
`; const renderPin = () => window.CustomAuthPin?.render({ mount: '#findMyShortcut', title: '비밀번호', placeholder: '비밀번호', backLabel: '채건닷컴', backHref: 'https://chaegeon.com/', onSuccess: () => location.reload(), }); if (window.CustomAuthPin) { renderPin(); return; } const script = document.createElement('script'); script.src = '/custom/common/js/auth-pin.js'; script.onload = renderPin; document.body.appendChild(script); } const findmydeviceAuthPromise = FINDMYDEVICE_AUTH_REQUIRED && !isStandalonePwa() ? fetch('/custom/launcher/api.php?session=1', { credentials: 'same-origin' }) .then(res => res.ok ? res.json() : Promise.reject()) .then(session => { findmydeviceAuthenticated = !!session.authenticated; return findmydeviceAuthenticated; }) .catch(() => { findmydeviceAuthenticated = false; return false; }) : Promise.resolve(true); function runAfterFindAuth(callback) { return findmydeviceAuthPromise .then(ok => ok ? callback() : null) .catch(() => null); } function cleanPageTitle(title) { return String(title || '') .replace(/^채건닷컴\s*-\s*/i, '') .replace(/\s*-\s*채건닷컴$/i, '') .trim(); } function sameSiteUrl(url) { try { const parsed = new URL(url, location.href); return parsed.hostname === location.hostname || parsed.hostname.endsWith('.chaegeon.com') || parsed.hostname === 'chaegeon.com' ? parsed : null; } catch (e) { return null; } } function titleFromBackUrl(url) { const parsed = sameSiteUrl(url); if (!parsed) return ''; return BACK_LINK_PATH_TITLE[parsed.pathname.replace(/\/$/, '') || '/'] || cleanPageTitle(parsed.pathname.split('/').filter(Boolean).pop() || ''); } async function resolveBackTarget() { const ref = sameSiteUrl(document.referrer || ''); const current = new URL(location.href); const target = ref && ref.href !== current.href ? ref.href : FALLBACK_BACK_TARGET; const knownTitle = titleFromBackUrl(target); let title = knownTitle; if (!knownTitle && ref && ref.origin === location.origin) { try { const res = await fetch(ref.href, { credentials: 'same-origin', cache: 'force-cache' }); if (res.ok) { const html = await res.text(); const doc = new DOMParser().parseFromString(html, 'text/html'); title = cleanPageTitle(doc.querySelector('title')?.textContent) || title; } } catch (e) {} } return { href: target, label: title || '블로그' }; } function updateBackLinkLabel(label) { const blogBackLink = document.getElementById('blogBackLink'); if (!blogBackLink) return; blogBackLink.innerHTML = ` ${label}`; } function setupBackLink() { const blogBackLink = document.getElementById('blogBackLink'); if (!blogBackLink) return; blogBackLink.href = FALLBACK_BACK_TARGET; blogBackLink.dataset.backLabel = '채건닷컴'; updateBackLinkLabel(blogBackLink.dataset.backLabel); resolveBackTarget().then(({ href, label }) => { blogBackLink.href = href; blogBackLink.dataset.backLabel = label; updateBackLinkLabel(label); }); blogBackLink.addEventListener('click', (e) => { e.preventDefault(); location.href = blogBackLink.href || FALLBACK_BACK_TARGET; }); } function ensureFindMyDeviceShell() { let mount = document.getElementById('findmydevice'); if (!mount) { mount = document.createElement('div'); mount.id = 'findmydevice'; document.body.prepend(mount); } document.querySelectorAll('#mySettingContainer, #ios-pwa-tooltip').forEach(el => { if (!mount.contains(el)) el.remove(); }); mount.innerHTML = `
Language Toggle
 
`; } ensureFindMyDeviceShell(); applyLanguage(langCode); document.querySelectorAll('.locateRow').forEach(row => { row.setAttribute('role', 'button'); row.tabIndex = 0; }); document.getElementById('langToggle').addEventListener('click', () => { langCode = langCode === 'ko' ? 'en' : 'ko'; localStorage.setItem('currentLang', langCode); applyLanguage(langCode); runAfterFindAuth(() => fetchDeviceData(true).then(data => updateDOMWithData(data))); // locateBtn 즉시 재렌더링 if (document.getElementById('locateBtn')) { document.getElementById('labelMoveToCurrent').textContent = langCode === 'ko' ? '현재 위치로 이동하기' : 'Move to Current Location'; } if (document.getElementById('autoUpdateBtn')) { document.getElementById('labelAutoUpdate').textContent = langCode === 'ko' ? '자동 업데이트' : 'Auto Update'; } if (document.getElementById('HistoryResetBtn')) { document.getElementById('labelHistoryReset').textContent = langCode === 'ko' ? '초기화' : 'Reset'; } if (document.getElementById('updateBtn')) { document.getElementById('labelForceUpdate').textContent = langCode === 'ko' ? '업데이트 하기' : 'Force Update'; } }); document.getElementById('locateBtn').addEventListener('click', () => { if (_overrideRange) return; if (hasValidCoords(lastLat, lastLng)) { const latlng = new naver.maps.LatLng(lastLat, lastLng); mapState.map.panTo(latlng); } }); // [REPLACE] 기존 초기 updateBtn 리스너 document.getElementById('updateBtn').addEventListener('click', triggerForceUpdate); document.getElementById('autoPositionSwitch').addEventListener('change', () => { syncLocateBtn(); lastAutoSwitchChecked = document.getElementById('autoPositionSwitch').checked; }); document.addEventListener('DOMContentLoaded', () => { const isMobile = /Mobi|Android|iPhone|iPad|iPod/i.test(navigator.userAgent); if (!isMobile) { const el = document.getElementById('mobileonly'); if (el) el.remove(); } const container = document.getElementById('mySettingContainer'); container.style.userSelect = 'none'; container.style.webkitUserSelect = 'none'; container.style.msUserSelect = 'none'; container.style.MozUserSelect = 'none'; container.style.webkitTouchCallout = 'none'; container.addEventListener('dragstart', e => e.preventDefault()); setupBackLink(); if (FINDMYDEVICE_AUTH_REQUIRED) { findmydeviceAuthPromise.then(ok => { if (!ok) renderAuthErrorPage(); }); } const btnArea = document.querySelector('body > div.btnArea'); const pageModifyLink = btnArea?.querySelectorAll('a')[1]?.getAttribute('href') || ''; if (btnArea) btnArea.remove(); // 페이지 수정 버튼 요소 생성 const editRow = document.createElement('div'); editRow.className = 'settingRow locateRow'; editRow.style.cursor = 'pointer'; editRow.innerHTML = `${langCode === 'ko' ? '페이지 수정' : 'Edit Page'}`; editRow.addEventListener('click', () => { window.location.href = pageModifyLink; }); const settingTitle = [...document.querySelectorAll('.settingTitle')].find(el => el.textContent.trim() === (langCode === 'ko' ? '설정' : 'Settings')); let settingSection = settingTitle?.nextElementSibling; // 삽입 기준: #mapContainer 다음 const mapContainer = document.getElementById('mapContainer'); const toggleContent = document.getElementById('toggleContent'); if (pageModifyLink) { // 설정 섹션이 없으면 새로 생성 (mapContainer 아래) if (!settingSection || !settingSection.classList.contains('settingSection')) { const newTitle = document.createElement('div'); newTitle.className = 'settingTitle'; newTitle.innerHTML = `${langCode === 'ko' ? '설정' : 'Settings'}`; const newSection = document.createElement('div'); newSection.className = 'settingSection'; newSection.appendChild(editRow); if (mapContainer) { toggleContent.insertBefore(newTitle, mapContainer.nextSibling); toggleContent.insertBefore(newSection, newTitle.nextSibling); } } else { // 설정 섹션이 이미 존재하는 경우 const keepRow = settingSection.querySelector('#labelKeepScreenOn')?.closest('.settingRow'); if (keepRow) { if (!isMobile) keepRow.remove(), settingSection.prepend(editRow); else keepRow.insertAdjacentElement('afterend', editRow); } else { settingSection.appendChild(editRow); } } } else { // pageModifyLink 없음 → 셀렉터 존재 안 함 if (!isMobile && settingSection && settingTitle) { settingTitle.remove(); settingSection.remove(); } } const tooltip = document.getElementById("ios-pwa-tooltip"); const closeBtn = document.getElementById("close-tooltip"); const isStandalone = window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches; const tooltipClosed = getCookie('iosPwaTooltipClosed'); // 없으면 쿠키 만료 or 미설정 if (tooltip && closeBtn && isIosSafari() && !isStandalone && !tooltipClosed) { function handleScroll() { const scrollPercent = ((window.scrollY || document.documentElement.scrollTop) / (document.documentElement.scrollHeight - document.documentElement.clientHeight)) * 100; if (scrollPercent > 10) { tooltip.classList.add("visible"); } else { tooltip.classList.remove("visible"); } } window.addEventListener("scroll", handleScroll, { passive: true }); closeBtn.addEventListener("click", function () { tooltip.classList.remove("visible"); setCookieHours("iosPwaTooltipClosed", "1", 12); // 12시간 동안 숨김 window.removeEventListener("scroll", handleScroll); }); } const btn = document.getElementById('HistoryResetBtn'); if (btn) { // 라벨 const label = document.getElementById('labelHistoryReset'); (label || btn).textContent = (langCode === 'ko') ? '초기화' : 'Reset'; btn.addEventListener('click', () => { // 1) 히스토리 모드 해제 → 라이브 복귀 _overrideRange = null; // 2) 입력 요소 가져오기 (같은 스코프에서!) const startEl = document.getElementById('rangeStart'); const endEl = document.getElementById('rangeEnd'); if (startEl && endEl) { const pad = n => String(n).padStart(2, '0'); const toLocalHour = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`; // 기준 시각(현재) const current = new Date(); // 시작: 24시간 전 → 정각 버림 const start24 = new Date(current.getTime() - 24 * 3600 * 1000); start24.setMinutes(0, 0, 0); // 종료: 현재 시각의 다음 정각 올림 const endRoundUp = new Date(current); endRoundUp.setMinutes(0, 0, 0); if (current.getMinutes() > 0 || current.getSeconds() > 0 || current.getMilliseconds() > 0) { endRoundUp.setHours(endRoundUp.getHours() + 1); } // 입력값 세팅 startEl.value = toLocalHour(start24); endEl.value = toLocalHour(endRoundUp); } // 3) 지도 히스토리 초기화 & 라이브 데이터 재요청 + 현재 위치로 이동 resetHistoryLayer(); runAfterFindAuth(() => fetchDeviceData(true).then(data => { updateDOMWithData(data); if (data.latitude && data.longitude && mapState.map) { mapState.map.panTo(new naver.maps.LatLng(data.latitude, data.longitude)); } })); }); } (function initInstantHistorySearch() { const startEl = document.getElementById('rangeStart'); const endEl = document.getElementById('rangeEnd'); if (!startEl || !endEl) return; const pad = n => String(n).padStart(2, '0'); const toLocalHour = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`; const toServerTime = v => v ? (v.includes('T') ? v.replace('T', ' ') : v) + (v.length === 16 ? ':00' : '') : null; const now = new Date(); // 시작: 현재 시각에서 24시간 전, 정각 버림 const start24 = new Date(now.getTime() - 24 * 3600 * 1000); start24.setMinutes(0, 0, 0); // 종료: 현재 시각, 다음 정각 올림 const endRoundUp = new Date(now); endRoundUp.setMinutes(0, 0, 0); if (now.getMinutes() > 0 || now.getSeconds() > 0 || now.getMilliseconds() > 0) { endRoundUp.setHours(endRoundUp.getHours() + 1); } const qs = new URLSearchParams(window.location.search); startEl.value = qs.get('startTime') ? qs.get('startTime').replace(' ', 'T').slice(0, 16) : toLocalHour(start24); endEl.value = qs.get('endTime') ? qs.get('endTime').replace(' ', 'T').slice(0, 16) : toLocalHour(endRoundUp); const hasQS = qs.has('startTime') || qs.has('endTime'); const fire = debounce(async (userTriggered = false) => { const start = startEl.value; const end = endEl.value; if (!start || !end) return; if (start > end) endEl.value = start; const range = { startTime: toServerTime(startEl.value), endTime: toServerTime(endEl.value) }; const shouldUseHistory = userTriggered || hasQS; const shouldRequestHistory = shouldUseHistory || !userTriggered; if (shouldUseHistory) { _overrideRange = range; resetHistoryLayer(); } else { _overrideRange = null; // 라이브 모드 유지 } try { const data = await fetchDeviceData(true, shouldRequestHistory ? range : null); updateDOMWithData(data); if (!shouldUseHistory && data.history?.length > 0) { applyHistoryCounts(data); updateHistoryMarkers(data.history); } const last = data.history && data.history[data.history.length - 1]; if (shouldUseHistory && last && mapState.map) { mapState.map.panTo(new naver.maps.LatLng(last.lat, last.lng)); } } catch (err) { alert(langCode === 'ko' ? '기록을 불러오지 못했습니다.' : 'Failed to load history.'); } }, 400); ['change', 'input'].forEach(ev => { startEl.addEventListener(ev, () => fire(true)); endEl.addEventListener(ev, () => fire(true)); }); runAfterFindAuth(() => fire(false)); })(); }); document.getElementById('wakeLockSwitch').addEventListener('change', async function () { if (this.checked) { try { wakeLock = await navigator.wakeLock.request('screen'); wakeLock.addEventListener('release', () => { wakeLock = null; document.getElementById('wakeLockSwitch').checked = false; }); } catch (err) { this.checked = false; } } else { if (wakeLock) { wakeLock.release(); wakeLock = null; } } }); document.getElementById('autoUpdateSwitch').addEventListener('change', () => { syncUpdateBtn(); const isChecked = document.getElementById('autoUpdateSwitch')?.checked; if (isChecked) { callDeviceWebhook('auto_on'); } else { callDeviceWebhook('auto_off'); } }); ['pagehide', 'visibilitychange', 'blur', 'beforeunload'].forEach(eventType => { window.addEventListener(eventType, function (e) { const switchEl = document.getElementById('autoUpdateSwitch'); const isAutoUpdateOn = switchEl?.checked; if (isAutoUpdateOn) { callDeviceWebhook('auto_off', true); // 스위치 UI 강제 끔 switchEl.checked = false; syncUpdateBtn(); } }); }); setInterval(() => { if (!findmydeviceAuthenticated) return; if (document.getElementById('autoUpdateSwitch')?.checked) { callDeviceWebhook('auto_tick'); } }, 10000); setInterval(() => { if (!findmydeviceAuthenticated) return; if (lastUpdateISOTime) { const el = document.getElementById("lastUpdateTime"); if (el) el.textContent = getRelativeTime(lastUpdateISOTime); } // 히스토리 범위가 활성화된 경우: hash 체크 생략하고 같은 범위로 재요청(낭비 방지로 5초 권장) if (_overrideRange) return; // 간단히 폴링 정지 (원하면 5초 주기로 같은 범위 재요청하도록 변경 가능) fetchDeviceData(false).then(({ hash }) => { if (!hash || hash === lastHash) return; lastHash = hash; fetchDeviceData(true).then(data => updateDOMWithData(data)); }); }, 1000); function formatNumber(num) { if (num == null || isNaN(num)) return '-'; return new Intl.NumberFormat().format(num); } function getRelativeTime(isoTime) { const d = new Date(isoTime), n = new Date(); const diffDays = Math.floor( (new Date(n.getFullYear(), n.getMonth(), n.getDate()) - new Date(d.getFullYear(), d.getMonth(), d.getDate())) / 86400000 ); const isKo = (typeof langCode !== 'undefined' && langCode === 'ko'); const pad = (x) => x.toString().padStart(2, '0'); const h24 = d.getHours(); const p = isKo ? (h24 < 12 ? '오전' : '오후') : (h24 < 12 ? 'AM' : 'PM'); const h12 = pad(h24 % 12 === 0 ? 12 : h24 % 12); const m = pad(d.getMinutes()), s = pad(d.getSeconds()); if (isKo) { return diffDays === 0 ? `${p} ${h12}:${m}:${s}` : `${diffDays}일 전 ${p} ${h12}:${m}:${s}`; } return diffDays === 0 ? `${p} ${h12}:${m}:${s}` : `${diffDays} day${diffDays > 1 ? 's' : ''} ago ${p} ${h12}:${m}:${s}`; } 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 }, labelMap = { labelKeepScreenOn: t.keepScreenOn, labelCurrentPosition: t.currentPosition, labelGpsAccuracy: t.gpsAccuracy, labelMoveToCurrent: t.moveToCurrent, labelLastUpdate: t.lastUpdate, labelUpdateTrigger: t.updateTrigger, labelCurrentAutoPosition: t.autoCurrentUpdate, labelForceUpdate: t.forceUpdate, labelBatteryState: t.batteryState, labelBatteryLevel: t.batteryLevel, labelWatchBatteryState: t.watchbatteryState, labelWatchBatteryLevel: t.watchbatteryLevel, labelCurrentActivity: t.currentActivity, labelActivityAccuracy: t.activityAccuracy, labelDistance: t.distance, labelSteps: t.steps, labelAscended: t.ascended, labelDescended: t.descended, labelConnection: t.connection, labelEditPage: t.editPage, labelAutoUpdate: t.autoUpdate, labelStartTime: t.startTime, labelEndTime: t.endTime, labelPwa: t.pwa, }; const blogBackLink = document.getElementById('blogBackLink'); if (blogBackLink) updateBackLinkLabel(blogBackLink.dataset.backLabel || t.blog); const headerTitle = document.querySelector('.headerTitle'); if (headerTitle) headerTitle.textContent = t.title; for (const id in titleMap) { const el = document.getElementById(id); if (el) el.textContent = titleMap[id]; } for (const id in labelMap) { const el = document.getElementById(id); if (el) el.textContent = labelMap[id]; } cleanupLegacyConnectionRow(); document.querySelectorAll('[data-sensor-title="1"]').forEach(title => { const sectionKey = title.dataset.sensorSection; const label = title.querySelector('span'); if (label) label.textContent = t[sectionKey] || sectionKey; }); document.querySelectorAll('[data-sensor-row="1"]').forEach(row => { const key = row.dataset.sensorKey; const label = row.querySelector('span'); if (label && key) label.textContent = t[key] || key; }); updateMapToolLabels(); renderHistorySummary(); } function setCookieHours(name, value, hours) { const d = new Date(); d.setTime(d.getTime() + hours * 60 * 60 * 1000); document.cookie = `${encodeURIComponent(name)}=${encodeURIComponent(value)}; expires=${d.toUTCString()}; path=/; SameSite=Lax`; } function updateDOM(data) { const textMap = { currentPosition: data.address, lastUpdateTime: data.updated ? getRelativeTime(data.updated) : "-", nowActivity: data.activity, connectionType: data.connection, updateTrigger: data.trigger, batteryStatus: data.state, Battery: data.level > 0 ? `${data.level}%` : "-", WbatteryStatus: data.wstate, WBattery: data.wlevel > 0 ? `${data.wlevel}%` : "-", todaySteps: data.steps > 0 ? `${formatNumber(data.steps)}${langCode === "ko" ? "걸음" : "steps"}` : "-", todayAscended: data.ascended > 0 ? `${formatNumber(data.ascended)}${langCode === "ko" ? "층" : "floors"}` : "-", todayDescended: data.descended > 0 ? `${formatNumber(data.descended)}${langCode === "ko" ? "층" : "floors"}` : "-", todayDistance: data.distance > 0 ? `${formatNumber(data.distance)}m` : "-", accuracyValue: data.accuracy > 0 ? `${formatNumber(data.accuracy)}m` : "-" }; for (const [id, value] of Object.entries(textMap)) { const el = document.getElementById(id); if (el) el.textContent = value || "-"; } toggleOptionalRow('WbatteryStatus', data.wstate); toggleOptionalRow('WBattery', data.wlevel); updateSensorSections(data.sensors || {}); cleanupLegacyConnectionRow(); } function cleanupLegacyConnectionRow() { const valueEl = document.getElementById('connectionType'); const row = valueEl?.closest('.settingRow'); row?.remove(); const title = document.getElementById('titleEtc'); const nextSection = title?.nextElementSibling; if (title && nextSection?.classList?.contains('settingSection') && !nextSection.querySelector('.settingRow')) { nextSection.remove(); title.remove(); } } function updateSensorSections(sections) { const t = translations[langCode] || translations.ko; const activeSectionIds = new Set(); const activeRowIds = new Set(); Object.entries(sections || {}).forEach(([sectionKey, rows]) => { const entries = Object.entries(rows || {}).filter(([, value]) => normalizeDisplayValue(value)); if (!entries.length) return; activeSectionIds.add(sectionKey); const section = ensureSensorSection(sectionKey); if (!section) return; const title = document.getElementById(`sensorTitleText_${sectionKey}`); if (title) title.textContent = t[sectionKey] || sectionKey; entries.forEach(([key, value]) => { activeRowIds.add(`sensor_${sectionKey}_${key}`); upsertOptionalRow(section, `sensor_${sectionKey}_${key}`, t[key] || key, value); }); }); document.querySelectorAll('[data-sensor-row="1"]').forEach(row => { if (!activeRowIds.has(row.id)) row.remove(); }); document.querySelectorAll('[data-sensor-title="1"]').forEach(title => { const sectionKey = title.dataset.sensorSection; const section = document.getElementById(`sensorSection_${sectionKey}`); if (!activeSectionIds.has(sectionKey) || !section?.querySelector('[data-sensor-row="1"]')) { section?.remove(); title.remove(); } }); } function ensureSensorSection(sectionKey) { let section = document.getElementById(`sensorSection_${sectionKey}`); if (section) return section; const t = translations[langCode] || translations.ko; const toggleContent = document.getElementById('toggleContent') || document.body; const title = document.createElement('div'); title.className = 'settingTitle'; title.id = `sensorTitle_${sectionKey}`; title.dataset.sensorTitle = '1'; title.dataset.sensorSection = sectionKey; title.innerHTML = `${t[sectionKey] || sectionKey}`; section = document.createElement('div'); section.className = 'settingSection'; section.id = `sensorSection_${sectionKey}`; section.dataset.sensorSection = sectionKey; toggleContent.appendChild(title); toggleContent.appendChild(section); return section; } function upsertOptionalRow(section, id, label, value) { if (!section) return; const displayValue = normalizeDisplayValue(value); let row = document.getElementById(id); if (!displayValue) { if (row) row.remove(); return; } if (!row) { row = document.createElement('div'); row.className = 'settingRow'; row.id = id; row.dataset.sensorRow = id.startsWith('sensor_') ? '1' : '0'; row.innerHTML = ``; section.appendChild(row); } if (id.startsWith('sensor_')) { row.dataset.sensorKey = id.split('_').slice(2).join('_'); } row.children[0].textContent = label; row.children[1].textContent = displayValue; } function toggleOptionalRow(valueId, value) { const el = document.getElementById(valueId); const row = el?.closest('.settingRow'); if (!row) return; row.style.display = normalizeDisplayValue(value) ? '' : 'none'; } function normalizeDisplayValue(value) { if (value === null || typeof value === 'undefined' || value === '' || value === '-' || value === 'unknown' || value === 'unavailable') { return ''; } return String(value); } function updateActivitySection(data) { const validActivities = ["Stationary", "Walking", "Running", "In Vehicle", "Cycling", "가만히 있음", "걷는 중", "달리는 중", "차량 이동 중", "자전거 이동 중"]; const validAccuracies = ["높음", "중간", "낮음", "High", "Middle", "Low"]; let existActivityEl = document.getElementById('existnowActivity'); if (existActivityEl) { if (validActivities.includes(data.activity || '') && validAccuracies.includes(data.aaccuracy || '')) { existActivityEl.style.display = 'flex'; document.getElementById('nowactivityAccuracy').textContent = data.aaccuracy || '-'; } else { existActivityEl.remove(); } } if (!document.getElementById('existnowActivity') && validActivities.includes(data.activity || '') && validAccuracies.includes(data.aaccuracy || '')) { const titles = document.querySelectorAll('.settingTitle'); let activitySection = null; for (let title of titles) { if (title.textContent.trim() === (langCode === 'ko' ? '활동' : 'Activity')) { const next = title.nextElementSibling; if (next && next.classList.contains('settingSection')) { activitySection = next; break; } } } if (activitySection) { const rows = activitySection.querySelectorAll('.settingRow'); let targetRow = null; for (let row of rows) { const label = row.querySelector('span'); if (label && label.textContent.trim() === (langCode === 'ko' ? '현재 활동' : 'Current Activity')) { targetRow = row; break; } } if (targetRow) { const newRow = document.createElement('div'); newRow.className = 'settingRow'; newRow.id = 'existnowActivity'; newRow.innerHTML = ` ${langCode === 'ko' ? '활동 정확도' : 'Activity Accuracy'} ${data.aaccuracy || '-'} `; activitySection.insertBefore(newRow, targetRow.nextSibling); } } } } 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); updateHomeCircle(); ensureMapTools(); } function ensureMapTools() { const container = document.getElementById('mapContainer') || document.getElementById('map')?.parentElement; 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'; tools.innerHTML = ` `; panel.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; panel.appendChild(summary); const timeline = document.createElement('div'); timeline.id = 'historyTimelinePanel'; timeline.className = 'history-timeline-panel'; timeline.hidden = true; timeline.innerHTML = `
-
`; panel.appendChild(timeline); timeline.querySelector('#historyTimelineSlider')?.addEventListener('input', event => { moveSelectedHistoryPoint(Number(event.target.value), true); }); updateMapToolLabels(); } function updateMapToolLabels() { const t = translations[langCode] || translations.ko; const labelMap = { 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) { if (!mapState.map) return; if (action === 'fit') { fitHistoryOrCurrentBounds(); return; } 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(); } } 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 speedBucket(kmh) { const t = translations[langCode] || translations.ko; if (!Number.isFinite(kmh) || kmh < 1) return { key: 'still', label: t.speedStill, color: '#8e8e93' }; if (kmh < 5) return { key: 'walk', label: t.speedWalk, color: '#34c759' }; if (kmh < 8) return { key: 'fastWalk', label: t.speedFastWalk, color: '#00c7be' }; if (kmh < 20) return { key: 'slowRide', label: t.speedSlowRide, color: '#5ac8fa' }; if (kmh < 60) return { key: 'cityDrive', label: t.speedCityDrive, color: '#ff9500' }; if (kmh < 110) return { key: 'highway', label: t.speedHighway, color: '#ff3b30' }; return { key: 'extreme', label: t.speedExtreme, color: '#af52de' }; } 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 formatTimelineTime(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) { return speedBucket(kmh).color; } function speedLabel(kmh) { return speedBucket(kmh).label; } 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); return { 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 updateConfiguredHome(data) { const home = homeConfigFromData(data); if (hasValidCoords(home.lat, home.lng)) { updateHomeCenter(home.lat, home.lng, home.radius); return true; } return false; } function pointWithLatLng(point, lat, lng, extra = {}) { return { ...point, ...extra, lat, lng, latlng: new naver.maps.LatLng(lat, lng) }; } function normalizeHomeJitterPoint(point) { if (!mapState.filterOutliers || !mapState.homeCenter || !point) return point; const distance = distanceFromHome(point); if (distance > HOME_JITTER_ABSORB_RADIUS_M) return point; return pointWithLatLng(point, mapState.homeCenter.lat, mapState.homeCenter.lng, { homeJitter: true, originalLat: point.lat, originalLng: point.lng, originalDistanceFromHome: distance, }); } function isSinglePointSpike(prev, cur, next) { if (!prev || !cur || !next) return false; const prevNext = distanceMeters(prev, next); if (prevNext > 180) return false; const inDistance = distanceMeters(prev, cur); const outDistance = distanceMeters(cur, next); if (inDistance < 250 || outDistance < 250) return false; const inSeconds = Math.max(1, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000); const outSeconds = Math.max(1, (toTimestamp(next.time) - toTimestamp(cur.time)) / 1000); const inKmh = inDistance / inSeconds * 3.6; const outKmh = outDistance / outSeconds * 3.6; return inKmh > 80 && outKmh > 80; } function filterHistoryOutliers(points) { if (!mapState.filterOutliers || points.length < 3) return points; const filtered = [normalizeHomeJitterPoint(points[0])]; for (let i = 1; i < points.length; i++) { if (i < points.length - 1 && isSinglePointSpike(points[i - 1], points[i], points[i + 1])) { continue; } const prev = filtered[filtered.length - 1]; const cur = normalizeHomeJitterPoint(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 > 500 && kmh > 220) continue; if (distance > 2000 && kmh > 120) continue; if (cur.accuracy && Number(cur.accuracy) > 300 && distance > 300) continue; if (cur.homeJitter && prev.homeJitter && seconds < HOME_JITTER_SAMPLE_SECONDS) { 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 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 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 distanceFromHome(point) { if (!mapState.homeCenter || !point) return Infinity; return distanceMeters(mapState.homeCenter, point); } 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'; return 'away'; } function buildHomeStats(points) { if (!mapState.homeCenter || !Array.isArray(points) || points.length < 2) return null; const t = translations[langCode] || translations.ko; const minAwaySeconds = 5 * 60; let homeSeconds = 0; let unstableSeconds = 0; let currentAway = null; let lastReturn = null; const sessions = []; const closeAwaySession = (returnTime = null) => { if (!currentAway) return; currentAway.returnTime = returnTime; if (currentAway.duration >= minAwaySeconds) { sessions.push(currentAway); if (returnTime) lastReturn = returnTime; } currentAway = null; }; for (let i = 1; i < points.length; i++) { const prev = points[i - 1]; const cur = points[i]; const duration = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000); if (!duration) continue; const isLargeGap = duration > AWAY_MAX_GAP_SECONDS; const prevState = classifyHomePoint(prev); const curState = classifyHomePoint(cur); const state = prevState === 'away' && curState === 'away' ? 'away' : (prevState === 'home' || curState === 'home' ? 'home' : 'near'); if (state === 'home') { homeSeconds += duration; closeAwaySession(cur.time); } else if (state === 'away') { if (isLargeGap) { closeAwaySession(null); unstableSeconds += duration; continue; } if (!currentAway) { currentAway = { departureTime: prev.time, returnTime: null, duration: 0 }; } currentAway.duration += duration; } else { closeAwaySession(null); unstableSeconds += duration; } } if (currentAway) { closeAwaySession(null); } const lastState = classifyHomePoint(points.at(-1)); const status = lastState === 'home' ? t.atHome : (lastState === 'away' ? t.awayNow : t.nearHome); const awaySeconds = sessions.reduce((sum, session) => sum + session.duration, 0); return { homeSeconds, awaySeconds, unstableSeconds, sessions, lastReturn, status, }; } 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; let maxGap = 0; for (let i = 1; i < points.length; i++) { const gap = Math.max(0, (toTimestamp(points[i].time) - toTimestamp(points[i - 1].time)) / 1000); maxGap = Math.max(maxGap, gap); if (gap <= TRACK_MAX_GAP_SECONDS) { distance += distanceMeters(points[i - 1], points[i]); } } 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, rawPoints: mapState.historyRawCount || points.length, renderPoints: mapState.historyRenderCount || points.length, averageSpeed: duration > 0 ? distance / duration * 3.6 : 0, stops: mapState.stayPoints?.length || 0, home: mapState.homeStats, 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; const home = stats.home; const sessions = home?.sessions || []; const recentSessions = sessions.slice(-3).reverse(); panel.hidden = false; panel.innerHTML = `
${t.historySummary}
${t.historyDistance}${formatDistance(stats.distance)} ${t.historyDuration}${formatDuration(Math.round(stats.duration))} ${t.historyAverageSpeed}${formatSpeed(stats.averageSpeed)} ${t.historyMaxGap}${formatDuration(Math.round(stats.maxGap))} ${t.historyPoints}${stats.rawPoints && stats.rawPoints !== stats.renderPoints ? `${formatNumber(stats.renderPoints)} / ${formatNumber(stats.rawPoints)}` : formatNumber(stats.points)} ${t.historyStops}${formatNumber(stats.stops || 0)} ${t.homeRadius}${mapState.filterOutliers ? `${formatNumber(homeRadiusMeters())}m + GPS 1.2km` : `${formatNumber(homeRadiusMeters())}m`} ${t.homeStay}${home ? formatDuration(Math.round(home.homeSeconds)) : '-'} ${t.awayTime}${home ? formatDuration(Math.round(home.awaySeconds)) : '-'} ${t.awaySessions}${home ? formatNumber(sessions.length) : '-'} ${t.lastReturn}${home?.lastReturn ? formatDateTime(home.lastReturn) : '-'} ${t.currentPlaceStatus}${home?.status || '-'}
${recentSessions.length ? `
${t.awaySessionTitle} ${recentSessions.map(session => ` ${t.departure} ${formatDateTime(session.departureTime)} · ${session.returnTime ? `${t.returnHome} ${formatDateTime(session.returnTime)}` : t.awayNow} · ${formatDuration(Math.round(session.duration))} `).join('')}
` : ''} `; } 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 `
${title} ${formatDateTime(point.time)} · ${getRelativeTime(point.time)} ${t.historyDistance}: ${index > 0 ? formatDistance(fromStart) : '-'} ${Number(point.lat).toFixed(6)}, ${Number(point.lng).toFixed(6)}
`; } function buildStayInfoContent(stay) { const t = translations[langCode] || translations.ko; return `
${t.historyStay} ${formatDuration(Math.round(stay.duration))} ${formatDateTime(stay.startTime)} - ${formatDateTime(stay.endTime)} ${Number(stay.lat).toFixed(6)}, ${Number(stay.lng).toFixed(6)}
`; } 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: '
', 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]; 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; const segments = []; let segment = [points[0]]; for (let i = 1; i < points.length; i++) { const prev = points[i - 1]; const cur = points[i]; const gap = Math.max(0, (toTimestamp(cur.time) - toTimestamp(prev.time)) / 1000); if (gap > TRACK_MAX_GAP_SECONDS) { if (segment.length >= 2) segments.push(segment); segment = [cur]; continue; } segment.push(cur); } if (segment.length >= 2) segments.push(segment); segments.forEach((items, index) => { const line = new naver.maps.Polyline({ map: mapState.map, path: items.map(point => point.latlng), strokeColor: '#007aff', strokeOpacity: 0.86, strokeWeight: 5, strokeLineCap: 'round', strokeLineJoin: 'round', endIcon: index === segments.length - 1 ? naver.maps.PointingIcon?.BLOCK_ARROW : undefined, endIconSize: 16, clickable: false }); mapState.trackLines.push(line); if (!mapState.trackLine) mapState.trackLine = line; }); } 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: '
', 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) { value.textContent = formatTimelineTime(point.time); } if (syncSlider) { const slider = document.getElementById('historyTimelineSlider'); if (slider) slider.value = String(safeIndex); } } function createMainMarker(latlng) { mapState.marker = new naver.maps.Marker({ position: latlng, map: mapState.map, zIndex: 100, icon: { content: `
`, anchor: new naver.maps.Point(30, 30) } }); applyMarkerEffects(mapState.marker); } 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 }); 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', () => { const el = marker.getElement(); if (el) el.style.transform = `scale(1.2)`; }); naver.maps.Event.addListener(marker, 'mouseout', () => { const el = marker.getElement(); if (el) el.style.transform = `scale(1)`; }); naver.maps.Event.addListener(marker, 'click', () => bounceMarker(marker)); breathingMarker(marker); } function breathingMarker(marker) { const el = marker.getElement(); let scale = 1, direction = 1; setInterval(() => { scale += 0.01 * direction; if (scale > 1.1) direction = -1; if (scale < 1.0) direction = 1; if (el) { el.style.transition = 'transform 0.1s'; el.style.transform = `scale(${scale})`; } }, 50); } function getCookie(name) { const m = document.cookie.match(new RegExp('(?:^|; )' + name.replace(/([.$?*|{}()[\]\\/+^])/g, '\\$1') + '=([^;]*)')); return m ? decodeURIComponent(m[1]) : null; } function bounceMarker(marker) { let scale = 1, up = true, count = 0; const interval = setInterval(() => { scale += up ? 0.1 : -0.1; if (scale >= 1.3) up = false; if (scale <= 1) { clearInterval(interval); scale = 1; } marker.setIcon({ url: 'https://chaegeon.com/custom/findmydevice/img/30c0fc9d9.png', size: new naver.maps.Size(60 * scale, 60 * scale), scaledSize: new naver.maps.Size(60 * scale, 60 * scale), anchor: new naver.maps.Point(30 * scale, 30 * scale) }); if (count++ > 10) clearInterval(interval); }, 30); } function resetHistoryLayer() { stopHistoryPlayback(); 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; } if (mapState.historyEndMarker) { mapState.historyEndMarker.setMap(null); mapState.historyEndMarker = null; } if (mapState.openInfoWindow) { mapState.openInfoWindow.close(); mapState.openInfoWindow = null; } if (mapState.playbackMarker) { mapState.playbackMarker.setMap(null); } if (mapState.selectedMarker) { mapState.selectedMarker.setMap(null); } mapState.rawHistoryPoints = []; mapState.historyPoints = []; mapState.historyRawCount = null; mapState.historyRenderCount = null; mapState.historyStats = null; mapState.stayPoints = []; mapState.homeStats = null; renderHistorySummary(); updateTimelinePanel(); mapState.initialInfoOpened = false; mapState.historyBoundsFitted = false; } function updateHistoryMarkers(history) { if (!Array.isArray(history) || history.length === 0) { return; } if (!mapState.map) return; const toTime = (v) => { if (v instanceof Date) return v.getTime(); if (typeof v === 'number' && isFinite(v)) return v < 2e10 ? v * 1000 : v; const t = Date.parse(v); return isNaN(t) ? 0 : t; }; const points = history .filter(pos => hasValidCoords(pos.lat, pos.lng)) .sort((a, b) => toTime(a.time) - toTime(b.time)) .map(pos => ({ ...pos, latlng: new naver.maps.LatLng(pos.lat, pos.lng) })); if (!points.length) return; mapState.rawHistoryPoints = points; renderHistoryPoints(points); } function applyHistoryCounts(data) { const historyLength = Array.isArray(data?.history) ? data.history.length : 0; mapState.historyRawCount = Number.isFinite(Number(data?.historyRawCount)) ? Number(data.historyRawCount) : historyLength; mapState.historyRenderCount = Number.isFinite(Number(data?.historyRenderCount)) ? Number(data.historyRenderCount) : historyLength; } 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.homeStats = buildHomeStats(points); mapState.historyStats = buildHistoryStats(points); renderHistorySummary(); 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); const makeMarker = (pos, type = 'point') => { const isEndpoint = type === 'start' || type === 'end'; const color = type === 'start' ? '#34C759' : (type === 'end' ? '#FF3B30' : '#007AFF'); const label = type === 'start' ? (langCode === 'ko' ? '시작' : 'Start') : (type === 'end' ? (langCode === 'ko' ? '종료' : 'End') : ''); const marker = new naver.maps.Marker({ position: pos.latlng, map: mapState.map, zIndex: isEndpoint ? 90 : 50, icon: { content: `
${label}
`, anchor: new naver.maps.Point(isEndpoint ? 24 : 8, isEndpoint ? 14 : 8) } }); marker.userTime = pos.time; marker.infoWindow = new naver.maps.InfoWindow({ content: buildHistoryInfoContent(pos, type, label) }); naver.maps.Event.addListener(marker, "click", () => { if (mapState.openInfoWindow && mapState.openInfoWindow !== marker.infoWindow) { mapState.openInfoWindow.close(); } if (marker.infoWindow.getMap()) { marker.infoWindow.close(); mapState.openInfoWindow = null; } 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; }; const first = points[0]; const last = points.at(-1); mapState.historyStartMarker = makeMarker(first, 'start'); mapState.historyEndMarker = makeMarker(last, 'end'); 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) }); 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; } if (!mapState.initialInfoOpened && mapState.historyEndMarker?.infoWindow) { mapState.historyEndMarker.infoWindow.open(mapState.map, mapState.historyEndMarker); mapState.openInfoWindow = mapState.historyEndMarker.infoWindow; mapState.initialInfoOpened = true; } if (!mapState.mapClickHandlerAdded) { naver.maps.Event.addListener(mapState.map, "click", () => { if (mapState.openInfoWindow) { mapState.openInfoWindow.close(); mapState.openInfoWindow = null; } }); mapState.mapClickHandlerAdded = true; } } function updateDOMWithData(data) { // ⚑ 히스토리 모드: 값은 "-"로, 지도엔 히스토리만 반영 if (typeof _overrideRange !== 'undefined' && _overrideRange) { resetDisplayValues(); // 히스토리 기준 중심점 선택 const last = data.history && data.history[data.history.length - 1]; const baseLatLng = last ? new naver.maps.LatLng(last.lat, last.lng) : (data.latitude && data.longitude ? new naver.maps.LatLng(data.latitude, data.longitude) : null); // 지도 초기화 (최초 1회) if (!mapState.map && baseLatLng) { initMap(baseLatLng, data.accuracy || 50); } updateConfiguredHome(data) || updateHomeCenter(data.latitude, data.longitude); // 히스토리 궤적/포인트 업데이트 if (data.history?.length > 0) { applyHistoryCounts(data); updateHistoryMarkers(data.history); } // 상대시간 갱신 중단 lastUpdateISOTime = null; // 히스토리 모드에서는 메인 마커/원, auto pan 업데이트 스킵 return; } // ── 라이브 모드 기존 로직 ── const latlng = new naver.maps.LatLng(data.latitude, data.longitude); updateConfiguredHome(data) || updateHomeCenter(data.latitude, data.longitude); // DOM 업데이트 updateDOM(data); updateActivitySection(data); // 지도 초기화 if (!mapState.map) { initMap(latlng, data.accuracy); } // 메인 마커 / 정확도 원 업데이트 mapState.marker.setPosition(latlng); mapState.circle.setCenter(latlng); animateCircleRadius(Math.max(data.accuracy || 50, 10)); updateCurrentRangeCircles(latlng); // autoPosition const isChecked = document.getElementById('autoPositionSwitch').checked; if (isChecked && (lastLat !== data.latitude || lastLng !== data.longitude)) { mapState.map.panTo(latlng); } lastLat = data.latitude; lastLng = data.longitude; lastUpdateISOTime = data.updated; } function animateCircleRadius(targetRadius) { if (!mapState.circle) return; if (window.radiusInterval) clearInterval(window.radiusInterval); let currentRadius = mapState.circle.getRadius(); const step = (targetRadius - currentRadius) / 100; let count = 0; window.radiusInterval = setInterval(() => { currentRadius += step; mapState.circle.setRadius(currentRadius); if (count++ >= 100) { mapState.circle.setRadius(targetRadius); clearInterval(window.radiusInterval); } }, 30); } function syncLocateBtn() { const isChecked = document.getElementById('autoPositionSwitch').checked; const parent = document.getElementById('autoLocateBtn'); const exist = document.getElementById('locateBtn'); if (isChecked && exist) { exist.remove(); } else if (!isChecked && !exist && parent) { const newDiv = document.createElement('div'); newDiv.className = 'settingRow locateRow'; newDiv.id = 'locateBtn'; newDiv.setAttribute('role', 'button'); newDiv.tabIndex = 0; newDiv.innerHTML = `${langCode === 'ko' ? '현재 위치로 이동하기' : 'Move to Current Location'}`; newDiv.addEventListener('click', () => { if (hasValidCoords(lastLat, lastLng)) { mapState.map.panTo(new naver.maps.LatLng(lastLat, lastLng)); } }); parent.parentNode.insertBefore(newDiv, parent.nextSibling); } } function syncUpdateBtn() { const isChecked = document.getElementById('autoUpdateSwitch').checked; const parent = document.getElementById('autoUpdateBtn'); const exist = document.getElementById('updateBtn'); if (isChecked && exist) { exist.remove(); } else if (!isChecked && !exist && parent) { const newDiv = document.createElement('div'); newDiv.className = 'settingRow locateRow'; newDiv.id = 'updateBtn'; newDiv.setAttribute('role', 'button'); newDiv.tabIndex = 0; newDiv.innerHTML = `${langCode === 'ko' ? '업데이트 하기' : 'Force Update'}`; newDiv.addEventListener('click', triggerForceUpdate); parent.parentNode.insertBefore(newDiv, parent.nextSibling); } } function fetchDeviceData(includeLang = true, range = null) { if (!findmydeviceAuthenticated) { return Promise.reject(new Error('not_authenticated')); } const params = new URLSearchParams(includeLang ? { lang: langCode } : { hashonly: 1 }); // 우선순위: 직접 인자(range) > 전역 오버라이드(_overrideRange) const eff = range || _overrideRange || {}; const hasHistoryRange = includeLang && !!(eff.startTime || eff.endTime); const s = eff.startTime; const e = eff.endTime; if (s) params.set('startTime', s); if (e) params.set('endTime', e); if (hasHistoryRange) { params.set('history', '1'); } const url = `https://chaegeon.com/custom/findmydevice/php/api.php?${params.toString()}`; return fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json', ...(isStandalonePwa() ? { 'X-Custom-PWA': 'findmydevice' } : {}) }, credentials: 'same-origin', body: JSON.stringify({}) }).then(res => res.json()); } function isIosSafari() { const ua = window.navigator.userAgent; const isIOS = /iPad|iPhone|iPod/.test(ua); const isSafari = /^((?!chrome|android).)*safari/i.test(ua); return isIOS && isSafari; } function debounce(fn, delay = 400) { let t; return (...args) => { clearTimeout(t); t = setTimeout(() => fn(...args), delay); }; } function resetDisplayValues() { const ids = [ 'currentPosition', 'accuracyValue', 'lastUpdateTime', 'updateTrigger', 'batteryStatus', 'Battery', 'WbatteryStatus', 'WBattery', 'nowActivity', 'nowactivityAccuracy', 'todayDistance', 'todaySteps', 'todayAscended', 'todayDescended', 'connectionType' ]; ids.forEach(id => { const el = document.getElementById(id); if (el) el.textContent = '-'; }); document.querySelectorAll('[data-sensor-row="1"], [data-sensor-title="1"], [id^="sensorSection_"]').forEach(el => el.remove()); } function triggerForceUpdate() { callDeviceWebhook('force_update'); const el = document.getElementById('updateBtn'); if (!el) return; el.style.pointerEvents = 'none'; el.style.color = '#8e8e93'; el.textContent = (langCode === 'ko' ? '갱신중...' : 'Updating...'); setTimeout(() => { el.style.pointerEvents = 'auto'; el.style.color = '#007AFF'; el.textContent = (langCode === 'ko' ? '업데이트 하기' : 'Force Update'); }, 5000); } function hasValidCoords(lat, lng) { return typeof lat === 'number' && isFinite(lat) && typeof lng === 'number' && isFinite(lng); } function callDeviceWebhook(name, keepalive = false) { return fetch('https://chaegeon.com/custom/findmydevice/php/api.php?action=webhook', { method: 'POST', headers: { 'Content-Type': 'application/json', ...(isStandalonePwa() ? { 'X-Custom-PWA': 'findmydevice' } : {}) }, credentials: 'same-origin', keepalive, body: JSON.stringify({ name }) }).catch(() => null); }