Files
custom-findmydevice/js/main.js
T

1456 lines
56 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.
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: "건강",
car: "차량",
storageData: "저장공간 및 데이터",
notifications: "알림",
environment: "센서",
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: "종료",
},
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",
car: "Car",
storageData: "Storage & Data",
notifications: "Notifications",
environment: "Sensors",
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",
},
},
mapState = {
map: null,
marker: null,
circle: null,
trackLine: null,
smallMarkers: [],
openInfoWindow: null,
mapClickHandlerAdded: false,
initialInfoOpened: false,
};
let langCode = localStorage.getItem("currentLang") || deviceLang,
lastAutoSwitchChecked,
lastUpdateSwitchChecked,
lastHash,
lastLat,
lastLng,
lastUpdateISOTime,
map,
marker,
circle,
radiusInterval,
_overrideRange,
wakeLock = null;
applyLanguage(langCode);
document.querySelectorAll('.locateRow').forEach(row => {
row.addEventListener('pointerdown', () => {
row.style.background = '#d3d3d3';
});
row.addEventListener('pointerup', () => {
row.style.background = '#fff';
});
row.addEventListener('pointerleave', () => {
row.style.background = '#fff';
});
});
document.getElementById('langToggle').addEventListener('click', () => {
langCode = langCode === 'ko' ? 'en' : 'ko';
localStorage.setItem('currentLang', langCode);
applyLanguage(langCode);
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());
document.getElementById('blogBackLink').addEventListener('click', (e) => {
e.preventDefault();
location.href = '/blog';
});
// PWA 상태인지 확인 (iOS 및 기타 브라우저 지원)
function isInStandaloneMode() {
return (window.matchMedia('(display-mode: standalone)').matches) || (window.navigator.standalone === true);
}
// 조건: /findmydevice로 시작하고 access 쿠키가 없으며, PWA가 아닌 경우에만 리디렉션
if (location.pathname.startsWith('/findmydevice') && getCookie('access') !== '9KxH6236' && !isInStandaloneMode()) {
location.replace('/blog');
return; // 이하 코드 실행 방지
}
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();
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)
};
// ✅ 사용자가 변경했거나 URL에 기간이 있으면 히스토리 모드
if (userTriggered || hasQS) {
_overrideRange = range;
resetHistoryLayer();
} else {
_overrideRange = null; // 라이브 모드 유지
}
try {
const data = await fetchDeviceData(true, range);
updateDOMWithData(data);
const last = data.history && data.history[data.history.length - 1];
if (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));
});
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 (document.getElementById('autoUpdateSwitch')?.checked) {
callDeviceWebhook('auto_tick');
}
}, 10000);
setInterval(() => {
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,
};
document.getElementById('blogBackLink').innerHTML = `<span class="backArrow"></span> ${t.blog}`;
document.querySelector('.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];
}
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;
});
}
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 || {});
}
function updateSensorSections(sections) {
const t = translations[langCode] || translations.ko;
const activeSectionIds = new Set();
const activeRowIds = new Set();
Object.entries(sections || {}).forEach(([sectionKey, rows]) => {
const entries = Object.entries(rows || {}).filter(([, value]) => normalizeDisplayValue(value));
if (!entries.length) return;
activeSectionIds.add(sectionKey);
const section = ensureSensorSection(sectionKey);
if (!section) return;
const title = document.getElementById(`sensorTitleText_${sectionKey}`);
if (title) title.textContent = t[sectionKey] || sectionKey;
entries.forEach(([key, value]) => {
activeRowIds.add(`sensor_${sectionKey}_${key}`);
upsertOptionalRow(section, `sensor_${sectionKey}_${key}`, t[key] || key, value);
});
});
document.querySelectorAll('[data-sensor-row="1"]').forEach(row => {
if (!activeRowIds.has(row.id)) row.remove();
});
document.querySelectorAll('[data-sensor-title="1"]').forEach(title => {
const sectionKey = title.dataset.sensorSection;
const section = document.getElementById(`sensorSection_${sectionKey}`);
if (!activeSectionIds.has(sectionKey) || !section?.querySelector('[data-sensor-row="1"]')) {
section?.remove();
title.remove();
}
});
}
function ensureSensorSection(sectionKey) {
let section = document.getElementById(`sensorSection_${sectionKey}`);
if (section) return section;
const t = translations[langCode] || translations.ko;
const toggleContent = document.getElementById('toggleContent') || document.body;
const title = document.createElement('div');
title.className = 'settingTitle';
title.id = `sensorTitle_${sectionKey}`;
title.dataset.sensorTitle = '1';
title.dataset.sensorSection = sectionKey;
title.innerHTML = `<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
});
createMainMarker(latlng);
createAccuracyCircle(latlng, accuracy);
}
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
});
}
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() {
if (mapState.trackLine) {
mapState.trackLine.setMap(null);
mapState.trackLine = null;
}
if (mapState.smallMarkers?.length) {
mapState.smallMarkers.forEach(m => m.setMap?.(null));
mapState.smallMarkers = [];
}
if (mapState.openInfoWindow) {
mapState.openInfoWindow.close();
mapState.openInfoWindow = null;
}
}
function updateHistoryMarkers(history) {
// 안전가드
if (!Array.isArray(history) || history.length === 0) {
// 표시된 마커를 기준으로 라인만 갱신(필요 시)
renderTrackFromDisplayedMarkers();
return;
}
// time 파싱 헬퍼
const toTime = (v) => {
if (v instanceof Date) return v.getTime();
if (typeof v === 'number' && isFinite(v)) {
// 초 단위처럼 보이면 ms로 변환
return v < 2e10 ? v * 1000 : v;
}
const t = Date.parse(v);
return isNaN(t) ? 0 : t;
};
// ★ history는 스몰마커 생성만에 사용(정렬은 생성 안정성용)
history.sort((a, b) => toTime(a.time) - toTime(b.time));
// 1) 스몰마커 생성/중복 방지
history.forEach(pos => {
const pointLatLng = new naver.maps.LatLng(pos.lat, pos.lng);
const exist = mapState.smallMarkers.some(marker =>
marker.getPosition().equals(pointLatLng) && marker.userTime === pos.time
);
if (!exist) {
const smallMarker = new naver.maps.Marker({
position: pointLatLng,
map: mapState.map,
icon: {
content: `
<div style="position: relative; width:36px; height:36px;">
<div style="position:absolute; top:0; left:0;
width:36px; height:36px;
border-radius:50%;
background:transparent;
cursor:pointer;"></div>
<div style="position:absolute; top:12px; left:12px;
width:12px; height:12px;
background:#0051A8;
border:2px solid white;
border-radius:50%;
box-shadow:0 0 2px rgba(0,0,0,0.5);"></div>
</div>`,
anchor: new naver.maps.Point(18, 18)
}
});
smallMarker.userTime = pos.time; // ⚑ 표시된 마커의 시간 정보를 유지
mapState.smallMarkers.push(smallMarker);
const infoWindow = new naver.maps.InfoWindow({
content: `<div style="font-size:12px;padding:4px;">${getRelativeTime(pos.time)}</div>`
});
smallMarker.infoWindow = infoWindow;
naver.maps.Event.addListener(smallMarker, "click", () => {
if (mapState.openInfoWindow && mapState.openInfoWindow !== infoWindow) {
mapState.openInfoWindow.close();
}
if (infoWindow.getMap()) {
infoWindow.close();
mapState.openInfoWindow = null;
} else {
infoWindow.open(mapState.map, smallMarker);
mapState.openInfoWindow = infoWindow;
}
});
}
});
// 2) ★★ 지금 "화면에 표시된 스몰마커들"만으로 폴리라인 갱신 ★★
renderTrackFromDisplayedMarkers();
// ★ 최초 로드시 마지막(가장 최근) 스몰마커의 InfoWindow 자동 오픈
if (!mapState.initialInfoOpened && mapState.smallMarkers.length) {
// 시간순 정렬 후 마지막 선택
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 lastMarker = mapState.smallMarkers
.slice()
.sort((a, b) => toTime(a.userTime) - toTime(b.userTime))
.at(-1);
if (lastMarker && lastMarker.infoWindow) {
// 이전 열린 창 닫기
if (mapState.openInfoWindow && mapState.openInfoWindow !== lastMarker.infoWindow) {
mapState.openInfoWindow.close();
}
lastMarker.infoWindow.open(mapState.map, lastMarker);
mapState.openInfoWindow = lastMarker.infoWindow;
// 보기 편하게 살짝 이동
mapState.map.panTo(lastMarker.getPosition());
}
mapState.initialInfoOpened = true;
}
// map 클릭시 열린 InfoWindow 닫기 (1회만 등록)
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);
}
// 히스토리 궤적/포인트 업데이트
if (data.history?.length > 0) {
updateHistoryMarkers(data.history);
}
// 상대시간 갱신 중단
lastUpdateISOTime = null;
// 히스토리 모드에서는 메인 마커/원, auto pan 업데이트 스킵
return;
}
// ── 라이브 모드 기존 로직 ──
const latlng = new naver.maps.LatLng(data.latitude, data.longitude);
// DOM 업데이트
updateDOM(data);
updateActivitySection(data);
// 지도 초기화
if (!mapState.map) {
initMap(latlng, data.accuracy);
if (data.history?.length > 0) updateHistoryMarkers(data.history);
}
// 메인 마커 / 정확도 원 업데이트
mapState.marker.setPosition(latlng);
mapState.circle.setCenter(latlng);
animateCircleRadius(Math.max(data.accuracy || 50, 10));
// history (있으면) 업데이트
if (data.history?.length > 0) updateHistoryMarkers(data.history);
// 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.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));
}
});
newDiv.addEventListener('pointerdown', () => newDiv.style.background = '#d3d3d3');
newDiv.addEventListener('pointerup', () => newDiv.style.background = '#fff');
newDiv.addEventListener('pointerleave', () => newDiv.style.background = '#fff');
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.innerHTML = `<span style="color:#007AFF; cursor:default;" id="labelForceUpdate">${langCode === 'ko' ? '업데이트 하기' : 'Force Update'}</span>`;
newDiv.addEventListener('pointerdown', () => newDiv.style.background = '#d3d3d3');
newDiv.addEventListener('pointerup', () => newDiv.style.background = '#fff');
newDiv.addEventListener('pointerleave', () => newDiv.style.background = '#fff');
newDiv.addEventListener('click', triggerForceUpdate);
parent.parentNode.insertBefore(newDiv, parent.nextSibling);
}
}
function fetchDeviceData(includeLang = true, range = null) {
const params = new URLSearchParams(includeLang ? { lang: langCode } : { hashonly: 1 });
// 우선순위: 직접 인자(range) > 전역 오버라이드(_overrideRange) > URL 쿼리
const eff = range || _overrideRange || {};
const qs = new URLSearchParams(window.location.search);
const s = eff.startTime || qs.get('startTime');
const e = eff.endTime || qs.get('endTime');
if (s) params.set('startTime', s);
if (e) params.set('endTime', e);
const url = `https://chaegeon.com/custom/findmydevice/php/api.php?${params.toString()}`;
return fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
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 renderTrackFromDisplayedMarkers() {
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; // 초 → ms 보정
}
const t = Date.parse(v);
return isNaN(t) ? 0 : t;
};
// 현재 표시된 스몰마커만 정렬 → 경로 생성
const points = mapState.smallMarkers
.slice()
.sort((a, b) => toTime(a.userTime) - toTime(b.userTime))
.map(m => m.getPosition());
// 라인 갱신
if (!mapState.trackLine) {
mapState.trackLine = new naver.maps.Polyline({
map: mapState.map,
path: points,
strokeColor: '#007AFF',
strokeOpacity: 0.8,
strokeWeight: 3
});
} else {
mapState.trackLine.setPath(points);
}
}
function callDeviceWebhook(name, keepalive = false) {
return fetch('https://chaegeon.com/custom/findmydevice/php/api.php?action=webhook', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
keepalive,
body: JSON.stringify({ name })
}).catch(() => null);
}