(function loadAssetReloadWatcher() {
if (window.__customAssetReloadLoader) return;
window.__customAssetReloadLoader = true;
const script = document.createElement('script');
script.src = '/custom/common/js/asset-reload.js?v=20260710_assetfix1';
script.dataset.service = 'findmydevice';
script.defer = true;
document.head.appendChild(script);
})();
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,
playbackSpeed: 1,
playbackSpeedOptions: [1],
rangeCircles: [],
homeCircle: null,
homeCenter: null,
rawHistoryPoints: [],
historyPoints: [],
historyRawCount: null,
historyRenderCount: null,
historyStats: null,
stayPoints: [],
homeStats: null,
filterOutliers: false,
openInfoWindow: null,
mapClickHandlerAdded: false,
initialInfoOpened: false,
historyBoundsFitted: false,
};
let langCode = localStorage.getItem("currentLang") || deviceLang,
lastAutoSwitchChecked,
lastUpdateSwitchChecked,
lastHash,
lastLat,
lastLng,
lastUpdateISOTime,
highAccuracyModeActive = false,
map,
marker,
circle,
radiusInterval,
_overrideRange,
wakeLock = null;
const PLAYBACK_SPEEDS = [2, 5, 10, 20, 50, 100, 500];
const PLAYBACK_MIN_VISIBLE_STEPS = 80;
const PLAYBACK_TICK_MS = 160;
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1';
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() || '');
}
function backPageKey(url) {
const parsed = sameSiteUrl(url);
if (!parsed) return '';
const pathname = parsed.pathname.replace(/\/+$/, '') || '/';
return `${parsed.hostname.replace(/^www\./, '')}${pathname}`;
}
function rememberBackHistory() {
try {
const current = new URL(location.href);
const list = JSON.parse(sessionStorage.getItem(BACK_HISTORY_KEY) || '[]')
.filter(item => item && item.href && item.key);
const entry = { href: current.href, key: backPageKey(current.href), time: Date.now() };
const last = list[list.length - 1];
const next = last && last.href === entry.href ? list : [...list, entry];
sessionStorage.setItem(BACK_HISTORY_KEY, JSON.stringify(next.slice(-20)));
} catch (e) {}
}
function storedBackTarget(currentKey) {
try {
const list = JSON.parse(sessionStorage.getItem(BACK_HISTORY_KEY) || '[]');
for (let i = list.length - 1; i >= 0; i--) {
const item = list[i];
if (item?.href && item.key && item.key !== currentKey && sameSiteUrl(item.href)) {
return item.href;
}
}
} catch (e) {}
return '';
}
async function resolveBackTarget() {
const ref = sameSiteUrl(document.referrer || '');
const current = new URL(location.href);
const currentKey = backPageKey(current.href);
rememberBackHistory();
const refIsDifferentPage = ref && backPageKey(ref.href) !== currentKey;
const target = refIsDifferentPage ? ref.href : (storedBackTarget(currentKey) || FALLBACK_BACK_TARGET);
const knownTitle = titleFromBackUrl(target);
let title = knownTitle;
const targetUrl = sameSiteUrl(target);
if (!knownTitle && targetUrl && targetUrl.origin === location.origin) {
try {
const res = await fetch(targetUrl.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 = `
`;
}
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 switchEl = document.getElementById('autoUpdateSwitch');
const isChecked = switchEl?.checked;
if (switchEl?.disabled) {
return;
}
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 && !switchEl?.disabled) {
callDeviceWebhook('auto_off', true);
// 스위치 UI 강제 끔
switchEl.checked = false;
syncUpdateBtn();
}
});
});
setInterval(() => {
if (!findmydeviceAuthenticated) return;
if (document.getElementById('autoUpdateSwitch')?.checked || highAccuracyModeActive) {
callDeviceWebhook('auto_tick');
}
}, 5000);
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) {
highAccuracyModeActive = !!data.highAccuracyMode;
syncUpdateBtn();
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;
const isBatterySection = sectionKey === 'battery';
const section = isBatterySection ? document.getElementById('batterySection') : ensureSensorSection(sectionKey);
if (!section) return;
if (!isBatterySection) {
activeSectionIds.add(sectionKey);
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} ${mapState.playbackSpeed}x` : 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();
const rows = [
[t.historyDistance, formatDistance(stats.distance)],
[t.historyDuration, formatDuration(Math.round(stats.duration))],
[t.historyPoints, formatNumber(stats.points)],
];
if (stats.stops > 0) rows.push([t.historyStops, formatNumber(stats.stops)]);
if (home?.status) rows.push([t.currentPlaceStatus, home.status]);
if (home && home.awaySeconds > 0) rows.push([t.awayTime, formatDuration(Math.round(home.awaySeconds))]);
if (sessions.length > 0) rows.push([t.awaySessions, formatNumber(sessions.length)]);
if (home?.lastReturn) rows.push([t.lastReturn, formatDateTime(home.lastReturn)]);
panel.hidden = false;
panel.innerHTML = `
${t.historySummary}
${rows.map(([label, value]) => `${label}${value || '-'}`).join('')}
${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;
}
if (mapState.playbackMarker) {
mapState.playbackMarker.setMap(null);
}
if (!_overrideRange) {
setCurrentLocationVisible(true);
}
updateMapToolLabels();
}
function getPlaybackSpeedOptions(points) {
const count = Array.isArray(points) ? points.length : 0;
const speeds = PLAYBACK_SPEEDS.filter(speed => count / speed >= PLAYBACK_MIN_VISIBLE_STEPS);
return speeds.length ? speeds : [1];
}
function startHistoryPlayback(speed) {
const points = mapState.historyPoints;
if (!mapState.map || !points?.length) return;
stopHistoryPlayback();
setCurrentLocationVisible(false);
if (mapState.openInfoWindow) {
mapState.openInfoWindow.close();
mapState.openInfoWindow = null;
}
mapState.playbackSpeed = speed;
if (mapState.playbackIndex >= points.length - 1) {
mapState.playbackIndex = 0;
}
moveSelectedHistoryPoint(mapState.playbackIndex, false);
if (!mapState.playbackMarker) {
mapState.playbackMarker = new naver.maps.Marker({
position: points[mapState.playbackIndex].latlng,
map: mapState.map,
zIndex: 120,
icon: createFaceMarkerIcon()
});
} else {
mapState.playbackMarker.setMap(mapState.map);
mapState.playbackMarker.setPosition(points[mapState.playbackIndex].latlng);
mapState.playbackMarker.setIcon(createFaceMarkerIcon());
}
let tick = 0;
mapState.playbackTimer = setInterval(() => {
const currentPoints = mapState.historyPoints || [];
if (!currentPoints.length) {
stopHistoryPlayback();
return;
}
const index = Math.min(mapState.playbackIndex || 0, currentPoints.length - 1);
const point = currentPoints[index];
moveSelectedHistoryPoint(index, false);
if (tick % 8 === 0) mapState.map.panTo(point.latlng);
const nextIndex = index + speed;
if (nextIndex >= currentPoints.length) {
moveSelectedHistoryPoint(currentPoints.length - 1, false);
stopHistoryPlayback();
return;
}
mapState.playbackIndex = nextIndex;
tick += 1;
}, PLAYBACK_TICK_MS);
updateMapToolLabels();
}
function toggleHistoryPlayback() {
if (!mapState.map || !mapState.historyPoints?.length) return;
const points = mapState.historyPoints;
const options = getPlaybackSpeedOptions(points);
mapState.playbackSpeedOptions = options;
if (mapState.playbackTimer) {
const currentIndex = options.indexOf(mapState.playbackSpeed);
const nextSpeed = currentIndex >= 0 ? options[currentIndex + 1] : options[0];
if (nextSpeed) {
startHistoryPlayback(nextSpeed);
} else {
stopHistoryPlayback();
}
return;
}
startHistoryPlayback(options[0]);
}
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.setMap(null);
mapState.selectedMarker = null;
}
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 createFaceMarkerIcon(size = 60) {
const half = size / 2;
return {
content: ``,
anchor: new naver.maps.Point(half, half)
};
}
function createMainMarker(latlng) {
mapState.marker = new naver.maps.Marker({
position: latlng,
map: mapState.map,
zIndex: 100,
icon: createFaceMarkerIcon()
});
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
});
}
function clearCurrentRangeCircles() {
if (!mapState.rangeCircles?.length) return;
mapState.rangeCircles.forEach(circle => circle?.setMap?.(null));
mapState.rangeCircles = [];
}
function setCurrentLocationVisible(visible) {
const map = visible ? mapState.map : null;
mapState.marker?.setMap?.(map);
mapState.circle?.setMap?.(map);
}
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 keepPlaybackIndex = !!mapState.playbackTimer;
const previousPlaybackIndex = Number.isFinite(mapState.playbackIndex) ? mapState.playbackIndex : 0;
const points = filterHistoryOutliers(rawPoints);
mapState.historyPoints = points;
mapState.playbackIndex = keepPlaybackIndex
? Math.max(0, Math.min(points.length - 1, previousPlaybackIndex))
: 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);
mapState.historyStartMarker = null;
}
if (mapState.historyEndMarker) {
mapState.historyEndMarker.setMap(null);
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)
});
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.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);
setCurrentLocationVisible(false);
clearCurrentRangeCircles();
// 히스토리 궤적/포인트 업데이트
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);
}
// 라이브 모드에서도 최신 24시간 기록을 계속 반영한다.
// 현재 위치 자동 이동은 지도 중심만 바꾸므로, 경로와 종료 마커는 여기서 별도로 갱신한다.
if (data.history?.length > 0) {
applyHistoryCounts(data);
updateHistoryMarkers(data.history);
}
// 메인 마커 / 정확도 원 업데이트
setCurrentLocationVisible(true);
mapState.marker.setPosition(latlng);
mapState.circle.setCenter(latlng);
animateCircleRadius(Math.max(data.accuracy || 50, 10));
clearCurrentRangeCircles();
// 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 switchEl = document.getElementById('autoUpdateSwitch');
if (!switchEl) return;
if (highAccuracyModeActive) {
switchEl.dataset.highAccuracyLocked = '1';
switchEl.checked = true;
switchEl.disabled = true;
} else {
switchEl.disabled = false;
if (switchEl.dataset.highAccuracyLocked === '1') {
switchEl.checked = false;
delete switchEl.dataset.highAccuracyLocked;
}
}
const isChecked = switchEl.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 readHistoryRangeInputs() {
const toServerTime = value => value
? (value.includes('T') ? value.replace('T', ' ') : value) + (value.length === 16 ? ':00' : '')
: null;
const start = document.getElementById('rangeStart')?.value || '';
const end = document.getElementById('rangeEnd')?.value || '';
return {
startTime: toServerTime(start),
endTime: toServerTime(end),
};
}
function fetchDeviceData(includeLang = true, range = null) {
if (!findmydeviceAuthenticated) {
return Promise.reject(new Error('not_authenticated'));
}
const params = new URLSearchParams(includeLang ? { lang: langCode } : { hashonly: 1 });
const eff = range || _overrideRange || (includeLang ? readHistoryRangeInputs() : {});
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);
}