POSA_LEAKSMS/php/report_charts.php
cw2007 06f96a8d09 feat(report): 월간 보고서 PDF 저장 전용 A4 문서 뷰
다크 대시보드를 그대로 인쇄하던 '단순 프린트지'를 화이트 A4 전문 보고서로 대체.
?format=print 로 브라우저 print-to-PDF 하면 표지·요약·차트·서명을 갖춘 정식 문서가
나온다. 설계 3안(경영진 브리프/보안증적/비주얼 애널리틱스) 병렬 → 종합 → 구현.
실제 브라우저 렌더로 6개 섹션 전부 검증.

구성:
- 표지 마스트헤드: 대형 대상월 + 관제 판정 씰(SVG 원형 도장 — 데이터 자동 판정
  安定/注意/警報)이 시그니처. 러닝 헤더/푸터(대외비·문서번호·시스템).
- 01 경영 요약: 헤드라인 KPI 4(SMS신뢰성 게이지·총경보·누수·측정) + 자동 총평 내러티브.
- 02 월간 지표: 경보·이벤트 / 온습도 측정 2열 정의형.
- 03 추이(인라인 SVG): 일별 평균 온·습도(안전/고온/저온 밴드·임계선), 일별 경보 스택
  막대(온습도=네이비 / 누수=적색 사선 해치 — 흑백 인쇄 구분).
- 04 관제 지점별 상세 카드, 05 누수 사고 로그(지속시간·복구), 06 기준·주석 및 서명란.

구현:
- 집계를 monthly_report_data.php(공유 include)로 추출 — HTML 조회·CSV·PDF 뷰가 단일
  소스 사용(회귀 0: HTML 표 3행·요약 11카드·CSV 3행 렌더 확인). 일별 추이·누수 사고·
  판정 파생은 인쇄 뷰 전용으로 추가.
- monthly_report_print.php(A4 뷰) + report_charts.php(인라인 SVG 차트) + assets/report-print.css
  (@page A4·여백·page-break·running header/footer·print-color-adjust). app.css 다크 토큰과
  격리(.report-paper 스코프, report-print.css 단독 링크).
- 폐쇄망 준수: 외부 CDN/JS/폰트/PDF 라이브러리 0, 시스템 폰트, 인라인 SVG.
- 보안: 신규 include 3파일 직접접근 가드 + .htaccess FilesMatch 차단(monthly_report.php
  본체는 진입 페이지라 제외). sensor_label 라벨·htmlspecialchars 이스케이프.
2026-07-11 16:56:02 +09:00

339 lines
17 KiB
PHP

