Files
custom-findmydevice/js/main.js
T

2556 lines
96 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
(function loadAssetReloadWatcher() {
if (window.__customAssetReloadLoader) return;
window.__customAssetReloadLoader = true;
const script = document.createElement('script');
script.src = '/custom/common/js/asset-reload.js';
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,
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,
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 = `
<main class="find-auth-page">
<div id="findMyShortcut"></div>
</main>
`;
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 = `<span class="backArrow"></span> ${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 = `
<div id="mySettingContainer" style="user-select: none;">
<div id="settingsHeader">
<a id="blogBackLink"></a>
<div class="headerTitle"></div>
<div id="langToggle"><img alt="Language Toggle" src="/custom/findmydevice/img/language-empty.svg" /></div>
</div>
<div id="toggleContent" style="padding-bottom:80px;">
<div id="mapContainer"><div id="map" style="width:100%; height:100%;">&nbsp;</div></div>
<div class="settingTitle"><span id="titleSettings"></span></div>
<div class="settingSection">
<div class="settingRow" id="wakeLockRow"><span id="labelKeepScreenOn"></span><label class="iosSwitch"><input id="wakeLockSwitch" type="checkbox" /><span class="slider"></span></label></div>
</div>
<div class="settingTitle"><span id="titleHistory"></span></div>
<div class="settingSection">
<div class="settingRow"><span id="labelStartTime"></span><input class="dth" id="rangeStart" type="datetime-local" /></div>
<div class="settingRow"><span id="labelEndTime"></span><input class="dth" id="rangeEnd" type="datetime-local" /></div>
<div class="settingRow locateRow" id="HistoryResetBtn"><span id="labelHistoryReset"></span></div>
</div>
<div class="settingTitle"><span id="titleLocation"></span></div>
<div class="settingSection">
<div class="settingRow"><span id="labelCurrentPosition"></span><span id="currentPosition"></span></div>
<div class="settingRow"><span id="labelGpsAccuracy"></span><span id="accuracyValue"></span></div>
<div class="settingRow" id="autoLocateBtn"><span id="labelCurrentAutoPosition"></span><label class="iosSwitch"><input id="autoPositionSwitch" type="checkbox" /><span class="slider"></span></label></div>
<div class="settingRow locateRow" id="locateBtn"><span id="labelMoveToCurrent"></span></div>
</div>
<div class="settingTitle"><span id="titleUpdate"></span></div>
<div class="settingSection">
<div class="settingRow"><span id="labelLastUpdate"></span><span id="lastUpdateTime"></span></div>
<div class="settingRow"><span id="labelUpdateTrigger"></span><span id="updateTrigger"></span></div>
<div class="settingRow" id="autoUpdateBtn"><span id="labelAutoUpdate"></span><label class="iosSwitch"><input id="autoUpdateSwitch" type="checkbox" /><span class="slider"></span></label></div>
<div class="settingRow locateRow" id="updateBtn"><span id="labelForceUpdate"></span></div>
</div>
<div class="settingTitle"><span id="titleBattery"></span></div>
<div class="settingSection" id="batterySection">
<div class="settingRow"><span id="labelBatteryState"></span><span id="batteryStatus"></span></div>
<div class="settingRow"><span id="labelBatteryLevel"></span><span id="Battery"></span></div>
<div class="settingRow"><span id="labelWatchBatteryState"></span><span id="WbatteryStatus"></span></div>
<div class="settingRow"><span id="labelWatchBatteryLevel"></span><span id="WBattery"></span></div>
</div>
<div class="settingTitle"><span id="titleActivity"></span></div>
<div class="settingSection">
<div class="settingRow"><span id="labelCurrentActivity"></span><span id="nowActivity"></span></div>
<div class="settingRow"><span id="labelDistance"></span><span id="todayDistance"></span></div>
<div class="settingRow"><span id="labelSteps"></span><span id="todaySteps"></span></div>
<div class="settingRow"><span id="labelAscended"></span><span id="todayAscended"></span></div>
<div class="settingRow"><span id="labelDescended"></span><span id="todayDescended"></span></div>
</div>
</div>
</div>
<div id="ios-pwa-tooltip">
<span class="close-btn" id="close-tooltip">✕</span>
<span style="width:18px;height:18px;display:inline-block;vertical-align:middle;margin-right:5px;transform:translateZ(0);will-change:transform;">
<svg height="18" viewBox="0 0 50 50" width="18" xmlns="http://www.w3.org/2000/svg"><path d="M30.3 13.7L25 8.4l-5.3 5.3-1.4-1.4L25 5.6l6.7 6.7z"></path><path d="M24 7h2v21h-2z"></path><path d="M35 40H15c-1.7 0-3-1.3-3-3V19c0-1.7 1.3-3 3-3h7v2h-7c-.6 0-1 .4-1 1v18c0 .6.4 1 1 1h20c.6 0 1-.4 1-1V19c0-.6-.4-1-1-1h-7v-2h7c1.7 0 3 1.3 3 3v18c0 1.7-1.3 3-3 3z"></path></svg>
</span>
<span id="labelPwa"></span>
</div>
`;
}
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 = `<span id="labelEditPage" style="color:#007AFF">${langCode === 'ko' ? '페이지 수정' : 'Edit Page'}</span>`;
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 = `<span id="titleSettings">${langCode === 'ko' ? '설정' : 'Settings'}</span>`;
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;
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 = `<span id="sensorTitleText_${sectionKey}">${t[sectionKey] || sectionKey}</span>`;
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 = `<span></span><span></span>`;
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 = `
<span>${langCode === 'ko' ? '활동 정확도' : 'Activity Accuracy'}</span>
<span id="nowactivityAccuracy">${data.aaccuracy || '-'}</span>
`;
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 = `
<button type="button" class="map-tool" data-map-action="fit"></button>
<button type="button" class="map-tool" data-map-action="play"></button>
<button type="button" class="map-tool active" data-map-action="filter"></button>
<button type="button" class="map-tool" data-map-action="naver"></button>
`;
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 = `
<div class="history-timeline-head">
<strong id="historyTimelineTitle"></strong>
<span id="historyTimelineValue">-</span>
</div>
<input id="historyTimelineSlider" type="range" min="0" max="0" value="0" step="1">
`;
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();
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 = `
<div class="history-summary-title">${t.historySummary}</div>
<div class="history-summary-grid">
${rows.map(([label, value]) => `<span>${label}</span><strong>${value || '-'}</strong>`).join('')}
</div>
${recentSessions.length ? `
<div class="away-session-list">
<strong>${t.awaySessionTitle}</strong>
${recentSessions.map(session => `
<span>${t.departure} ${formatDateTime(session.departureTime)} · ${session.returnTime ? `${t.returnHome} ${formatDateTime(session.returnTime)}` : t.awayNow} · ${formatDuration(Math.round(session.duration))}</span>
`).join('')}
</div>
` : ''}
`;
}
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 `
<div class="history-info-window history-info-${typeClass}">
<strong>${title}</strong>
<span>${formatDateTime(point.time)} · ${getRelativeTime(point.time)}</span>
<span>${t.historyDistance}: ${index > 0 ? formatDistance(fromStart) : '-'}</span>
<span>${Number(point.lat).toFixed(6)}, ${Number(point.lng).toFixed(6)}</span>
</div>
`;
}
function buildStayInfoContent(stay) {
const t = translations[langCode] || translations.ko;
return `
<div class="history-info-window history-info-stay">
<strong>${t.historyStay}</strong>
<span>${formatDuration(Math.round(stay.duration))}</span>
<span>${formatDateTime(stay.startTime)} - ${formatDateTime(stay.endTime)}</span>
<span>${Number(stay.lat).toFixed(6)}, ${Number(stay.lng).toFixed(6)}</span>
</div>
`;
}
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: '<div class="history-playback-marker"></div>',
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: '<div class="history-selected-marker"></div>',
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: `<div style="width:60px; height:60px; background:url('https://chaegeon.com/custom/findmydevice/img/30c0fc9d9.png') no-repeat center/contain;"></div>`,
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: `<div class="history-marker history-marker-${type}" style="--marker-color:${color}">${label}</div>`,
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: `<div class="history-marker history-marker-stay">${formatDuration(Math.round(stay.duration))}</div>`,
anchor: new naver.maps.Point(26, 14)
}
});
marker.infoWindow = new naver.maps.InfoWindow({
content: buildStayInfoContent(stay)
});
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);
}
// 라이브 모드에서도 최신 24시간 기록을 계속 반영한다.
// 현재 위치 자동 이동은 지도 중심만 바꾸므로, 경로와 종료 마커는 여기서 별도로 갱신한다.
if (data.history?.length > 0) {
applyHistoryCounts(data);
updateHistoryMarkers(data.history);
}
// 메인 마커 / 정확도 원 업데이트
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 = `<span style="color:#007AFF; cursor:default;" id="labelMoveToCurrent">${langCode === 'ko' ? '현재 위치로 이동하기' : 'Move to Current Location'}</span>`;
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 = `<span style="color:#007AFF; cursor:default;" id="labelForceUpdate">${langCode === 'ko' ? '업데이트 하기' : 'Force Update'}</span>`;
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);
}