diff --git a/php/api/status_summary.php b/php/api/status_summary.php new file mode 100644 index 0000000..cac001d --- /dev/null +++ b/php/api/status_summary.php @@ -0,0 +1,261 @@ + pm(미세먼지) > alert(온습도) > offline > ok +// · 온습도/미세먼지는 같은 sensor_id 아래 metric_type('sht30' vs 'pms7003')로 분리 — +// temp/hum/th_status 는 sht30 행에서만, pm25/pm10/pm1_0/pm_status 는 pms7003 행에서만. +// ============================================================================= + +require_once __DIR__ . '/../config.php'; + +header('Content-Type: application/json; charset=utf-8'); +header('Cache-Control: no-store'); + +if (($_SERVER['REQUEST_METHOD'] ?? '') !== 'GET') { + http_response_code(405); + echo json_encode(['error' => 'method_not_allowed']); + exit; +} + +// ── 레이트 리밋 (IP별 고정창 60초/30회 — 표시기 정상 폴링은 분당 2회) ───────── +// login.php 와 동일하게 php/var JSON + LOCK_EX. 파일 오류 시엔 차단하지 않는다 +// (모니터링 표시기가 스로틀 저장소 문제로 죽는 것보다 서비스 지속이 우선). +$rl_file = __DIR__ . '/../var/status_rate.json'; +$rl_ip = $_SERVER['REMOTE_ADDR'] ?? '0.0.0.0'; +$rl_now = time(); +$rl_win = $rl_now - ($rl_now % 60); +$rl = @json_decode((string)@file_get_contents($rl_file), true); +$rl = is_array($rl) ? $rl : []; +$rec = $rl[$rl_ip] ?? ['n' => 0, 'win' => $rl_win]; +if (($rec['win'] ?? 0) !== $rl_win) { $rec = ['n' => 0, 'win' => $rl_win]; } +$rec['n']++; +// 창이 바뀐 다른 IP 기록은 청소(파일 무한 성장 방지) +$rl = array_filter($rl, fn($r) => ($r['win'] ?? 0) === $rl_win); +$rl[$rl_ip] = $rec; +@file_put_contents($rl_file, json_encode($rl), LOCK_EX); +if ($rec['n'] > 30) { + http_response_code(429); + header('Retry-After: ' . (60 - ($rl_now % 60))); + echo json_encode(['error' => 'rate_limited']); + exit; +} + +// ── 인증 (fail closed) ──────────────────────────────────────────────────────── +if (!defined('API_KEY') || API_KEY === '' || API_KEY === 'change-this-api-key') { + http_response_code(503); + echo json_encode(['error' => 'server_not_configured']); + exit; +} +$status_secret = hash_hmac('sha256', 'status', API_KEY); +$given = (string)($_SERVER['HTTP_X_STATUS_KEY'] ?? ''); +if ($given === '' || !hash_equals($status_secret, $given)) { + http_response_code(403); + echo json_encode(['error' => 'forbidden']); + exit; +} + +// ── 데이터 수집 (대시보드 판정 규칙과 동일 기준) ───────────────────────────── +// DB 예외는 전부 잡아 generic 500 으로 — display_errors=On 호스팅에서도 내부정보 비노출. +try { +$db = get_db(); + +$status_rows = $db->query("SELECT * FROM leak_sensor_status ORDER BY sensor_id")->fetchAll(); +$sensor_ids = array_map(fn($r) => (int)$r['sensor_id'], $status_rows); + +// 최신 측정을 (sensor_id, metric_type) 단위로 가져온다 — 누수 노드(1·3)는 온습도(sht30)와 +// 미세먼지(pms7003)를 같은 sensor_id 로 보고하므로, metric_type 무시하고 MAX(id) 로 뽑으면 +// 두 행이 서로를 덮어써(sht30 행은 pm NULL, pms 행은 temp/hum NULL) 한쪽이 사라진다. +// → metric_type 별 MAX(id) 로 뽑아 sht30/pms 버킷에 각각 담는다. 레거시 metric_type='' 은 sht30. +$latest_sht30 = []; // sensor_id => 최신 sht30(온습도) 행 +$latest_pms = []; // sensor_id => 최신 pms7003(미세먼지) 행 +foreach ($db->query(" + SELECT sm.* + FROM leak_sensor_metric sm + INNER JOIN ( + SELECT sensor_id, metric_type, MAX(id) AS latest_id + FROM leak_sensor_metric GROUP BY sensor_id, metric_type + ) latest ON latest.latest_id = sm.id +")->fetchAll() as $mr) { + $sid = (int)$mr['sensor_id']; + $mt = (string)($mr['metric_type'] ?? ''); + if ($mt === 'pms7003') { + $latest_pms[$sid] = $mr; + } else { + // 'sht30' 또는 레거시 '' — 온습도로 취급. sht30·'' 가 둘 다 있으면 최신(id 큰) 것 유지. + if (!isset($latest_sht30[$sid]) || (int)$mr['id'] > (int)$latest_sht30[$sid]['id']) { + $latest_sht30[$sid] = $mr; + } + } +} + +// ── 24시간 추세(시간당 평균, 최대 24포인트) — 센서별 쿼리로 +// (sensor_id, measured_at) 복합 인덱스를 태워 풀스캔 방지(30초 폴링 엔드포인트) ── +$history = []; // sensor_id => [ ['t'=>'HH:MM','temp'=>float|null,'hum'=>float|null], ... ] +$pm_history = []; // sensor_id => [ ['t'=>'HH:MM','pm25'=>int], ... ] (pms7003 시간당 평균) +// AVG 는 컬럼별로 NULL 을 무시하므로, 같은 시간버킷에 온습도(sht30 행)·미세먼지(pms 행)가 +// 섞여 있어도 서로를 오염시키지 않는다(sht30 행은 pm2_5=NULL, pms 행은 temp/hum=NULL). +// pm2_5 를 같은 쿼리에 얹어 추가 쿼리 없이 (sensor_id, measured_at) 인덱스를 그대로 태운다. +$hist_stmt = $db->prepare(" + SELECT DATE_FORMAT(measured_at, '%Y-%m-%d %H:00:00') AS bucket, + AVG(temperature_c) AS temp, + AVG(humidity_percent) AS hum, + AVG(pm2_5) AS pm25 + FROM leak_sensor_metric + WHERE sensor_id = :sid AND measured_at >= NOW() - INTERVAL 24 HOUR + GROUP BY bucket + ORDER BY bucket + LIMIT 25 +"); +foreach ($sensor_ids as $sid) { + $hist_stmt->execute([':sid' => $sid]); + foreach ($hist_stmt->fetchAll() as $h) { + $t = substr($h['bucket'], 11, 5); // HH:MM + $history[$sid][] = [ + 't' => $t, + 'temp' => $h['temp'] !== null ? round((float)$h['temp'], 1) : null, + 'hum' => $h['hum'] !== null ? round((float)$h['hum'], 0) : null, + ]; + if ($h['pm25'] !== null) { // 미세먼지 값이 있는 버킷만 + $pm_history[$sid][] = ['t' => $t, 'pm25' => (int)round((float)$h['pm25'])]; + } + } +} + +// 누수 이벤트 타임라인 — 플래핑 센서가 응답을 무한정 키우지 않도록 상한(최신 우선) +$leak_events = []; // sensor_id => ['HH:MM', ...] — 최근 24h leak_alert 발생 시각 +foreach ($db->query(" + SELECT sensor_id, sensor_time + FROM leak_sensor_log + WHERE event_type = 'leak_alert' AND sensor_time >= NOW() - INTERVAL 24 HOUR + ORDER BY sensor_time DESC + LIMIT 200 +")->fetchAll() as $e) { + $leak_events[(int)$e['sensor_id']][] = substr((string)$e['sensor_time'], 11, 5); +} +foreach ($leak_events as &$ev_list) { $ev_list = array_reverse($ev_list); } // 시간순 복원 +unset($ev_list); + +$now = time(); +$sensors = []; +$offline_count = 0; +foreach ($status_rows as $s) { + $sid = (int)$s['sensor_id']; + $elapsed = $now - strtotime($s['last_seen'] ?? '2000-01-01'); + $offline = ((int)($s['offline_alerted'] ?? 0) === 1) || ($elapsed >= heartbeat_timeout_for($sid)); + if ($offline) { $offline_count++; } + + // 누수 확정(디바운스) 기준 — 대시보드/SMS 와 동일. 컬럼 부재 구배포는 raw 폴백. + $raw_leak = (int)($s['is_leak'] ?? 0); + $leak = array_key_exists('leak_confirmed', $s) ? (int)$s['leak_confirmed'] : $raw_leak; + $pending = array_key_exists('leak_confirmed', $s) + && (($s['leak_pending_since'] ?? null) !== null) && ($raw_leak !== $leak); + + [$location, $name] = sensor_label($sid, $s['device_location'] ?? null, $s['sensor_name'] ?? null); + // 최신 측정을 metric_type 별로 분리 — 온습도(sht30 행)와 미세먼지(pms 행)가 같은 + // sensor_id 아래 서로를 덮어쓰지 않게 한다. temp/hum/th_status 는 sht30 행에서만, + // pm25/pm10/pm1_0/pm_status 는 pms 행에서만 취한다. + $sht = $latest_sht30[$sid] ?? null; + $pms = $latest_pms[$sid] ?? null; + + // 온습도 대표 상태(결로 포함) — sht30 행의 저장 metric_status. + $th_status = $sht['metric_status'] ?? 'normal'; + // 미세먼지 상태 — pms 행의 저장 metric_status(정의상 pms 행은 temp/hum NULL 이라 + // high_pm25/high_pm10/normal 만 나온다). 계약 보장을 위해 PM 값만 허용, 그 외는 normal. + $pm_status_raw = $pms['metric_status'] ?? 'normal'; + $pm_status = in_array($pm_status_raw, ['high_pm25', 'high_pm10'], true) ? $pm_status_raw : 'normal'; + + $sensors[] = [ + 'id' => $sid, + 'name' => $name, + 'location' => $location, + 'leak_node' => in_array($sid, LEAK_SENSOR_IDS, true), + 'metric_node' => in_array($sid, METRIC_SENSOR_IDS, true), + 'pm_node' => in_array($sid, PM_SENSOR_IDS, true), + 'offline' => $offline, + 'leak' => $leak, + 'pending' => $pending, + 'th_status' => $th_status, // 온습도(+결로) 상태(sht30 행) + 'metric_status' => $th_status, // 하위호환 별칭(= th_status) + 'pm_status' => $pm_status, // 미세먼지 상태(normal|high_pm25|high_pm10) + 'temp' => isset($sht['temperature_c']) && $sht['temperature_c'] !== null ? (float)$sht['temperature_c'] : null, + 'hum' => isset($sht['humidity_percent']) && $sht['humidity_percent'] !== null ? (float)$sht['humidity_percent'] : null, + 'pm25' => isset($pms['pm2_5']) && $pms['pm2_5'] !== null ? (int)$pms['pm2_5'] : null, + 'pm10' => isset($pms['pm10']) && $pms['pm10'] !== null ? (int)$pms['pm10'] : null, + 'pm1_0' => isset($pms['pm1_0']) && $pms['pm1_0'] !== null ? (int)$pms['pm1_0'] : null, + 'last_seen' => $s['last_seen'] ?? null, + 'history' => $history[$sid] ?? [], // 24h 시간당 추세(온/습도) + 'pm_history' => $pm_history[$sid] ?? [], // 24h 시간당 추세(PM2.5) + 'leak_events' => $leak_events[$sid] ?? [], // 24h 누수 이벤트 시각 + ]; +} + +$unrecovered = 0; +if (table_exists_api($db, 'leak_incident')) { + $unrecovered = (int)$db->query("SELECT COUNT(*) FROM leak_incident WHERE recovered_at IS NULL")->fetchColumn(); +} + +$today_alerts = (int)$db->query(" + SELECT COUNT(*) FROM leak_sensor_metric + WHERE metric_status <> 'normal' AND measured_at >= CURDATE() +")->fetchColumn() ++ (int)$db->query(" + SELECT COUNT(*) FROM leak_sensor_log + WHERE event_type = 'leak_alert' AND sensor_time >= CURDATE() +")->fetchColumn(); + +// 시스템 종합 상태 — 표시기 배경색 판정용. 5단 우선순위: 누수 > 미세먼지 > 온습도 > 오프라인 > 정상 +// (leak > pm > alert(온습도 th) > offline > ok). 오프라인 센서의 경보는 신선하지 않으므로 제외. +$leak_active = count(array_filter($sensors, fn($x) => $x['leak'] === 1 && !$x['offline'])); +$pm_alert = count(array_filter($sensors, fn($x) => $x['pm_status'] !== 'normal' && !$x['offline'])); +$th_alert = count(array_filter($sensors, fn($x) => $x['th_status'] !== 'normal' && !$x['offline'])); +if ($leak_active > 0 || $unrecovered > 0) { $state = 'leak'; } +elseif ($pm_alert > 0) { $state = 'pm'; } +elseif ($th_alert > 0) { $state = 'alert'; } +elseif ($offline_count > 0) { $state = 'offline'; } +else { $state = 'ok'; } + +echo json_encode([ + 'ts' => date('Y-m-d H:i:s'), + 'system' => [ + 'state' => $state, + 'online' => count($sensors) - $offline_count, + 'total' => count($sensors), + 'unrecovered' => $unrecovered, + 'sms_balance' => latest_sms_balance($db), + 'today_alerts' => $today_alerts, + ], + 'sensors' => $sensors, +], JSON_UNESCAPED_UNICODE); + +} catch (Throwable $e) { + // 내부 오류 상세(쿼리/테이블명 등)는 응답에 싣지 않는다 — 로그로만. + error_log('[status_summary] ' . $e->getMessage()); + http_response_code(500); + echo json_encode(['error' => 'internal']); + exit; +} + +// 로컬 헬퍼 — dashboard_data.php(관리 화면 전용)를 끌어오지 않기 위한 최소 복제. +function table_exists_api(PDO $db, string $table): bool { + try { + $st = $db->prepare(" + SELECT COUNT(*) FROM information_schema.tables + WHERE table_schema = DATABASE() AND table_name = :t + "); + $st->execute([':t' => $table]); + return (int)$st->fetchColumn() > 0; + } catch (Throwable $e) { + return false; + } +} diff --git a/php/assets/dash-v2.css b/php/assets/dash-v2.css new file mode 100644 index 0000000..1a3fdc9 --- /dev/null +++ b/php/assets/dash-v2.css @@ -0,0 +1,237 @@ +/* ============================================================================= + * dash-v2.css — dashboard.php 전용 오버레이 (app.css 뒤에 로드) + * 목업 review/ui_mockup_v2.html 반영: 글래스 툴바 · 스프링 모션 · 세그먼트 탭 · + * 결합 센서 카드(누수+온습도) · 카운트다운 링 · 카드형 테이블 헤더. + * app.css 는 7페이지 공용이므로 건드리지 않는다. 인쇄 규칙은 app.css 것을 계승. + * 폐쇄망: 외부 리소스 0. + * ============================================================================= */ + +:root { + --spring: cubic-bezier(.32,.72,.28,1); /* 감쇠 스프링 근사 */ +} + +/* ── 글래스 툴바: 반투명 재질 + sticky, 콘텐츠가 아래로 스크롤 ─────────────── */ +header { + background: rgba(20,26,61,.78); + backdrop-filter: blur(18px) saturate(160%); + -webkit-backdrop-filter: blur(18px) saturate(160%); +} +.top-actions a { + transition: background .18s var(--spring), color .15s, border-color .15s, transform .15s var(--spring); +} +.top-actions a:active { transform: scale(.96); } + +/* ── 새로고침 카운트다운 링 ─────────────────────────────────────────────────── */ +.refresh { display: inline-flex; align-items: center; gap: 8px; } +.refresh-ring { width: 15px; height: 15px; flex: none; } +.refresh-ring circle { + fill: none; stroke: var(--primary-600); stroke-width: 2.5; + stroke-dasharray: 38; stroke-linecap: round; + transform: rotate(-90deg); transform-origin: center; + animation: dash-countdown 30s linear infinite; +} +@keyframes dash-countdown { from { stroke-dashoffset: 0; } to { stroke-dashoffset: 38; } } +.refresh.paused .refresh-ring circle { animation-play-state: paused; opacity: .35; } +.refresh-toggle { transition: transform .15s var(--spring), background .15s; } +.refresh-toggle:active { transform: scale(.93); } + +/* ── 세그먼트 탭바(iOS segmented control) ───────────────────────────────────── */ +.tabbar { border-radius: var(--r-pill); padding: 4px; gap: 3px; } +.tabbar .tab-link { + border-radius: var(--r-pill); padding: 8px 18px; + transition: background .22s var(--spring), color .15s, transform .15s var(--spring); +} +.tabbar .tab-link:active { transform: scale(.96); } +.tabbar .tab-link.active { + color: #fff; + background: linear-gradient(135deg, var(--primary), #8f6bff); + box-shadow: 0 4px 14px rgba(108,123,255,.35); +} +.tabbar .tab-link.active::after { content: none; } +.tabbar .tab-link.active .tab-badge.crit { background: rgba(255,255,255,.22); color: #fff; } +.tabbar .tab-print { border-radius: var(--r-pill); } + +/* ── 인터랙션 공통: 즉각 반응(:active) + 부상(hover) 스프링 ─────────────────── */ +.kpi, .sensor-card, .leak-status-card, .alert-strip, .quick-tile { + transition: transform .2s var(--spring), border-color .2s, box-shadow .2s, background .2s; +} +.kpi:hover, .sensor-card:hover, .leak-status-card:hover { + transform: translateY(-3px); box-shadow: var(--shadow-lg); +} +.kpi:active, .sensor-card:active, .alert-strip:active { transform: translateY(-1px) scale(.985); } +.alert-strip:hover { transform: translateX(3px); } + +/* ── 결합 센서 카드 (누수+온습도) ──────────────────────────────────────────── + 상태 우선순위: 오프라인 > 누수 확정 > 확인중 > 온습도 경보 > 정상 */ +.sensor-card { position: relative; overflow: hidden; } +.sensor-card::before { + content: ""; position: absolute; inset: 0 0 auto 0; height: 3px; + background: var(--normal); opacity: .85; +} +.sensor-card.alert::before { background: var(--leak); } +.sensor-card.leak::before { background: var(--chart-hum); } +.sensor-card.pending::before { background: var(--warn); } +.sensor-card.offline::before { background: var(--offline); } +.sensor-card.offline { opacity: .78; } +.sensor-card.leak { + border-color: rgba(63,199,230,.4); + background: linear-gradient(180deg, rgba(63,199,230,.10), transparent 60%), var(--surface); + animation: dash-leak-glow 1.8s ease-in-out infinite; +} +@keyframes dash-leak-glow { + 50% { box-shadow: 0 0 26px rgba(63,199,230,.30); } +} + +/* 유형 칩 2개(결합) 나란히 */ +.sc-types { display: flex; gap: 4px; flex: none; } + +/* 상태 라인: 점 + 텍스트 (badge-row 대체 — 기존 배지도 유지 호환) */ +.status-line { display: flex; align-items: center; gap: 7px; font-size: .8125rem; font-weight: 700; margin-top: var(--sp-2); } +.status-line .sdot { width: 8px; height: 8px; border-radius: 50%; background: var(--normal); box-shadow: 0 0 8px var(--normal); flex: none; } +.status-line.ok { color: var(--normal-ink); } +.status-line.err { color: var(--leak-ink); } +.status-line.err .sdot { background: var(--leak); box-shadow: 0 0 8px var(--leak); animation: dash-pulse 1.2s ease-in-out infinite; } +.status-line.leakst { color: var(--cold-ink); } +.status-line.leakst .sdot { background: var(--chart-hum); box-shadow: 0 0 8px var(--chart-hum); animation: dash-pulse 1.2s ease-in-out infinite; } +.status-line.pend { color: var(--warn-ink); } +.status-line.pend .sdot { background: var(--warn); box-shadow: 0 0 8px var(--warn); } +.status-line.off { color: var(--offline-ink); } +.status-line.off .sdot { background: var(--offline); box-shadow: none; } +@keyframes dash-pulse { 50% { opacity: .4; } } + +/* 온습도 큰 수치 2열 */ +.vals { display: flex; gap: var(--sp-5); margin-top: var(--sp-3); } +.vals .v { flex: 1; min-width: 0; } +.vals .v .n { + font-size: 1.7rem; font-weight: 800; letter-spacing: -.03em; line-height: 1.08; + font-variant-numeric: tabular-nums; color: var(--ink); +} +.vals .v .n small { font-size: .85rem; color: var(--ink-2); font-weight: 600; letter-spacing: 0; } +.vals .v .l { font-size: .7rem; color: var(--ink-3); margin-top: 2px; } +.vals .v.temp .n { color: var(--chart-temp); } +.vals .v.hum .n { color: var(--chart-hum); } +.vals .v .n.hot { text-shadow: 0 0 14px rgba(255,138,92,.5); } +.vals .v .n.cold { color: var(--cold-ink); } +.vals .v .n.humid { text-shadow: 0 0 14px rgba(255,178,62,.5); } +.vals .v .n.dry { color: var(--dry-ink); } + +/* 24h 스파크라인 */ +.spark24 { width: 100%; height: 34px; display: block; margin-top: var(--sp-2); } +.spark24 polyline { fill: none; stroke-width: 1.6; stroke-linecap: round; stroke-linejoin: round; } +.spark24 .sp-t { stroke: var(--chart-temp); opacity: .85; } +.spark24 .sp-h { stroke: var(--chart-hum); opacity: .85; stroke-dasharray: 5 3; } + +/* 누수 행(결합 카드 내부): 경과·SMS·사진 */ +.leak-row { + display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; + margin-top: var(--sp-3); padding-top: var(--sp-3); border-top: 1px solid var(--line-2); +} +.leak-row .leak-fact { font-size: .8125rem; color: var(--ink-2); font-variant-numeric: tabular-nums; } +.leak-row .leak-fact b { color: var(--ink); font-weight: 800; } +.leak-row .leak-thumbs { margin-left: auto; } +.leak-row .leak-thumbs img { + width: 46px; height: 36px; object-fit: cover; + transition: transform .18s var(--spring); +} +.leak-row .leak-thumbs a:hover img { transform: scale(1.5); } + +/* 누수 카드 하단 물결(장식) */ +.leak-wave { position: absolute; left: 0; right: 0; bottom: 0; height: 42px; opacity: .14; pointer-events: none; } +.leak-wave path { fill: var(--chart-hum); } + +/* 카드 푸터: 최종확인 + 보고 주기 */ +.sc-foot { + display: flex; justify-content: space-between; gap: var(--sp-2); + font-size: .72rem; color: var(--ink-3); margin-top: var(--sp-3); + font-variant-numeric: tabular-nums; +} +.sc-foot .stale { color: var(--leak-ink); font-weight: 700; } + +/* ── 이력 테이블: 카드형 헤더(테이블 위 캡션 스트립) ────────────────────────── */ +h2.table-title { margin-bottom: 0; font-size: .9375rem; } +.table-title { + display: flex; align-items: center; gap: var(--sp-2); + background: var(--surface-2); border: 1px solid var(--line); border-bottom: none; + border-radius: var(--r-lg) var(--r-lg) 0 0; padding: 12px 16px; + font-size: .9375rem; font-weight: 700; +} +.table-title .count-pill { + margin-left: auto; font-size: .7rem; font-weight: 700; color: var(--ink-2); + background: var(--surface); border: 1px solid var(--line); + padding: 2px 10px; border-radius: var(--r-pill); font-variant-numeric: tabular-nums; +} +.table-title + .table-wrap, +.table-title + details.mobile-detail .table-wrap { border-radius: 0 0 var(--r-lg) var(--r-lg); } +.table-title + .table-wrap table, +.table-title + details.mobile-detail table { border-radius: 0 0 var(--r-lg) var(--r-lg); border-top: none; } +.table-title .h2-sub { margin-left: auto; font-size: .72rem; font-weight: 600; color: var(--ink-3); font-variant-numeric: tabular-nums; } + +/* ── 히어로 미세 조정: 큰 제목 음수 트래킹 ──────────────────────────────────── */ +.status-hero h2 { letter-spacing: -.02em; } +.kpi .kpi-val, .card .val, .gauge-num b { letter-spacing: -.03em; } + +/* ── 미세먼지(PM) 계통 색상·요소 (온습도 orange 와 구분되는 magenta/violet 계열) ────── + PMS7003 미세먼지 경보가 온습도 경보로 오독되지 않도록 별도 색 계통을 부여한다. PM 토큰은 + app.css 에 없으므로 여기서 정의(dash-v2.css 는 app.css 뒤 로드 — 재정의 아님, additive). */ +:root { + --pm: #d24fd6; /* 미세먼지 대표 액센트(도넛·칩·라인) */ + --pm-ink: #eca3ea; /* 어두운 배경 위 텍스트용 밝은 변형 */ + --pm-bg: rgba(210,79,214,.15); + --pm25: #e85fbf; /* PM2.5 계열(magenta-pink) */ + --pm10: #a06bff; /* PM10 계열(violet) */ +} + +/* 유형 칩(미세먼지) — sc-type.metric(청록)·sc-type.leak(회색)과 구분 */ +.sc-type.pm { background: var(--pm-bg); color: var(--pm-ink); } + +/* 카드 상태(미세먼지 경보) — 상단 바 + 배경 그라디언트(온습도 alert 와 별개 색) */ +.sensor-card.pm { border-color: rgba(210,79,214,.4); background: linear-gradient(180deg, var(--pm-bg), transparent 60%), var(--surface); } +.sensor-card.pm::before { background: var(--pm); } + +/* 상태 라인(미세먼지 경보) */ +.status-line.pmst { color: var(--pm-ink); } +.status-line.pmst .sdot { background: var(--pm); box-shadow: 0 0 8px var(--pm); animation: dash-pulse 1.2s ease-in-out infinite; } + +/* 미세먼지 큰 수치 2열 — 온습도 vals 재사용, 색만 PM 계열. 위반 시 hot 글로우(magenta) */ +.vals.pm-vals { margin-top: var(--sp-2); } +.vals .v.pm25 .n { color: var(--pm25); } +.vals .v.pm10 .n { color: var(--pm10); } +.pm-vals .v .n.hot { text-shadow: 0 0 14px rgba(210,79,214,.55); } + +/* 상태 pill(미세먼지/결로) — 이력·측정 표의 상태 배지 */ +.pill.high_pm25 { background: var(--pm-bg); color: var(--pm-ink); } +.pill.high_pm10 { background: var(--pm-bg); color: var(--pm-ink); } +.pill.condensation_risk { background: var(--warn-bg); color: var(--warn-ink); } + +/* 추이 차트(미세먼지) — dashboard.php 인라인