Improve mobile car monitor layout

This commit is contained in:
seo
2026-06-26 01:39:25 +09:00
parent 2e9c4eaf4a
commit 14f546890a
2 changed files with 158 additions and 12 deletions
+3
View File
@@ -15,6 +15,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
- 허용된 차량 명령 TCP 전송 - 허용된 차량 명령 TCP 전송
- monitor 화면과 AJAX 상태 갱신 - monitor 화면과 AJAX 상태 갱신
- monitor 화면 WakeLock, 언어, 테마 토글 - monitor 화면 WakeLock, 언어, 테마 토글
- 모바일 폭에 맞춘 사용량/로그 가로 스크롤과 시간 문구 축약
- 사용량/요금 표시와 보정 metadata - 사용량/요금 표시와 보정 metadata
- 4.95초 UI timeout과 60초 사용량 정산 분리 - 4.95초 UI timeout과 60초 사용량 정산 분리
- TCP 실패 reason과 마지막 수신 지연 표시 - TCP 실패 reason과 마지막 수신 지연 표시
@@ -37,6 +38,8 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저
상태 기록 섹션의 경과는 각 로그가 기록된 시각부터 현재까지 흐른 시간입니다. 각 행의 `age_seconds`는 서버 현재시각과 로그 timestamp의 차이로 계산해 내려주며, 화면 갱신마다 상단 조회 주기 경과처럼 계속 증가합니다. 상태 기록 섹션의 경과는 각 로그가 기록된 시각부터 현재까지 흐른 시간입니다. 각 행의 `age_seconds`는 서버 현재시각과 로그 timestamp의 차이로 계산해 내려주며, 화면 갱신마다 상단 조회 주기 경과처럼 계속 증가합니다.
모바일처럼 폭이 좁은 화면에서는 조회 경과와 시동 유지 시간이 한 줄로 유지되도록 초, 필요 시 분 단위를 생략해 표시합니다. 사용량 지표와 상태 기록 테이블은 카드 내부에서 좌우 스크롤되도록 분리해 전체 페이지 레이아웃이 밀리지 않게 합니다.
## Monitor 화면 제어 ## Monitor 화면 제어
헤더 우측 컨트롤은 WakeLock, 언어, 테마 순서입니다. WakeLock은 Screen Wake Lock API를 지원하는 브라우저에서 화면 꺼짐을 방지하며, 활성 상태는 초록색 아이콘 버튼으로 표시합니다. 사용자의 선택은 `localStorage`에 저장하고, 새로고침이나 탭 복귀 후에는 브라우저 정책에 맞춰 다시 획득을 시도합니다. 헤더 우측 컨트롤은 WakeLock, 언어, 테마 순서입니다. WakeLock은 Screen Wake Lock API를 지원하는 브라우저에서 화면 꺼짐을 방지하며, 활성 상태는 초록색 아이콘 버튼으로 표시합니다. 사용자의 선택은 `localStorage`에 저장하고, 새로고침이나 탭 복귀 후에는 브라우저 정책에 맞춰 다시 획득을 시도합니다.
+155 -12
View File
@@ -773,6 +773,9 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
transition: all 0.3s ease; transition: all 0.3s ease;
overflow: hidden; /* Fix for header background corner bleeding */ overflow: hidden; /* Fix for header background corner bleeding */
} }
.glass-card > * {
min-width: 0;
}
/* Headers & Typography */ /* Headers & Typography */
.dashboard-header { .dashboard-header {
@@ -866,7 +869,13 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
@keyframes spin-slow { 100% { transform: translate(-50%, -50%) rotate(360deg); } } @keyframes spin-slow { 100% { transform: translate(-50%, -50%) rotate(360deg); } }
/* Log Table Styling */ /* Log Table Styling */
.table-container { width: 100%; border-collapse: separate; border-spacing: 0 5px; } .table-scroll {
max-height: 400px;
overflow: auto;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
}
.table-container { width: 100%; min-width: 720px; border-collapse: separate; border-spacing: 0 5px; }
.table-container th { .table-container th {
font-size: 0.7rem; color: var(--table-head-text); font-weight: 700; font-size: 0.7rem; color: var(--table-head-text); font-weight: 700;
padding: 0 10px 10px 10px; text-align: center; border: none; padding: 0 10px 10px 10px; text-align: center; border: none;
@@ -906,6 +915,13 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
padding: 3px 10px; border-radius: 6px; padding: 3px 10px; border-radius: 6px;
font-size: 0.75rem; font-weight: 600; min-width: 90px; font-size: 0.75rem; font-weight: 600; min-width: 90px;
} }
.duration-line {
display: block;
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail-text { .detail-text {
color: var(--text-secondary); color: var(--text-secondary);
@@ -980,6 +996,10 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
} }
.usage-main { .usage-main {
min-width: 0; min-width: 0;
overflow-x: auto;
overflow-y: hidden;
-webkit-overflow-scrolling: touch;
scrollbar-gutter: stable;
} }
.usage-meter { .usage-meter {
height: 11px; height: 11px;
@@ -1008,6 +1028,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
display: grid; display: grid;
grid-template-columns: repeat(6, minmax(100px, 1fr)); grid-template-columns: repeat(6, minmax(100px, 1fr));
gap: 0.8rem; gap: 0.8rem;
min-width: 660px;
} }
.usage-metric { .usage-metric {
min-width: 0; min-width: 0;
@@ -1039,19 +1060,83 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
.usage-metrics { .usage-metrics {
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 0.55rem; gap: 0.55rem;
min-width: 0;
} }
} }
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
.container {
max-width: 100%;
padding-left: 0.9rem;
padding-right: 0.9rem;
}
.dashboard-header {
gap: 0.8rem;
align-items: flex-start;
}
.dashboard-header > .d-flex:last-child {
gap: 0.45rem !important;
flex: 0 0 auto;
}
.btn-icon-toggle {
width: 36px;
height: 36px;
}
.glass-card.h-100.p-4 {
padding: 1rem !important;
}
.value-text {
font-size: 1.55rem;
}
.icon-box {
width: 42px;
height: 42px;
border-radius: 13px;
margin-bottom: 0.85rem;
}
.usage-card { .usage-card {
padding: 1rem; padding: 1rem;
} }
.usage-metrics { .usage-head {
grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.75rem;
gap: 0.45rem;
} }
.usage-stat .d-flex {
flex-wrap: nowrap !important;
}
.usage-main {
padding-bottom: 0.25rem;
}
.usage-metrics {
grid-template-columns: repeat(6, minmax(92px, 1fr));
gap: 0.45rem;
min-width: 600px;
}
.usage-metric .label-text { font-size: 0.64rem; }
.usage-metric .font-mono { font-size: 0.88rem; }
.usage-detail-line { .usage-detail-line {
white-space: normal; white-space: normal;
} }
.table-scroll {
max-height: 52vh;
}
.table-container {
min-width: 660px;
}
.table-container th {
padding-left: 8px;
padding-right: 8px;
}
.table-container td {
padding: 9px 8px;
font-size: 0.82rem;
}
.cmd-pill {
min-width: 78px;
padding-left: 8px;
padding-right: 8px;
}
.detail-text {
max-width: 140px;
}
} }
</style> </style>
</head> </head>
@@ -1110,7 +1195,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
<div class="d-flex align-items-center gap-2 mb-2"> <div class="d-flex align-items-center gap-2 mb-2">
<span id="val-engine" class="value-text text-secondary">OFF</span> <span id="val-engine" class="value-text text-secondary">OFF</span>
</div> </div>
<div id="val-state-timer" class="font-mono text-secondary mb-2" style="font-size: 0.85rem;">-</div> <div id="val-state-timer" class="font-mono text-secondary mb-2 duration-line" style="font-size: 0.85rem;">-</div>
</div> </div>
</div> </div>
@@ -1133,7 +1218,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
<div class="icon-box c-green"><i class="fas fa-signal"></i></div> <div class="icon-box c-green"><i class="fas fa-signal"></i></div>
<div class="text-end"> <div class="text-end">
<div class="label-text mb-0" style="font-size:0.65rem;" data-i18n="age">AGE</div> <div class="label-text mb-0" style="font-size:0.65rem;" data-i18n="age">AGE</div>
<div id="age-seconds" class="font-mono fw-bold text-muted">-</div> <div id="age-seconds" class="font-mono fw-bold text-muted duration-line">-</div>
</div> </div>
</div> </div>
<div class="label-text mt-1" data-i18n="pollTarget">Poll Target</div> <div class="label-text mt-1" data-i18n="pollTarget">Poll Target</div>
@@ -1334,7 +1419,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
<h6 class="mb-0 fw-bold small text-uppercase text-secondary"><i class="fas fa-list-ul me-2"></i><span data-i18n="statusLog">Status Log</span></h6> <h6 class="mb-0 fw-bold small text-uppercase text-secondary"><i class="fas fa-list-ul me-2"></i><span data-i18n="statusLog">Status Log</span></h6>
</div> </div>
<div class="p-3 flex-grow-1"> <div class="p-3 flex-grow-1">
<div class="table-responsive" style="max-height: 400px; overflow-y: auto;"> <div class="table-responsive table-scroll">
<table class="table-container"> <table class="table-container">
<thead> <thead>
<tr> <tr>
@@ -1514,6 +1599,8 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
let usageInFlight = false; let usageInFlight = false;
let wakeLockSentinel = null; let wakeLockSentinel = null;
let wakeLockWanted = false; let wakeLockWanted = false;
let latestDashboardData = null;
let latestDashboardMeta = null;
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initLanguage(); initLanguage();
@@ -1523,6 +1610,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
fetchData(); fetchData();
refreshTimer = setInterval(fetchData, 1000); refreshTimer = setInterval(fetchData, 1000);
fetchUsage(); fetchUsage();
window.addEventListener('resize', debounce(updateResponsiveDurations, 150));
}); });
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
@@ -1567,6 +1655,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
langBtn.setAttribute('aria-label', t('languageTitle')); langBtn.setAttribute('aria-label', t('languageTitle'));
} }
updateWakeLockButton(); updateWakeLockButton();
updateResponsiveDurations();
} }
function initTheme() { function initTheme() {
@@ -1786,10 +1875,12 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
const age = Number(meta.age_seconds ?? 0); const age = Number(meta.age_seconds ?? 0);
const isStale = Boolean(meta.stale ?? age > 30); const isStale = Boolean(meta.stale ?? age > 30);
const isReceiveBad = age >= 60; const isReceiveBad = age >= 60;
latestDashboardData = data;
latestDashboardMeta = meta;
updateLiveBadge(true, isStale); updateLiveBadge(true, isStale);
document.getElementById('last-updated').textContent = data.ts ? String(data.ts).substring(11) : '--:--:--'; document.getElementById('last-updated').textContent = data.ts ? String(data.ts).substring(11) : '--:--:--';
document.getElementById('age-seconds').textContent = formatAgeUnits(age); document.getElementById('age-seconds').textContent = formatResponsiveDuration(age, 'age');
document.getElementById('val-poll-interval').textContent = meta.poll_interval ?? '-'; document.getElementById('val-poll-interval').textContent = meta.poll_interval ?? '-';
const freshnessEl = document.getElementById('val-freshness'); const freshnessEl = document.getElementById('val-freshness');
freshnessEl.textContent = isReceiveBad ? t('dataDelayed') : t('targetInterval'); freshnessEl.textContent = isReceiveBad ? t('dataDelayed') : t('targetInterval');
@@ -1828,15 +1919,27 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
visualHeadlight.classList.remove('on'); visualHeadlight.classList.remove('on');
} }
const duration = formatDuration(Number(meta.state_duration || 0)); updateEngineDuration(data, meta);
updateRemote(data);
updateDoors(data);
}
function updateResponsiveDurations() {
if (!latestDashboardData || !latestDashboardMeta) return;
document.getElementById('age-seconds').textContent =
formatResponsiveDuration(Number(latestDashboardMeta.age_seconds ?? 0), 'age');
updateEngineDuration(latestDashboardData, latestDashboardMeta);
}
function updateEngineDuration(data, meta) {
const duration = formatResponsiveDuration(Number(meta.state_duration || 0), 'state');
const isEngineOnForLabel = parseInt(data.engine) === 1; const isEngineOnForLabel = parseInt(data.engine) === 1;
const sentenceKey = isEngineOnForLabel ? 'engineStateOnSentence' : 'engineStateOffSentence'; const sentenceKey = isEngineOnForLabel ? 'engineStateOnSentence' : 'engineStateOffSentence';
const sentence = t(sentenceKey).replace('{duration}', `<span class="fw-bold">${duration}</span>`); const sentence = t(sentenceKey).replace('{duration}', `<span class="fw-bold">${duration}</span>`);
document.getElementById('val-state-timer').innerHTML = sentence; document.getElementById('val-state-timer').innerHTML = sentence;
updateRemote(data);
updateDoors(data);
} }
function updateUsage(usage) { function updateUsage(usage) {
@@ -2011,6 +2114,38 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
return '+' + Math.floor(sec / 3600) + t('hourShort'); return '+' + Math.floor(sec / 3600) + t('hourShort');
} }
function compactDurationMode() {
const width = window.innerWidth || document.documentElement.clientWidth || 1024;
if (width <= 380) return 'tight';
if (width <= 575) return 'compact';
return 'full';
}
function formatResponsiveDuration(sec, context = 'state') {
const mode = compactDurationMode();
if (mode === 'full') {
return context === 'age' ? formatAgeUnits(sec) : formatDuration(sec);
}
sec = Math.max(0, Number(sec || 0));
const d = Math.floor(sec / 86400);
const h = Math.floor((sec % 86400) / 3600);
const m = Math.floor((sec % 3600) / 60);
const s = Math.floor(sec % 60);
if (mode === 'tight') {
if (d > 0) return d + t('dayShort');
if (h > 0) return h + t('hourShort');
if (m > 0) return m + t('minShort');
return s + t('secShort');
}
if (d > 0) return d + t('dayShort') + (h > 0 ? ' ' + h + t('hourShort') : '');
if (h > 0) return h + t('hourShort');
if (m > 0) return m + t('minShort');
return s + t('secShort');
}
function formatDuration(sec) { function formatDuration(sec) {
sec = Math.max(0, Number(sec || 0)); sec = Math.max(0, Number(sec || 0));
@@ -2060,6 +2195,14 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
return Math.max(0, Number(sec || 0)) + t('secShort'); return Math.max(0, Number(sec || 0)) + t('secShort');
} }
function debounce(fn, wait) {
let timer = null;
return function (...args) {
clearTimeout(timer);
timer = setTimeout(() => fn.apply(this, args), wait);
};
}
function escapeHtml(value) { function escapeHtml(value) {
return String(value) return String(value)
.replace(/&/g, '&amp;') .replace(/&/g, '&amp;')