commit a2a89998b66e3554df606528feac4acdec04a086 Author: seo Date: Mon Jun 8 02:33:05 2026 +0900 Initial private import diff --git a/css/style.css b/css/style.css new file mode 100644 index 0000000..b5dd668 --- /dev/null +++ b/css/style.css @@ -0,0 +1,237 @@ +html, body { + margin: 0; + padding: 0; + width: 100%; + height: 100%; + background: #f2f2f7; +} +#mySettingContainer { + background: #f2f2f7; + width: 100%; + max-width: 1026px; + margin: 0 auto; + min-height: 100%; + padding: 0; + border-radius: 0; + box-shadow: none; +} +#mySettingContainer #settingsHeader { + display: flex; + align-items: center; + justify-content: center; + position: relative; + padding: 12px; + padding-top: 12px; + font-size: 17px; + color: #000; +} +#mySettingContainer #blogBackLink { + position: absolute; + left: 15px; + color: #007AFF; + text-decoration: none; + font-weight: normal; + cursor: pointer; +} +#mySettingContainer #blogBackLink .backArrow { + font-size: 26px; /* 더 크게 */ + position: relative; + top: 1px; + margin-right: 1px; +} +#mySettingContainer #blogBackLink:active { + opacity: 0.5; +} +#mySettingContainer #langToggle { + position: absolute; + right: 15px; + top: 50%; + transform: translateY(-50%); + width: 24px; + height: 24px; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; +} +#mySettingContainer #langToggle img { + width: 30px; + height: 30px; + opacity: 0.8; + transition: opacity 0.2s ease; +} +#mySettingContainer #langToggle:hover img { + opacity: 1; +} +#mySettingContainer #mapContainer { + height: 500px; + border-radius: 14px; + overflow: hidden; + margin: 0 16px 16px 16px; + box-shadow: 0 2px 5px rgba(0,0,0,0.1); +} +#mySettingContainer .settingSection { + background: #fff; + margin: 0 16px 16px 16px; + border-radius: 14px; + overflow: hidden; + box-shadow: 0 1px 3px rgba(0,0,0,0.1); +} +#mySettingContainer .settingRow { + position: relative; + display: flex; + justify-content: space-between; + align-items: center; + height: 36px; + line-height: 36px; + font-size: 14px; + color: #000; + padding: 0 12px; +} +#mySettingContainer .settingRow::after { + content: ""; + position: absolute; + bottom: 0; + left: 12px; + right: 0; + height: 1px; + background: #e5e5ea; +} +#mySettingContainer .settingRow:last-child::after { + content: none; +} + +#mySettingContainer .settingRow:last-child { + border-bottom: none; +} +#mySettingContainer .settingRow span:last-child { + color: #6e6e73; +} +#mySettingContainer .iosSwitch { + position: relative; + width: 42px; + height: 26px; + line-height: normal; +} +#mySettingContainer .iosSwitch input { + opacity: 0; + width: 0; + height: 0; +} +#mySettingContainer .slider { + position: absolute; + cursor: default; + top: 0; + left: 0; + right: 0; + bottom: 0; + background-color: #ccc; + border-radius: 20px; + transition: .4s; +} +#mySettingContainer .slider:before { + position: absolute; + content: ""; + height: 22px; + width: 22px; + left: 2px; + bottom: 2px; + background-color: white; + border-radius: 50%; + transition: .4s; +} +#mySettingContainer input:checked + .slider { + background-color: #34c759; +} +#mySettingContainer input:checked + .slider:before { + transform: translateX(16px); +} +#mySettingContainer .settingRow.locateRow { + background: #fff; + transition: background 0.3s ease; +} +#mySettingContainer .settingRow.locateRow:active { + background: #d3d3d3; +} +#mySettingContainer .settingRow.locateRow:hover { + background: #d3d3d3; +} +#mySettingContainer .settingTitle { + padding: 4px 12px 4px 28px; + font-size: 11px; + color: #8e8e93; +} +#ios-pwa-tooltip { + opacity: 0; + visibility: hidden; + transition: opacity 0.4s ease, visibility 0.4s ease; + position: fixed; + bottom: 30px; + left: 50%; + transform: translateX(-50%); + background: #ffffff; + color: #000; + border-radius: 12px; + box-shadow: 0 4px 8px rgba(0, 0, 0, 0.15); + padding: 10px 30px; + font-size: 14px; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + z-index: 9999; + box-sizing: border-box; + white-space: nowrap; + text-overflow: ellipsis; + width: fit-content; + max-width: calc(100% - 20px); + text-align: center; +} + +#ios-pwa-tooltip::after { + content: ""; + position: absolute; + bottom: -10px; + left: 50%; + transform: translateX(-50%); + border-left: 8px solid transparent; + border-right: 8px solid transparent; + border-top: 10px solid #ffffff; + filter: drop-shadow(0px 2px 2px rgba(0, 0, 0, 0.2)); +} + +#ios-pwa-tooltip .close-btn { + position: absolute; + top: 2px; + right: 7px; + font-size: 14px; + color: #888; + cursor: pointer; + line-height: 1; +} + +#ios-pwa-tooltip .close-btn:hover { + color: #333; +} + +#ios-pwa-tooltip.visible { + opacity: 1; + visibility: visible; +} + +.dth { + border: none; + border-radius: 8px; + padding: 2px 4px; + background: #f2f2f7; /* 기본 연회색 배경 */ + color: #007AFF; /* 파란 글자 */ + font-size: 14px; + text-align: center; + cursor: pointer; +} + +.dth:focus { + outline: none; + background: #e5e5ea; /* 눌렀을 때 조금 진한 회색 */ +} + +.dth::placeholder { + color: #aaa; +} diff --git a/debug.html b/debug.html new file mode 100644 index 0000000..51124b5 --- /dev/null +++ b/debug.html @@ -0,0 +1,175 @@ + + +
+
+ + +
+ Language Toggle +
+
+ +
+
+
+
+ +
+ +
+
+
+ + +
+
+ +
+ +
+ +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+ + +
+
+
+
+ +
+ + + + + + + + + +
+ + + \ No newline at end of file diff --git a/img/30c0fc9d9.png b/img/30c0fc9d9.png new file mode 100644 index 0000000..0ecb688 Binary files /dev/null and b/img/30c0fc9d9.png differ diff --git a/img/language-empty.svg b/img/language-empty.svg new file mode 100644 index 0000000..2212a7c --- /dev/null +++ b/img/language-empty.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/js/main.js b/js/main.js new file mode 100644 index 0000000..beb0afc --- /dev/null +++ b/js/main.js @@ -0,0 +1,1074 @@ +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: "워치 배터리 잔량", + activity: "활동", + currentActivity: "현재 활동", + activityAccuracy: "활동 정확도", + distance: "도보로 움직인 거리", + steps: "걸음 수", + ascended: "오른 계단", + descended: "내려간 계단", + etc: "기타", + connection: "연결 방법", + 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", + activity: "Activity", + currentActivity: "Current Activity", + activityAccuracy: "Activity Confidence", + distance: "Distance walked", + steps: "Steps", + ascended: "Floors Ascended", + descended: "Floors Descended", + etc: "Others", + connection: "Network Type", + 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 = `${langCode === 'ko' ? '페이지 수정' : 'Edit Page'}`; + editRow.addEventListener('click', () => { + window.location.href = pageModifyLink; + }); + + const settingTitle = [...document.querySelectorAll('.settingTitle')].find(el => el.textContent.trim() === (langCode === 'ko' ? '설정' : 'Settings')); + let settingSection = settingTitle?.nextElementSibling; + + // 삽입 기준: #mapContainer 다음 + const mapContainer = document.getElementById('mapContainer'); + const toggleContent = document.getElementById('toggleContent'); + + if (pageModifyLink) { + // 설정 섹션이 없으면 새로 생성 (mapContainer 아래) + if (!settingSection || !settingSection.classList.contains('settingSection')) { + const newTitle = document.createElement('div'); + newTitle.className = 'settingTitle'; + newTitle.innerHTML = `${langCode === 'ko' ? '설정' : 'Settings'}`; + + const newSection = document.createElement('div'); + newSection.className = 'settingSection'; + newSection.appendChild(editRow); + + if (mapContainer) { + toggleContent.insertBefore(newTitle, mapContainer.nextSibling); + toggleContent.insertBefore(newSection, newTitle.nextSibling); + } + } else { + // 설정 섹션이 이미 존재하는 경우 + const keepRow = settingSection.querySelector('#labelKeepScreenOn')?.closest('.settingRow'); + + if (keepRow) { + if (!isMobile) keepRow.remove(), settingSection.prepend(editRow); + else keepRow.insertAdjacentElement('afterend', editRow); + } else { + settingSection.appendChild(editRow); + } + } + } else { + // pageModifyLink 없음 → 셀렉터 존재 안 함 + if (!isMobile && settingSection && settingTitle) { + settingTitle.remove(); + settingSection.remove(); + } + } + + const tooltip = document.getElementById("ios-pwa-tooltip"); + const closeBtn = document.getElementById("close-tooltip"); + const isStandalone = window.navigator.standalone || window.matchMedia('(display-mode: standalone)').matches; + const tooltipClosed = getCookie('iosPwaTooltipClosed'); // 없으면 쿠키 만료 or 미설정 + + if (tooltip && closeBtn && isIosSafari() && !isStandalone && !tooltipClosed) { + function handleScroll() { + const scrollPercent = ((window.scrollY || document.documentElement.scrollTop) / + (document.documentElement.scrollHeight - document.documentElement.clientHeight)) * 100; + if (scrollPercent > 10) { + tooltip.classList.add("visible"); + } else { + tooltip.classList.remove("visible"); + } + } + + window.addEventListener("scroll", handleScroll, { passive: true }); + + closeBtn.addEventListener("click", function () { + tooltip.classList.remove("visible"); + setCookieHours("iosPwaTooltipClosed", "1", 12); // 12시간 동안 숨김 + window.removeEventListener("scroll", handleScroll); + }); + } + + const btn = document.getElementById('HistoryResetBtn'); + if (btn) { + // 라벨 + const label = document.getElementById('labelHistoryReset'); + (label || btn).textContent = (langCode === 'ko') ? '초기화' : 'Reset'; + + btn.addEventListener('click', () => { + // 1) 히스토리 모드 해제 → 라이브 복귀 + _overrideRange = null; + + // 2) 입력 요소 가져오기 (같은 스코프에서!) + const startEl = document.getElementById('rangeStart'); + const endEl = document.getElementById('rangeEnd'); + if (startEl && endEl) { + const pad = n => String(n).padStart(2, '0'); + const toLocalHour = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`; + + // 기준 시각(현재) + const current = new Date(); + + // 시작: 24시간 전 → 정각 버림 + const start24 = new Date(current.getTime() - 24 * 3600 * 1000); + start24.setMinutes(0, 0, 0); + + // 종료: 현재 시각의 다음 정각 올림 + const endRoundUp = new Date(current); + endRoundUp.setMinutes(0, 0, 0); + if (current.getMinutes() > 0 || current.getSeconds() > 0 || current.getMilliseconds() > 0) { + endRoundUp.setHours(endRoundUp.getHours() + 1); + } + + // 입력값 세팅 + startEl.value = toLocalHour(start24); + endEl.value = toLocalHour(endRoundUp); + } + + // 3) 지도 히스토리 초기화 & 라이브 데이터 재요청 + 현재 위치로 이동 + resetHistoryLayer(); + 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) { + fetch('https://ha.chaegeon.com/api/webhook/5P1HgWiDpaAK8E0B', { method: 'POST' }); + } else { + fetch('https://ha.chaegeon.com/api/webhook/z2nHt75jRxWKo2zF', { method: 'POST' }); + } +}); + +['pagehide', 'visibilitychange', 'blur', 'beforeunload'].forEach(eventType => { + window.addEventListener(eventType, function (e) { + + const switchEl = document.getElementById('autoUpdateSwitch'); + const isAutoUpdateOn = switchEl?.checked; + + if (isAutoUpdateOn) { + navigator.sendBeacon('https://ha.chaegeon.com/api/webhook/z2nHt75jRxWKo2zF'); + + // 스위치 UI 강제 끔 + switchEl.checked = false; + syncUpdateBtn(); + } + }); +}); + +setInterval(() => { + if (document.getElementById('autoUpdateSwitch')?.checked) { + fetch('https://ha.chaegeon.com/api/webhook/870ZNzB20WRkzjnh', { method: 'POST' }); + } +}, 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 = ` ${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]; + } +} + +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 || "-"; + } +} + +function updateActivitySection(data) { + const validActivities = ["Stationary", "Walking", "Running", "In Vehicle", "Cycling", "가만히 있음", "걷는 중", "달리는 중", "차량 이동 중", "자전거 이동 중"]; + const validAccuracies = ["높음", "중간", "낮음", "High", "Middle", "Low"]; + + let existActivityEl = document.getElementById('existnowActivity'); + if (existActivityEl) { + if (validActivities.includes(data.activity || '') && validAccuracies.includes(data.aaccuracy || '')) { + existActivityEl.style.display = 'flex'; + document.getElementById('nowactivityAccuracy').textContent = data.aaccuracy || '-'; + } else { + existActivityEl.remove(); + } + } + + if (!document.getElementById('existnowActivity') && + validActivities.includes(data.activity || '') && + validAccuracies.includes(data.aaccuracy || '')) { + const titles = document.querySelectorAll('.settingTitle'); + let activitySection = null; + + for (let title of titles) { + if (title.textContent.trim() === (langCode === 'ko' ? '활동' : 'Activity')) { + const next = title.nextElementSibling; + if (next && next.classList.contains('settingSection')) { + activitySection = next; + break; + } + } + } + + if (activitySection) { + const rows = activitySection.querySelectorAll('.settingRow'); + let targetRow = null; + + for (let row of rows) { + const label = row.querySelector('span'); + if (label && label.textContent.trim() === (langCode === 'ko' ? '현재 활동' : 'Current Activity')) { + targetRow = row; + break; + } + } + + if (targetRow) { + const newRow = document.createElement('div'); + newRow.className = 'settingRow'; + newRow.id = 'existnowActivity'; + newRow.innerHTML = ` + ${langCode === 'ko' ? '활동 정확도' : 'Activity Accuracy'} + ${data.aaccuracy || '-'} + `; + activitySection.insertBefore(newRow, targetRow.nextSibling); + } + } + } +} + +function initMap(latlng, accuracy = 50) { + mapState.map = new naver.maps.Map('map', { + center: latlng, + zoom: 12 + }); + + createMainMarker(latlng); + createAccuracyCircle(latlng, accuracy); +} + +function createMainMarker(latlng) { + mapState.marker = new naver.maps.Marker({ + position: latlng, + map: mapState.map, + zIndex: 100, + icon: { + content: `
`, + anchor: new naver.maps.Point(30, 30) + } + }); + applyMarkerEffects(mapState.marker); +} + +function createAccuracyCircle(latlng, accuracy = 50) { + mapState.circle = new naver.maps.Circle({ + map: mapState.map, + center: latlng, + radius: Math.max(accuracy, 10), + strokeColor: '#007AFF', + strokeOpacity: 0.8, + strokeWeight: 2, + fillColor: '#007AFF', + fillOpacity: 0.2 + }); +} + +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: ` +
+
+
+
`, + anchor: new naver.maps.Point(18, 18) + } + }); + smallMarker.userTime = pos.time; // ⚑ 표시된 마커의 시간 정보를 유지 + mapState.smallMarkers.push(smallMarker); + + const infoWindow = new naver.maps.InfoWindow({ + content: `
${getRelativeTime(pos.time)}
` + }); + + 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 = `${langCode === 'ko' ? '현재 위치로 이동하기' : 'Move to Current Location'}`; + + 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 = `${langCode === 'ko' ? '업데이트 하기' : 'Force Update'}`; + + 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', + 'Authorization': 'REDACTED_HA_TOKEN' + }, + 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 = '-'; + }); +} + +function triggerForceUpdate() { + fetch('https://ha.chaegeon.com/api/webhook/g1M4dZrsiLTt1woqgDXL6wtBSNCp6HGsRSzRLnNV4Up2Tpy8kre0q33EGvB70H9ftw48oENbUngHhVEBRlBFLNA7x7MbcmwZnVSHU71BG83ROMy4MOqIDWGxpKvI1YPX', { method: 'POST' }); + + 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); + } +} \ No newline at end of file diff --git a/php/api.php b/php/api.php new file mode 100644 index 0000000..a4a422e --- /dev/null +++ b/php/api.php @@ -0,0 +1,306 @@ + 'Access forbidden: Apache environment is not allowed']); + exit; +} + +// POST 전용 검사 +if ($_SERVER['REQUEST_METHOD'] !== 'POST') { + http_response_code(405); // Method Not Allowed + header('Content-Type: application/json'); + echo json_encode(['error' => 'Method Not Allowed']); + exit; +} + +// JSON 요청 검사 +$content_type = $_SERVER['CONTENT_TYPE'] ?? ''; +if (stripos($content_type, 'application/json') === false) { + http_response_code(415); // Unsupported Media Type + header('Content-Type: application/json'); + echo json_encode(['error' => 'Unsupported Content-Type. application/json required']); + exit; +} + +function get_authorization_header() { + if (!empty($_SERVER['HTTP_AUTHORIZATION'])) { + return trim($_SERVER['HTTP_AUTHORIZATION']); + } elseif (function_exists('apache_request_headers')) { + foreach (apache_request_headers() as $key => $value) { + if (strcasecmp($key, 'Authorization') === 0) { + return trim($value); + } + } + } + return null; +} + +$auth_header = get_authorization_header(); +if (!$auth_header || !preg_match('/^Bearer\s+(\S+)$/', $auth_header, $matches)) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Unauthorized: Missing or invalid Authorization header']); + exit; +} + +$allowed_password = 'XBv9z1IGqPUdtVf7DABFA16H0E154LJS'; +$password = $matches[1]; +if ($password !== $allowed_password) { + http_response_code(401); + header('Content-Type: application/json'); + echo json_encode(['error' => 'Unauthorized: Invalid token']); + exit; +} + +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-store, no-cache, must-revalidate'); +header('Expires: 0'); + +$baseUrl = "https://ha.seoul.chaegeon.com/api/states"; +$headers = [ + "Authorization: Bearer REDACTED_HA_TOKEN", + "Content-Type: application/json" +]; + +// 엔드포인트 URL +$deviceUrl = "$baseUrl/device_tracker.seocaegeonyi_z_fold7"; +$geoUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_geocoded_location"; +$batteryStateUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_battery_state"; +$batteryLevelUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_battery_level"; +$watchBatteryStateUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_watch_battery_state"; +$watchBatteryLevelUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_watch_battery_level"; +$activityUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_detected_activity"; +$connectionUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_network_type"; +$fascendedUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_floors"; +$fdescendedUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_floors"; +$stepsUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_steps"; +$distanceUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_daily_distance"; +$triggerUrl = "$baseUrl/sensor.seocaegeonyi_z_fold7_last_update_trigger"; + +// GET 파라미터 읽기 +$startParam = $_GET['startTime'] ?? null; +$endParam = $_GET['endTime'] ?? null; + +// Home Assistant에서 인식 가능한 UTC 포맷 +$haFormat = 'Y-m-d\TH:i:s\Z'; + +if ($startParam && $endParam) { + // 전달받은 값을 DateTime으로 변환 후 UTC 포맷으로 변환 + $startTime = (new DateTime($startParam))->setTimezone(new DateTimeZone('UTC'))->format($haFormat); + $endTime = (new DateTime($endParam))->setTimezone(new DateTimeZone('UTC'))->format($haFormat); +} else { + // 기본값: 최근 24시간 + $startTime = (new DateTime('-24 hours'))->setTimezone(new DateTimeZone('UTC'))->format($haFormat); + $endTime = (new DateTime())->setTimezone(new DateTimeZone('UTC'))->format($haFormat); +} + +// API URL 생성 +$historyUrl = "https://ha.seoul.chaegeon.com/api/history/period/{$startTime}?filter_entity_id=device_tracker.seocaegeonyi_z_fold7&end_time={$endTime}"; + +// 공통 요청 함수 +function getData($url, $headers) { + $ch = curl_init(); + curl_setopt($ch, CURLOPT_URL, $url); + curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); + curl_setopt($ch, CURLOPT_HTTPHEADER, $headers); + $response = curl_exec($ch); + curl_close($ch); + return json_decode($response, true); +} + +// 데이터 불러오기 +$deviceData = getData($deviceUrl, $headers); +$geoData = getData($geoUrl, $headers); +$batteryStateData = getData($batteryStateUrl, $headers); +$batteryLevelData = getData($batteryLevelUrl, $headers); +$watchBatteryStateData = getData($watchBatteryStateUrl, $headers); +$watchBatteryLevelData = getData($watchBatteryLevelUrl, $headers); +$activityData = getData($activityUrl, $headers); +$connectionData = getData($connectionUrl, $headers); +$ascendedData = getData($fascendedUrl, $headers); +$descendedData = getData($fdescendedUrl, $headers); +$stepsData = getData($stepsUrl, $headers); +$distanceData = getData($distanceUrl, $headers); +$historyData = getData($historyUrl, $headers); +$triggerData = getData($triggerUrl, $headers); + +// 경로 기록 +$path = []; +if (isset($historyData[0])) { + foreach ($historyData[0] as $item) { + $lat = $item['attributes']['latitude'] ?? null; + $lng = $item['attributes']['longitude'] ?? null; + $time = $item['last_changed'] ?? null; + if ($lat && $lng) { + $path[] = ['lat' => $lat, 'lng' => $lng, 'time' => $time]; + } + } +} + +$lang = $_GET['lang'] ?? 'en'; + +$activityMap = [ + 'ko' => [ + 'Stationary' => '가만히 있음', + 'Walking' => '걷는 중', + 'Running' => '달리는 중', + 'Automotive' => '차량 이동 중', + 'Cycling' => '자전거 이동 중', + 'Unknown' => '-', + ], + 'en' => [ + 'Stationary' => 'Stationary', + 'Walking' => 'Walking', + 'Running' => 'Running', + 'Automotive' => 'Automotive', + 'Cycling' => 'Cycling', + 'Unknown' => '-', + ] +]; + +$aaccuracyMap = [ + 'ko' => [ + 'Low' => '낮음', + 'Medium' => '중간', + 'High' => '높음', + 'Unknown' => '-', + ], + 'en' => [ + 'Low' => 'Low', + 'Medium' => 'Medium', + 'High' => 'High', + 'Unknown' => '-', + ] +]; + +$connectionMap = [ + 'ko' => [ + 'Wi-Fi' => '와이파이', + 'Cellular' => '셀룰러', + 'none' => '-', + 'unknown' => '-', + ], + 'en' => [ + 'Wi-Fi' => 'Wi-Fi', + 'Cellular' => 'Cellular', + 'none' => '-', + 'unknown' => '-', + ] +]; + +$batteryStateMap = [ + 'ko' => [ + 'Charging' => '충전 중', + 'Not Charging' => '충전중이 아님', + 'Full' => '가득참', + 'Unknown' => '-', + ], + 'en' => [ + 'Charging' => 'Charging', + 'Not Charging' => 'Not Charging', + 'Full' => 'Full', + 'Unknown' => '-', + ] +]; + +$triggerMap = [ + 'ko' => [ + "Launch" => "앱 실행", + "Background Fetch" => "백그라운드 갱신", + "Push Notification" => "푸시", + "Periodic" => "주기적", + "Significant Location Change" => "중요한 위치 변화", + "Manual" => "수동", + "Signaled" => "시그널 됨", + "Siri" => "Siri 실행", + "Watch Context" => "워치 상태 전송", + "Geographic Region Entered" => "지정 지역에 들어옴", + "Geographic Region Exited" => "지정 지역을 벗어남", + ], + 'en' => [ + "Launch" => "Launch", + "Background Fetch" => "Background Fetch", + "Push Notification" => "Push Notification", + "Periodic" => "Periodic", + "Significant Location Change" => "Significant Location Change", + "Manual" => "Manual", + "Signaled" => "Signaled", + "Siri" => "Siri", + "Watch Context" => "Watch Context", + "Geographic Region Entered" => "Geographic Region Entered", + "Geographic Region Exited" => "Geographic Region Exited", + ] +]; + +$activityState = $activityData['state'] ?? null; +$aaccuracyState = $activityData['attributes']['Confidence'] ?? null; +$connectionState = $connectionData['state'] ?? null; +$batteryState = $batteryStateData['state'] ?? null; +$watchBatteryState = $watchBatteryStateData['state'] ?? null; + +$address = null; + +if (($deviceData['state'] ?? null) !== 'not_home') { + $address = $deviceData['state']; +} else { + // Locality 값 확인 + $locality = $geoData['attributes']['Locality'] ?? null; + + // Locality가 없거나 N/A면 Sub Administrative Area로 대체 + if (empty($locality) || $locality === 'N/A') { + $locality = $geoData['attributes']['Sub Administrative Area'] ?? ''; + } + + // Sub Administrative Area 도 없을 경우 Name만 사용 + $address = trim($locality . ' ' . ($geoData['attributes']['Name'] ?? '')); +} + +// 해시 요청만 들어온 경우 +if (isset($_GET['hashonly']) && $_GET['hashonly'] == '1') { + $raw = json_encode([ + 'latitude' => $deviceData['attributes']['latitude'] ?? null, + 'longitude' => $deviceData['attributes']['longitude'] ?? null, + 'accuracy' => $deviceData['attributes']['gps_accuracy'] ?? null, + 'updated' => $deviceData['last_changed'] ?? null, + 'trigger' => $triggerMap[$lang][$triggerData['state']] ?? $triggerData['state'] ?? null, + 'address' => $address, + 'state' => $batteryStateMap[$lang][$batteryState] ?? $batteryState ?? null, + 'level' => $batteryLevelData['state'] ?? null, + 'wstate' => $batteryStateMap[$lang][$watchBatteryState] ?? $watchBatteryState ?? null, + 'wlevel' => $watchBatteryLevelData['state'] ?? null, + 'ascended' => $ascendedData['state'] ?? null, + 'descended' => $descendedData['state'] ?? null, + 'steps' => $stepsData['state'] ?? null, + 'distance' => $distanceData['state'] ?? null, + 'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null, + 'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null, + 'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null, + ]); + + echo json_encode(['hash' => hash('sha256', $raw)]); + exit; +} + +echo json_encode([ + 'latitude' => $deviceData['attributes']['latitude'] ?? null, + 'longitude' => $deviceData['attributes']['longitude'] ?? null, + 'accuracy' => $deviceData['attributes']['gps_accuracy'] ?? null, + 'updated' => $deviceData['last_changed'] ?? null, + 'trigger' => $triggerMap[$lang][$triggerData['state']] ?? $triggerData['state'] ?? null, + 'address' => $address, + 'state' => $batteryStateMap[$lang][$batteryState] ?? $batteryState ?? null, + 'level' => $batteryLevelData['state'] ?? null, + 'wstate' => $batteryStateMap[$lang][$watchBatteryState] ?? $watchBatteryState ?? null, + 'wlevel' => $watchBatteryLevelData['state'] ?? null, + 'ascended' => $ascendedData['state'] ?? null, + 'descended' => $descendedData['state'] ?? null, + 'steps' => $stepsData['state'] ?? null, + 'distance' => $distanceData['state'] ?? null, + 'activity' => $activityMap[$lang][$activityState] ?? $activityState ?? null, + 'aaccuracy' => $aaccuracyMap[$lang][$aaccuracyState] ?? $aaccuracyState ?? null, + 'connection' => $connectionMap[$lang][$connectionState] ?? $connectionState ?? null, + 'history' => $path ?? null, +], JSON_UNESCAPED_UNICODE); +?>