From 14f546890ab893c362b06f589a4703f19b4502ca Mon Sep 17 00:00:00 2001 From: seo Date: Fri, 26 Jun 2026 01:39:25 +0900 Subject: [PATCH] Improve mobile car monitor layout --- README.md | 3 + monitor.php | 167 ++++++++++++++++++++++++++++++++++++++++++++++++---- 2 files changed, 158 insertions(+), 12 deletions(-) diff --git a/README.md b/README.md index 88d3703..20a0204 100644 --- a/README.md +++ b/README.md @@ -15,6 +15,7 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저 - 허용된 차량 명령 TCP 전송 - monitor 화면과 AJAX 상태 갱신 - monitor 화면 WakeLock, 언어, 테마 토글 +- 모바일 폭에 맞춘 사용량/로그 가로 스크롤과 시간 문구 축약 - 사용량/요금 표시와 보정 metadata - 4.95초 UI timeout과 60초 사용량 정산 분리 - TCP 실패 reason과 마지막 수신 지연 표시 @@ -37,6 +38,8 @@ Car는 차량 모뎀/게이트웨이에서 받은 상태를 수집해 DB에 저 상태 기록 섹션의 경과는 각 로그가 기록된 시각부터 현재까지 흐른 시간입니다. 각 행의 `age_seconds`는 서버 현재시각과 로그 timestamp의 차이로 계산해 내려주며, 화면 갱신마다 상단 조회 주기 경과처럼 계속 증가합니다. +모바일처럼 폭이 좁은 화면에서는 조회 경과와 시동 유지 시간이 한 줄로 유지되도록 초, 필요 시 분 단위를 생략해 표시합니다. 사용량 지표와 상태 기록 테이블은 카드 내부에서 좌우 스크롤되도록 분리해 전체 페이지 레이아웃이 밀리지 않게 합니다. + ## Monitor 화면 제어 헤더 우측 컨트롤은 WakeLock, 언어, 테마 순서입니다. WakeLock은 Screen Wake Lock API를 지원하는 브라우저에서 화면 꺼짐을 방지하며, 활성 상태는 초록색 아이콘 버튼으로 표시합니다. 사용자의 선택은 `localStorage`에 저장하고, 새로고침이나 탭 복귀 후에는 브라우저 정책에 맞춰 다시 획득을 시도합니다. diff --git a/monitor.php b/monitor.php index 1ac1ed3..a771d90 100644 --- a/monitor.php +++ b/monitor.php @@ -773,6 +773,9 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { transition: all 0.3s ease; overflow: hidden; /* Fix for header background corner bleeding */ } + .glass-card > * { + min-width: 0; + } /* Headers & Typography */ .dashboard-header { @@ -866,7 +869,13 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { @keyframes spin-slow { 100% { transform: translate(-50%, -50%) rotate(360deg); } } /* 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 { font-size: 0.7rem; color: var(--table-head-text); font-weight: 700; 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; 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 { color: var(--text-secondary); @@ -980,6 +996,10 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { } .usage-main { min-width: 0; + overflow-x: auto; + overflow-y: hidden; + -webkit-overflow-scrolling: touch; + scrollbar-gutter: stable; } .usage-meter { height: 11px; @@ -1008,6 +1028,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { display: grid; grid-template-columns: repeat(6, minmax(100px, 1fr)); gap: 0.8rem; + min-width: 660px; } .usage-metric { min-width: 0; @@ -1039,19 +1060,83 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { .usage-metrics { grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 0.55rem; + min-width: 0; } } @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 { padding: 1rem; } - .usage-metrics { - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 0.45rem; + .usage-head { + gap: 0.75rem; } + .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 { 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; + } } @@ -1110,7 +1195,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
OFF
-
-
+
-
@@ -1133,7 +1218,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
AGE
-
-
+
-
Poll Target
@@ -1334,7 +1419,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') {
Status Log
-
+
@@ -1514,6 +1599,8 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { let usageInFlight = false; let wakeLockSentinel = null; let wakeLockWanted = false; + let latestDashboardData = null; + let latestDashboardMeta = null; document.addEventListener('DOMContentLoaded', () => { initLanguage(); @@ -1523,6 +1610,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { fetchData(); refreshTimer = setInterval(fetchData, 1000); fetchUsage(); + window.addEventListener('resize', debounce(updateResponsiveDurations, 150)); }); document.addEventListener('visibilitychange', () => { @@ -1567,6 +1655,7 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { langBtn.setAttribute('aria-label', t('languageTitle')); } updateWakeLockButton(); + updateResponsiveDurations(); } function initTheme() { @@ -1786,10 +1875,12 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { const age = Number(meta.age_seconds ?? 0); const isStale = Boolean(meta.stale ?? age > 30); const isReceiveBad = age >= 60; + latestDashboardData = data; + latestDashboardMeta = meta; updateLiveBadge(true, isStale); 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 ?? '-'; const freshnessEl = document.getElementById('val-freshness'); freshnessEl.textContent = isReceiveBad ? t('dataDelayed') : t('targetInterval'); @@ -1828,15 +1919,27 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { 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 sentenceKey = isEngineOnForLabel ? 'engineStateOnSentence' : 'engineStateOffSentence'; const sentence = t(sentenceKey).replace('{duration}', `${duration}`); document.getElementById('val-state-timer').innerHTML = sentence; - - updateRemote(data); - updateDoors(data); } function updateUsage(usage) { @@ -2011,6 +2114,38 @@ if (isset($_GET['mode']) && $_GET['mode'] === 'usage') { 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) { 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'); } + function debounce(fn, wait) { + let timer = null; + return function (...args) { + clearTimeout(timer); + timer = setTimeout(() => fn.apply(this, args), wait); + }; + } + function escapeHtml(value) { return String(value) .replace(/&/g, '&')