라이브 기록 구간 정시 자동 이동
This commit is contained in:
@@ -56,7 +56,7 @@ PWA standalone으로 진입한 경우에는 설치형 앱 사용성을 우선해
|
|||||||
|
|
||||||
## 히스토리와 지도
|
## 히스토리와 지도
|
||||||
|
|
||||||
실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 자동 위치 이동이 켜져 있어도 새 위치를 받을 때마다 경로선, 종료 지점, 기록 요약을 같은 히스토리 기준으로 다시 그립니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 Home Assistant 히스토리 API를 실행한 뒤 시간과 좌표가 같은 중복 지점만 제거하고, 설정 시간 안의 병합 지점을 전부 응답합니다.
|
실시간 화면은 현재 위치와 상태를 조회하고, 기본 기간 입력값에 해당하는 최근 하루 이동 기록도 지도에 함께 표시합니다. 라이브 모드의 기본 기간은 현재 시각 기준 24시간 전 정각부터 다음 정각까지이며, 정시가 지나면 입력 구간도 한 시간씩 같이 이동해 새 시간대 기록이 경로선에 계속 포함됩니다. 자동 위치 이동이 켜져 있어도 새 위치를 받을 때마다 경로선과 기록 요약을 같은 히스토리 기준으로 다시 그립니다. 기간 입력을 변경하면 해당 범위의 이동 기록을 `history=1` 옵션으로 다시 요청합니다. 서버는 Home Assistant 히스토리 API를 실행한 뒤 시간과 좌표가 같은 중복 지점만 제거하고, 설정 시간 안의 병합 지점을 전부 응답합니다.
|
||||||
|
|
||||||
상태 조회는 `seoul`, `main` Home Assistant의 `/states`를 모두 읽어 entity별로 병합합니다. 한쪽에만 의미 있는 값이 있으면 해당 값을 사용하고, 양쪽 모두 값이 있으면 더 최신 `last_updated` 또는 `last_changed` 값을 사용합니다. 선택된 상태의 attributes에 빈 값이 있으면 다른 쪽 attributes로 채워 빈칸을 줄입니다.
|
상태 조회는 `seoul`, `main` Home Assistant의 `/states`를 모두 읽어 entity별로 병합합니다. 한쪽에만 의미 있는 값이 있으면 해당 값을 사용하고, 양쪽 모두 값이 있으면 더 최신 `last_updated` 또는 `last_changed` 값을 사용합니다. 선택된 상태의 attributes에 빈 값이 있으면 다른 쪽 attributes로 채워 빈칸을 줄입니다.
|
||||||
|
|
||||||
|
|||||||
+76
-56
@@ -461,6 +461,7 @@ let langCode = localStorage.getItem("currentLang") || deviceLang,
|
|||||||
lastAutoSwitchChecked,
|
lastAutoSwitchChecked,
|
||||||
lastUpdateSwitchChecked,
|
lastUpdateSwitchChecked,
|
||||||
lastHash,
|
lastHash,
|
||||||
|
lastLiveHistoryRangeKey,
|
||||||
lastLat,
|
lastLat,
|
||||||
lastLng,
|
lastLng,
|
||||||
lastUpdateISOTime,
|
lastUpdateISOTime,
|
||||||
@@ -475,6 +476,7 @@ let langCode = localStorage.getItem("currentLang") || deviceLang,
|
|||||||
const PLAYBACK_SPEEDS = [2, 5, 10, 20, 50, 100, 500];
|
const PLAYBACK_SPEEDS = [2, 5, 10, 20, 50, 100, 500];
|
||||||
const PLAYBACK_MIN_VISIBLE_STEPS = 80;
|
const PLAYBACK_MIN_VISIBLE_STEPS = 80;
|
||||||
const PLAYBACK_TICK_MS = 160;
|
const PLAYBACK_TICK_MS = 160;
|
||||||
|
const STAY_MIN_SECONDS = 10 * 60;
|
||||||
|
|
||||||
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
|
const FALLBACK_BACK_TARGET = 'https://chaegeon.com/';
|
||||||
const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1';
|
const BACK_HISTORY_KEY = 'chaegeon.backHistory.v1';
|
||||||
@@ -489,6 +491,58 @@ function isStandalonePwa() {
|
|||||||
}
|
}
|
||||||
let findmydeviceAuthenticated = !FINDMYDEVICE_AUTH_REQUIRED || isStandalonePwa();
|
let findmydeviceAuthenticated = !FINDMYDEVICE_AUTH_REQUIRED || isStandalonePwa();
|
||||||
|
|
||||||
|
function pad2(value) {
|
||||||
|
return String(value).padStart(2, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
function toLocalHourValue(date) {
|
||||||
|
return `${date.getFullYear()}-${pad2(date.getMonth() + 1)}-${pad2(date.getDate())}T${pad2(date.getHours())}:00`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toServerTimeValue(value) {
|
||||||
|
return value
|
||||||
|
? (value.includes('T') ? value.replace('T', ' ') : value) + (value.length === 16 ? ':00' : '')
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function liveHistoryRangeValues(now = new Date()) {
|
||||||
|
const start = new Date(now.getTime() - 24 * 3600 * 1000);
|
||||||
|
start.setMinutes(0, 0, 0);
|
||||||
|
|
||||||
|
const end = new Date(now);
|
||||||
|
end.setMinutes(0, 0, 0);
|
||||||
|
if (now.getMinutes() > 0 || now.getSeconds() > 0 || now.getMilliseconds() > 0) {
|
||||||
|
end.setHours(end.getHours() + 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const startValue = toLocalHourValue(start);
|
||||||
|
const endValue = toLocalHourValue(end);
|
||||||
|
return {
|
||||||
|
startValue,
|
||||||
|
endValue,
|
||||||
|
startTime: toServerTimeValue(startValue),
|
||||||
|
endTime: toServerTimeValue(endValue),
|
||||||
|
key: `${startValue}|${endValue}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function syncLiveHistoryRangeInputs(force = false) {
|
||||||
|
if (_overrideRange) return false;
|
||||||
|
const startEl = document.getElementById('rangeStart');
|
||||||
|
const endEl = document.getElementById('rangeEnd');
|
||||||
|
if (!startEl || !endEl) return false;
|
||||||
|
if (!force && (document.activeElement === startEl || document.activeElement === endEl)) return false;
|
||||||
|
|
||||||
|
const range = liveHistoryRangeValues();
|
||||||
|
const changed = lastLiveHistoryRangeKey !== range.key || startEl.value !== range.startValue || endEl.value !== range.endValue;
|
||||||
|
if (force || changed) {
|
||||||
|
startEl.value = range.startValue;
|
||||||
|
endEl.value = range.endValue;
|
||||||
|
lastLiveHistoryRangeKey = range.key;
|
||||||
|
}
|
||||||
|
return changed;
|
||||||
|
}
|
||||||
|
|
||||||
function renderAuthErrorPage() {
|
function renderAuthErrorPage() {
|
||||||
document.body.innerHTML = `
|
document.body.innerHTML = `
|
||||||
<main class="find-auth-page">
|
<main class="find-auth-page">
|
||||||
@@ -882,26 +936,7 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const startEl = document.getElementById('rangeStart');
|
const startEl = document.getElementById('rangeStart');
|
||||||
const endEl = document.getElementById('rangeEnd');
|
const endEl = document.getElementById('rangeEnd');
|
||||||
if (startEl && endEl) {
|
if (startEl && endEl) {
|
||||||
const pad = n => String(n).padStart(2, '0');
|
syncLiveHistoryRangeInputs(true);
|
||||||
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) 지도 히스토리 초기화 & 라이브 데이터 재요청 + 현재 위치로 이동
|
// 3) 지도 히스토리 초기화 & 라이브 데이터 재요청 + 현재 위치로 이동
|
||||||
@@ -920,35 +955,23 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
const endEl = document.getElementById('rangeEnd');
|
const endEl = document.getElementById('rangeEnd');
|
||||||
if (!startEl || !endEl) return;
|
if (!startEl || !endEl) return;
|
||||||
|
|
||||||
const pad = n => String(n).padStart(2, '0');
|
const qs = new URLSearchParams(window.location.search);
|
||||||
const toLocalHour = d => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:00`;
|
const hasQS = qs.has('startTime') || qs.has('endTime');
|
||||||
const toServerTime = v =>
|
if (hasQS) {
|
||||||
v ? (v.includes('T') ? v.replace('T', ' ') : v) + (v.length === 16 ? ':00' : '') : null;
|
startEl.value = qs.get('startTime')
|
||||||
|
? qs.get('startTime').replace(' ', 'T').slice(0, 16)
|
||||||
const now = new Date();
|
: liveHistoryRangeValues().startValue;
|
||||||
|
endEl.value = qs.get('endTime')
|
||||||
// 시작: 현재 시각에서 24시간 전, 정각 버림
|
? qs.get('endTime').replace(' ', 'T').slice(0, 16)
|
||||||
const start24 = new Date(now.getTime() - 24 * 3600 * 1000);
|
: liveHistoryRangeValues().endValue;
|
||||||
start24.setMinutes(0, 0, 0);
|
} else {
|
||||||
|
syncLiveHistoryRangeInputs(true);
|
||||||
// 종료: 현재 시각, 다음 정각 올림
|
|
||||||
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 fire = debounce(async (userTriggered = false) => {
|
||||||
|
if (!userTriggered && !hasQS) {
|
||||||
|
syncLiveHistoryRangeInputs(false);
|
||||||
|
}
|
||||||
const start = startEl.value;
|
const start = startEl.value;
|
||||||
const end = endEl.value;
|
const end = endEl.value;
|
||||||
if (!start || !end) return;
|
if (!start || !end) return;
|
||||||
@@ -956,8 +979,8 @@ document.addEventListener('DOMContentLoaded', () => {
|
|||||||
if (start > end) endEl.value = start;
|
if (start > end) endEl.value = start;
|
||||||
|
|
||||||
const range = {
|
const range = {
|
||||||
startTime: toServerTime(startEl.value),
|
startTime: toServerTimeValue(startEl.value),
|
||||||
endTime: toServerTime(endEl.value)
|
endTime: toServerTimeValue(endEl.value)
|
||||||
};
|
};
|
||||||
|
|
||||||
const shouldUseHistory = userTriggered || hasQS;
|
const shouldUseHistory = userTriggered || hasQS;
|
||||||
@@ -1065,8 +1088,9 @@ setInterval(() => {
|
|||||||
// 히스토리 범위가 활성화된 경우: hash 체크 생략하고 같은 범위로 재요청(낭비 방지로 5초 권장)
|
// 히스토리 범위가 활성화된 경우: hash 체크 생략하고 같은 범위로 재요청(낭비 방지로 5초 권장)
|
||||||
if (_overrideRange) return; // 간단히 폴링 정지 (원하면 5초 주기로 같은 범위 재요청하도록 변경 가능)
|
if (_overrideRange) return; // 간단히 폴링 정지 (원하면 5초 주기로 같은 범위 재요청하도록 변경 가능)
|
||||||
|
|
||||||
|
const rangeChanged = syncLiveHistoryRangeInputs(false);
|
||||||
fetchDeviceData(false).then(({ hash }) => {
|
fetchDeviceData(false).then(({ hash }) => {
|
||||||
if (!hash || hash === lastHash) return;
|
if (!rangeChanged && (!hash || hash === lastHash)) return;
|
||||||
lastHash = hash;
|
lastHash = hash;
|
||||||
fetchDeviceData(true).then(data => updateDOMWithData(data));
|
fetchDeviceData(true).then(data => updateDOMWithData(data));
|
||||||
});
|
});
|
||||||
@@ -1676,7 +1700,6 @@ function buildStayPoints(points) {
|
|||||||
const stays = [];
|
const stays = [];
|
||||||
let start = 0;
|
let start = 0;
|
||||||
const radius = 80;
|
const radius = 80;
|
||||||
const minSeconds = 10 * 60;
|
|
||||||
|
|
||||||
for (let i = 1; i <= points.length; i++) {
|
for (let i = 1; i <= points.length; i++) {
|
||||||
const base = points[start];
|
const base = points[start];
|
||||||
@@ -1686,7 +1709,7 @@ function buildStayPoints(points) {
|
|||||||
|
|
||||||
const end = points[Math.max(start, i - 1)];
|
const end = points[Math.max(start, i - 1)];
|
||||||
const duration = (toTimestamp(end.time) - toTimestamp(base.time)) / 1000;
|
const duration = (toTimestamp(end.time) - toTimestamp(base.time)) / 1000;
|
||||||
if (duration >= minSeconds) {
|
if (duration >= STAY_MIN_SECONDS) {
|
||||||
const slice = points.slice(start, i);
|
const slice = points.slice(start, i);
|
||||||
const lat = slice.reduce((sum, p) => sum + Number(p.lat), 0) / slice.length;
|
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;
|
const lng = slice.reduce((sum, p) => sum + Number(p.lng), 0) / slice.length;
|
||||||
@@ -2517,14 +2540,11 @@ function syncUpdateBtn() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function readHistoryRangeInputs() {
|
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 start = document.getElementById('rangeStart')?.value || '';
|
||||||
const end = document.getElementById('rangeEnd')?.value || '';
|
const end = document.getElementById('rangeEnd')?.value || '';
|
||||||
return {
|
return {
|
||||||
startTime: toServerTime(start),
|
startTime: toServerTimeValue(start),
|
||||||
endTime: toServerTime(end),
|
endTime: toServerTimeValue(end),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user