<?php
// =============================================================================
// report_charts.php - 월간 보고서 인라인 SVG 차트 렌더 함수 (폐쇄망: 외부 0)
//
// monthly_report_print.php 가 include 해 호출한다. 전부 서버렌더 인라인 SVG —
// 차트 라이브러리·외부 폰트·CDN 없음. 색은 인쇄 안전 팔레트(app.css @media print
// 토큰과 값 정합)를 hex 로 직접 박아 print-color-adjust:exact 로 유실을 막는다.
// trend.php 의 좌표/스케일(x_at·y_temp·y_hum·build_segments·threshold) 로직을 이식하고
// 임계 밴드 배경을 더했다.
//
// 흑백 프린터 대비: 색 외 채널을 병행한다 — 온도 실선 vs 습도 파선, 누수 막대 사선
// 해치, 판정 씰 텍스트 라벨. (색·도형·텍스트 3중 인코딩)
// =============================================================================
if (!defined('APP_VERSION')) { exit; } // 직접 접근 차단(include 전용)
// ─── 인쇄 안전 팔레트(단일 출처) ──────────────────────────────────────────────
const RC_NAVY = '#14315e'; // 기준/온습도 경보 막대
const RC_INK = '#1a1f2b';
const RC_SUB = '#5b6472';
const RC_HAIR = '#c9d0da'; // 그리드/괘선
const RC_OK = '#0a6b39'; // 안정(녹)
const RC_WARN = '#8a5a00'; // 주의(황갈)
const RC_ALERT = '#b11f1f'; // 경보/누수(적)
const RC_TEMP = '#b34a1f'; // 온도 실선(cat-hot)
const RC_HUM = '#0f5e74'; // 습도 파선(cat-cold)
const RC_BAND_SAFE = '#e7f0ea'; // 안전 밴드(녹)
const RC_BAND_HOT = '#f6e3dc'; // 고온 밴드(적)
const RC_BAND_COLD = '#dfeef2'; // 저온 밴드(청)
// verdict → 색 매핑(씰·게이지 공유)
function rc_verdict_color(string $verdict): string {
return match ($verdict) {
'alert' => RC_ALERT,
'warn' => RC_WARN,
default => RC_NAVY, // ok(안정)은 신뢰감 있는 네이비
};
}
// 짧은 수치 포맷(소수 1자리, 불필요한 0 제거)
function rc_fmt(float $v): string {
return rtrim(rtrim(number_format($v, 1, '.', ''), '0'), '.');
}
// =============================================================================
// 1) 판정 씰 — 결재 도장형 스탬프. verdict 색·라벨 자동. 표지/경영요약 공유.
// =============================================================================
function render_verdict_seal(string $verdict, string $label): string {
$c = rc_verdict_color($verdict);
$lab = htmlspecialchars($label);
ob_start(); ?>
<svg viewBox="0 0 120 120" width="112" height="112" role="img"
aria-label="관제 판정: <?= $lab ?>" class="verdict-seal"
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
<g transform="rotate(-8 60 60)" fill="none" stroke="<?= $c ?>">
<circle cx="60" cy="60" r="56" stroke-width="2.4"></circle>
<circle cx="60" cy="60" r="48" stroke-width="1"></circle>
</g>
<g transform="rotate(-8 60 60)" fill="<?= $c ?>" stroke="none" text-anchor="middle">
<text x="60" y="34" font-size="10.5" font-weight="700" letter-spacing="1.5">관제 판정</text>
<text x="60" y="74" font-size="30" font-weight="800" letter-spacing="2"><?= $lab ?></text>
<text x="60" y="94" font-size="7.5" font-weight="700" letter-spacing="3">MONTHLY REVIEW</text>
</g>
</svg>
<?php
return (string)ob_get_clean();
}
// =============================================================================
// 2) 원형 게이지 — 값 %(0~100). 경영 요약 KPI(예: SMS 신뢰성). trend.php 게이지 이식.
// =============================================================================
function render_uptime_gauge(int $pct, string $color, string $center_big, string $center_small): string {
$pct = max(0, min(100, $pct));
ob_start(); ?>
<svg viewBox="0 0 120 120" width="100%" height="120" role="img"
aria-label="<?= htmlspecialchars($center_small) ?> <?= $pct ?>%" class="kpi-gauge"
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
<g transform="rotate(-90 60 60)" fill="none" stroke-linecap="round">
<circle cx="60" cy="60" r="50" pathLength="100" stroke="<?= RC_HAIR ?>" stroke-width="10"></circle>
<circle cx="60" cy="60" r="50" pathLength="100" stroke="<?= htmlspecialchars($color) ?>"
stroke-width="10" stroke-dasharray="<?= $pct ?> 100"></circle>
</g>
<text x="60" y="58" text-anchor="middle" font-size="26" font-weight="800"
fill="<?= RC_INK ?>"><?= htmlspecialchars($center_big) ?></text>
<text x="60" y="76" text-anchor="middle" font-size="9" font-weight="600"
fill="<?= RC_SUB ?>"><?= htmlspecialchars($center_small) ?></text>
</svg>
<?php
return (string)ob_get_clean();
}
// =============================================================================
// 3) 일별 평균 온·습도 strip-chart — 임계 밴드 배경 + 온도 실선 + 습도 파선(이중축).
// $cal_days: ['Y-m-d',...] $daily_temp/$daily_hum: 동일 순서, 결측 null.
// =============================================================================
function render_daily_temp_hum_line(array $cal_days, array $daily_temp, array $daily_hum): string {
$n = count($cal_days);
$temps = array_values($daily_temp); // cal_days 순서 유지(값 배열)
$hums = array_values($daily_hum);
$has_temp = count(array_filter($temps, fn($v) => $v !== null)) > 0;
$has_hum = count(array_filter($hums, fn($v) => $v !== null)) > 0;
if ($n === 0 || (!$has_temp && !$has_hum)) {
return '<div class="chart-empty">당월 측정 데이터가 없습니다.</div>';
}
$W = 920; $H = 300;
$pad_l = 46; $pad_r = 48; $pad_t = 22; $pad_b = 40;
$plot_w = $W - $pad_l - $pad_r;
$plot_h = $H - $pad_t - $pad_b;
// 임계 상수(밴드/스케일에 포함해 항상 보이게)
$t_high = (float)METRIC_TEMP_HIGH_C;
$t_low = (float)METRIC_TEMP_LOW_C;
// 온도 축: 데이터 + 임계 포함해 범위 산정
$t_vals = array_filter($temps, fn($v) => $v !== null);
$t_pool = array_merge($t_vals, [$t_high, $t_low]);
$t_min = min($t_pool); $t_max = max($t_pool);
if ($t_min === $t_max) { $t_min -= 1; $t_max += 1; }
$t_span = ($t_max - $t_min) * 0.12;
$t_min -= $t_span; $t_max += $t_span;
// 습도 축: 데이터 기준
$h_vals = array_filter($hums, fn($v) => $v !== null);
if ($h_vals) {
$h_min = min($h_vals); $h_max = max($h_vals);
if ($h_min === $h_max) { $h_min -= 1; $h_max += 1; }
$h_span = ($h_max - $h_min) * 0.12;
$h_min -= $h_span; $h_max += $h_span;
} else {
$h_min = 0; $h_max = 100;
}
$x_at = function (int $i) use ($pad_l, $plot_w, $n): float {
if ($n <= 1) { return $pad_l + $plot_w / 2; }
return $pad_l + ($plot_w * $i / ($n - 1));
};
$y_temp = function (?float $v) use ($pad_t, $plot_h, $t_min, $t_max): ?float {
if ($v === null) { return null; }
$r = ($t_max - $t_min) ?: 1;
return $pad_t + $plot_h * (1 - (($v - $t_min) / $r));
};
$y_hum = function (?float $v) use ($pad_t, $plot_h, $h_min, $h_max): ?float {
if ($v === null) { return null; }
$r = ($h_max - $h_min) ?: 1;
return $pad_t + $plot_h * (1 - (($v - $h_min) / $r));
};
$clampy = fn(float $y) => max($pad_t, min($pad_t + $plot_h, $y));
// 결측(null)은 구간을 끊어 polyline 을 만든다
$build_segments = function (array $vals, callable $y_fn) use ($x_at): array {
$segments = []; $current = [];
foreach ($vals as $i => $v) {
if ($v === null) {
if (count($current) >= 2) { $segments[] = $current; }
$current = [];
continue;
}
$y = $y_fn((float)$v);
$current[] = sprintf('%.1f,%.1f', $x_at($i), $y);
}
if (count($current) >= 2) { $segments[] = $current; }
return $segments;
};
$temp_segments = $build_segments($temps, $y_temp);
$hum_segments = $build_segments($hums, $y_hum);
// 임계 밴드 경계(온도축)
$y_high = $clampy($y_temp($t_high));
$y_low = $clampy($y_temp($t_low));
$band_top = $pad_t; $band_bottom = $pad_t + $plot_h;
ob_start(); ?>
<svg viewBox="0 0 <?= $W ?> <?= $H ?>" width="100%" height="<?= $H ?>"
preserveAspectRatio="xMidYMid meet" role="img"
aria-label="당월 일별 평균 온도·습도 추이(임계 밴드 배경)" class="strip-chart"
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
<!-- 임계 밴드: 고온(적)·안전(녹)·저온(청) -->
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $band_top) ?>" width="<?= $plot_w ?>"
height="<?= sprintf('%.1f', max(0, $y_high - $band_top)) ?>" fill="<?= RC_BAND_HOT ?>"></rect>
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $y_high) ?>" width="<?= $plot_w ?>"
height="<?= sprintf('%.1f', max(0, $y_low - $y_high)) ?>" fill="<?= RC_BAND_SAFE ?>"></rect>
<rect x="<?= $pad_l ?>" y="<?= sprintf('%.1f', $y_low) ?>" width="<?= $plot_w ?>"
height="<?= sprintf('%.1f', max(0, $band_bottom - $y_low)) ?>" fill="<?= RC_BAND_COLD ?>"></rect>
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
fill="none" stroke="<?= RC_HAIR ?>" stroke-width="1"></rect>
<!-- 가로 그리드 + 좌(온도)/우(습도) 눈금 -->
<?php for ($g = 0; $g <= 4; $g++):
$gy = $pad_t + $plot_h * $g / 4;
$tv = $t_max - ($t_max - $t_min) * $g / 4;
$hv = $h_max - ($h_max - $h_min) * $g / 4; ?>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $pad_l + $plot_w ?>"
y2="<?= sprintf('%.1f', $gy) ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.6" stroke-dasharray="3,3"></line>
<text x="<?= $pad_l - 7 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end"
font-size="10" fill="<?= RC_TEMP ?>"><?= htmlspecialchars(rc_fmt($tv)) ?></text>
<text x="<?= $pad_l + $plot_w + 7 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="start"
font-size="10" fill="<?= RC_HUM ?>"><?= htmlspecialchars(rc_fmt($hv)) ?></text>
<?php endfor; ?>
<!-- 임계선(온도 고온/저온) 라벨 -->
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $y_high) ?>" x2="<?= $pad_l + $plot_w ?>"
y2="<?= sprintf('%.1f', $y_high) ?>" stroke="<?= RC_ALERT ?>" stroke-width="1" stroke-dasharray="2,3" opacity="0.7"></line>
<text x="<?= $pad_l + 4 ?>" y="<?= sprintf('%.1f', $y_high - 3) ?>" font-size="9"
fill="<?= RC_ALERT ?>">고온 <?= htmlspecialchars(rc_fmt($t_high)) ?>℃</text>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $y_low) ?>" x2="<?= $pad_l + $plot_w ?>"
y2="<?= sprintf('%.1f', $y_low) ?>" stroke="<?= RC_HUM ?>" stroke-width="1" stroke-dasharray="2,3" opacity="0.7"></line>
<text x="<?= $pad_l + 4 ?>" y="<?= sprintf('%.1f', $y_low + 11) ?>" font-size="9"
fill="<?= RC_HUM ?>">저온 <?= htmlspecialchars(rc_fmt($t_low)) ?>℃</text>
<!-- X축 날짜(일자) 라벨: 1/5/10.../말일 -->
<?php
for ($i = 0; $i < $n; $i++):
$day = (int)substr($cal_days[$i], 8, 2);
$is_tick = ($day === 1 || $day % 5 === 0 || $i === $n - 1);
if (!$is_tick) { continue; }
$lx = $x_at($i);
$anchor = $i === 0 ? 'start' : ($i === $n - 1 ? 'end' : 'middle'); ?>
<line x1="<?= sprintf('%.1f', $lx) ?>" y1="<?= $pad_t + $plot_h ?>" x2="<?= sprintf('%.1f', $lx) ?>"
y2="<?= $pad_t + $plot_h + 4 ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.8"></line>
<text x="<?= sprintf('%.1f', $lx) ?>" y="<?= $H - 22 ?>" text-anchor="<?= $anchor ?>"
font-size="9.5" fill="<?= RC_SUB ?>"><?= $day ?></text>
<?php endfor; ?>
<text x="<?= $pad_l + $plot_w / 2 ?>" y="<?= $H - 6 ?>" text-anchor="middle"
font-size="9" fill="<?= RC_SUB ?>">일(당월)</text>
<!-- 습도 파선 -->
<?php foreach ($hum_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" fill="none" stroke="<?= RC_HUM ?>"
stroke-width="1.8" stroke-dasharray="6,3" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
<!-- 온도 실선 -->
<?php foreach ($temp_segments as $seg): ?>
<polyline points="<?= htmlspecialchars(implode(' ', $seg)) ?>" fill="none" stroke="<?= RC_TEMP ?>"
stroke-width="2" stroke-linejoin="round" stroke-linecap="round"></polyline>
<?php endforeach; ?>
</svg>
<div class="chart-legend-row">
<span><span class="lg-line" style="background:<?= RC_TEMP ?>"></span> 평균 온도(℃, 좌축·실선)</span>
<span><span class="lg-line dash" style="background:<?= RC_HUM ?>"></span> 평균 습도(%, 우축·파선)</span>
<span><span class="lg-band" style="background:<?= RC_BAND_SAFE ?>"></span> 안전대</span>
<span><span class="lg-band" style="background:<?= RC_BAND_HOT ?>"></span> 고온대</span>
<span><span class="lg-band" style="background:<?= RC_BAND_COLD ?>"></span> 저온대</span>
</div>
<?php
return (string)ob_get_clean();
}
// =============================================================================
// 4) 일별 경보 발생 스택 막대 — 온습도 임계(네이비) + 누수 감지(적·사선 해치).
// =============================================================================
function render_daily_alert_bars(array $cal_days, array $daily_metric, array $daily_leak): string {
$n = count($cal_days);
$metric = array_values($daily_metric);
$leak = array_values($daily_leak);
if ($n === 0) {
return '<div class="chart-empty">당월 일자 축이 없습니다.</div>';
}
$totals = [];
for ($i = 0; $i < $n; $i++) {
$totals[$i] = (int)($metric[$i] ?? 0) + (int)($leak[$i] ?? 0);
}
$peak = max(1, max($totals));
// Y 눈금 상한을 보기 좋은 정수로 올림(최대 4단계)
$step = (int)max(1, ceil($peak / 4));
$y_top = $step * 4;
$W = 920; $H = 240;
$pad_l = 40; $pad_r = 16; $pad_t = 20; $pad_b = 40;
$plot_w = $W - $pad_l - $pad_r;
$plot_h = $H - $pad_t - $pad_b;
$slot = $plot_w / $n;
$bar_w = min(22, $slot * 0.6);
ob_start(); ?>
<svg viewBox="0 0 <?= $W ?> <?= $H ?>" width="100%" height="<?= $H ?>"
preserveAspectRatio="xMidYMid meet" role="img"
aria-label="당월 일별 경보 발생(온습도 임계 + 누수 감지) 스택 막대"
style="print-color-adjust:exact;-webkit-print-color-adjust:exact">
<defs>
<pattern id="leakHatch" patternUnits="userSpaceOnUse" width="5" height="5" patternTransform="rotate(45)">
<rect width="5" height="5" fill="<?= RC_ALERT ?>"></rect>
<line x1="0" y1="0" x2="0" y2="5" stroke="#fff" stroke-width="1.4"></line>
</pattern>
</defs>
<rect x="<?= $pad_l ?>" y="<?= $pad_t ?>" width="<?= $plot_w ?>" height="<?= $plot_h ?>"
fill="none" stroke="<?= RC_HAIR ?>" stroke-width="1"></rect>
<!-- Y 그리드 + 눈금 -->
<?php for ($g = 0; $g <= 4; $g++):
$gv = $y_top - $step * $g;
$gy = $pad_t + $plot_h * $g / 4; ?>
<line x1="<?= $pad_l ?>" y1="<?= sprintf('%.1f', $gy) ?>" x2="<?= $pad_l + $plot_w ?>"
y2="<?= sprintf('%.1f', $gy) ?>" stroke="<?= RC_HAIR ?>" stroke-width="0.6" stroke-dasharray="3,3"></line>
<text x="<?= $pad_l - 6 ?>" y="<?= sprintf('%.1f', $gy + 3) ?>" text-anchor="end"
font-size="10" fill="<?= RC_SUB ?>"><?= $gv ?></text>
<?php endfor; ?>
<!-- 스택 막대 -->
<?php for ($i = 0; $i < $n; $i++):
$mv = (int)($metric[$i] ?? 0);
$lv = (int)($leak[$i] ?? 0);
$cx = $pad_l + $slot * ($i + 0.5);
$bx = $cx - $bar_w / 2;
$base = $pad_t + $plot_h;
// 온습도(네이비) 아래, 누수(해치) 위
$m_h = $mv > 0 ? ($plot_h * $mv / $y_top) : 0;
$l_h = $lv > 0 ? ($plot_h * $lv / $y_top) : 0;
$m_y = $base - $m_h;
$l_y = $m_y - $l_h;
?>
<?php if ($mv > 0): ?>
<rect x="<?= sprintf('%.1f', $bx) ?>" y="<?= sprintf('%.1f', $m_y) ?>" width="<?= sprintf('%.1f', $bar_w) ?>"
height="<?= sprintf('%.1f', $m_h) ?>" fill="<?= RC_NAVY ?>"></rect>
<?php endif; ?>
<?php if ($lv > 0): ?>
<rect x="<?= sprintf('%.1f', $bx) ?>" y="<?= sprintf('%.1f', $l_y) ?>" width="<?= sprintf('%.1f', $bar_w) ?>"
height="<?= sprintf('%.1f', $l_h) ?>" fill="url(#leakHatch)" stroke="<?= RC_ALERT ?>" stroke-width="0.5"></rect>
<?php endif; ?>
<?php
$day = (int)substr($cal_days[$i], 8, 2);
if ($day === 1 || $day % 5 === 0 || $i === $n - 1): ?>
<text x="<?= sprintf('%.1f', $cx) ?>" y="<?= $H - 22 ?>" text-anchor="middle"
font-size="9.5" fill="<?= RC_SUB ?>"><?= $day ?></text>
<?php endif; ?>
<?php endfor; ?>
<text x="<?= $pad_l + $plot_w / 2 ?>" y="<?= $H - 6 ?>" text-anchor="middle"
font-size="9" fill="<?= RC_SUB ?>">일(당월) · 발생 건수</text>
</svg>
<div class="chart-legend-row">
<span><span class="lg-sw" style="background:<?= RC_NAVY ?>"></span> 온습도 임계 경보</span>
<span><span class="lg-sw hatch"></span> 누수 감지</span>
</div>
<?php
return (string)ob_get_clean();
}