178 lines
5.5 KiB
TypeScript
178 lines
5.5 KiB
TypeScript
"use client";
|
|
|
|
/**
|
|
* KPI 카드 서브타입 컴포넌트
|
|
*
|
|
* 큰 숫자 + 단위 + 증감 표시
|
|
* ResizeObserver로 컨테이너 크기를 감지하여 글자 크기를 비례적으로 조절
|
|
* itemStyle.valueFontSize 로 배율 조절 가능
|
|
*/
|
|
|
|
import React, { useRef, useEffect, useState } from "react";
|
|
import type { DashboardItem, FontSize } from "../../types";
|
|
import { TEXT_ALIGN_CLASSES } from "../../types";
|
|
import { abbreviateNumber } from "../utils/formula";
|
|
|
|
// ===== Props =====
|
|
|
|
export interface KpiCardProps {
|
|
item: DashboardItem;
|
|
data: number | null;
|
|
/** 이전 기간 대비 증감 퍼센트 (선택) */
|
|
trendValue?: number | null;
|
|
/** 수식 결과 표시 문자열 (formula가 있을 때) */
|
|
formulaDisplay?: string | null;
|
|
}
|
|
|
|
// ===== valueFontSize → 크기 배율 =====
|
|
|
|
const FONT_SIZE_SCALE: Record<FontSize, number> = {
|
|
xs: 0.65,
|
|
sm: 0.80,
|
|
base: 1.0,
|
|
lg: 1.25,
|
|
xl: 1.55,
|
|
};
|
|
|
|
// ===== 색상 구간 판정 =====
|
|
|
|
function getColorForValue(
|
|
value: number,
|
|
ranges?: { min: number; max: number; color: string }[]
|
|
): string | undefined {
|
|
if (!ranges?.length) return undefined;
|
|
const match = ranges.find((r) => value >= r.min && value <= r.max);
|
|
return match?.color;
|
|
}
|
|
|
|
// ===== 메인 컴포넌트 =====
|
|
|
|
export function KpiCardComponent({
|
|
item,
|
|
data,
|
|
trendValue,
|
|
formulaDisplay,
|
|
}: KpiCardProps) {
|
|
const { visibility, kpiConfig, itemStyle } = item;
|
|
const displayValue = data ?? 0;
|
|
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
|
|
|
|
// valueFontSize 배율 (기본: base = 1.0)
|
|
const valueFontSize: FontSize = itemStyle?.valueFontSize ?? "base";
|
|
const scale = FONT_SIZE_SCALE[valueFontSize];
|
|
|
|
// 라벨 정렬
|
|
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
|
|
|
|
// 단위: kpiConfig.unit 우선, fallback으로 item.unit (레거시)
|
|
const unit = kpiConfig?.unit ?? item.unit;
|
|
|
|
// ===== 컨테이너 크기 감지 =====
|
|
const containerRef = useRef<HTMLDivElement>(null);
|
|
const [containerSize, setContainerSize] = useState({ w: 200, h: 100 });
|
|
|
|
useEffect(() => {
|
|
const el = containerRef.current;
|
|
if (!el) return;
|
|
|
|
const observer = new ResizeObserver((entries) => {
|
|
for (const entry of entries) {
|
|
const { width, height } = entry.contentRect;
|
|
setContainerSize({ w: width, h: height });
|
|
}
|
|
});
|
|
observer.observe(el);
|
|
return () => observer.disconnect();
|
|
}, []);
|
|
|
|
// ===== 비례 폰트 크기 계산 =====
|
|
// 숫자: 컨테이너 높이의 50% (라벨·단위가 없으면 60%)
|
|
// 라벨: 컨테이너 높이의 13%
|
|
// 단위: 숫자 크기의 40%
|
|
// 추세: 고정 최소값 (너무 작으면 읽기 어려움)
|
|
|
|
const hasLabel = visibility.showLabel && !!item.label;
|
|
const hasTrend = visibility.showTrend && trendValue != null;
|
|
const hasSubLabel = visibility.showSubLabel && !!formulaDisplay;
|
|
|
|
// 수직 공간을 차지하는 요소 수에 따라 숫자 비율 조정
|
|
const occupiedLines = (hasLabel ? 1 : 0) + (hasTrend ? 1 : 0) + (hasSubLabel ? 1 : 0);
|
|
const valueRatio = occupiedLines === 0 ? 0.62 : occupiedLines === 1 ? 0.52 : 0.42;
|
|
|
|
// 너비 기준으로도 제한 (너무 넓은 셀에서 지나치게 커지지 않도록)
|
|
const baseFromHeight = containerSize.h * valueRatio;
|
|
const baseFromWidth = containerSize.w * 0.35;
|
|
const baseSize = Math.min(baseFromHeight, baseFromWidth);
|
|
|
|
const valueFontPx = Math.max(16, Math.round(baseSize * scale));
|
|
const labelFontPx = Math.max(11, Math.round(containerSize.h * 0.13 * scale));
|
|
const unitFontPx = Math.max(12, Math.round(valueFontPx * 0.40));
|
|
const trendFontPx = Math.max(10, Math.round(containerSize.h * 0.09 * scale));
|
|
|
|
return (
|
|
<div
|
|
ref={containerRef}
|
|
className="flex h-full w-full flex-col items-center justify-center p-3"
|
|
>
|
|
{/* 라벨 */}
|
|
{hasLabel && (
|
|
<p
|
|
className={`w-full text-muted-foreground ${labelAlignClass}`}
|
|
style={{ fontSize: `${labelFontPx}px`, lineHeight: 1.2 }}
|
|
>
|
|
{item.label}
|
|
</p>
|
|
)}
|
|
|
|
{/* 메인 값 */}
|
|
{visibility.showValue && (
|
|
<div className="flex items-baseline gap-[0.15em]">
|
|
<span
|
|
className="font-bold leading-none"
|
|
style={{
|
|
fontSize: `${valueFontPx}px`,
|
|
...(valueColor ? { color: valueColor } : {}),
|
|
}}
|
|
>
|
|
{formulaDisplay ?? abbreviateNumber(displayValue)}
|
|
</span>
|
|
|
|
{/* 단위 */}
|
|
{visibility.showUnit && unit && (
|
|
<span
|
|
className="text-muted-foreground font-medium"
|
|
style={{ fontSize: `${unitFontPx}px` }}
|
|
>
|
|
{unit}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{/* 증감율 */}
|
|
{hasTrend && trendValue != null && (
|
|
<span
|
|
className="inline-flex items-center gap-0.5 font-medium"
|
|
style={{
|
|
fontSize: `${trendFontPx}px`,
|
|
color: trendValue > 0 ? "var(--color-emerald-600)" : trendValue === 0 ? "var(--muted-foreground)" : "var(--color-rose-600)",
|
|
}}
|
|
>
|
|
<span>{trendValue > 0 ? "↑" : trendValue === 0 ? "→" : "↓"}</span>
|
|
<span>{Math.abs(trendValue).toFixed(1)}%</span>
|
|
</span>
|
|
)}
|
|
|
|
{/* 보조 라벨 (수식 표시 등) */}
|
|
{hasSubLabel && (
|
|
<p
|
|
className="text-muted-foreground"
|
|
style={{ fontSize: `${Math.max(10, Math.round(labelFontPx * 0.85))}px` }}
|
|
>
|
|
{item.formula?.values.map((v) => v.label).join(" / ")}
|
|
</p>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|