feat: KPI 카드 글자 크기를 컨테이너 크기에 비례하도록 개선

ResizeObserver로 실제 컨테이너 픽셀 크기를 감지하여
숫자·라벨·단위 폰트 크기를 동적으로 계산한다.
기존 고정 @container Tailwind 브레이크포인트 방식 대체.
- 숫자: 컨테이너 높이의 42~62% (표시 요소 수에 따라 조정)
- 너비 기준 35% 캡으로 가로로 매우 넓은 셀도 적절히 제한
- 라벨: 높이의 13%, 단위: 숫자의 40%, 추세: 높이의 9%
- valueFontSize(xs/sm/base/lg/xl)는 전체 배율로 계속 동작
This commit is contained in:
SeongHyun Kim 2026-03-26 15:03:47 +09:00
parent 761100a176
commit cda7e7bbfe
1 changed files with 91 additions and 60 deletions

View File

@ -4,11 +4,11 @@
* KPI
*
* + +
* CSS Container Query로
* itemStyle.valueFontSize
* ResizeObserver로
* itemStyle.valueFontSize
*/
import React from "react";
import React, { useRef, useEffect, useState } from "react";
import type { DashboardItem, FontSize } from "../../types";
import { TEXT_ALIGN_CLASSES } from "../../types";
import { abbreviateNumber } from "../utils/formula";
@ -24,52 +24,16 @@ export interface KpiCardProps {
formulaDisplay?: string | null;
}
// ===== valueFontSize → Tailwind 클래스 매핑 =====
// ===== valueFontSize → 크기 배율 =====
const VALUE_FONT_SIZE_CLASSES: Record<FontSize, string> = {
xs: "text-2xl font-bold @[200px]:text-3xl @[350px]:text-4xl",
sm: "text-3xl font-bold @[200px]:text-4xl @[350px]:text-5xl",
base: "text-4xl font-bold @[200px]:text-5xl @[350px]:text-6xl",
lg: "text-5xl font-bold @[200px]:text-6xl @[350px]:text-7xl",
xl: "text-6xl font-bold @[200px]:text-7xl @[350px]:text-8xl",
const FONT_SIZE_SCALE: Record<FontSize, number> = {
xs: 0.65,
sm: 0.80,
base: 1.0,
lg: 1.25,
xl: 1.55,
};
const UNIT_FONT_SIZE_CLASSES: Record<FontSize, string> = {
xs: "text-sm text-muted-foreground",
sm: "text-base text-muted-foreground",
base: "text-lg text-muted-foreground",
lg: "text-xl text-muted-foreground",
xl: "text-2xl text-muted-foreground",
};
const LABEL_FONT_SIZE_CLASSES: Record<FontSize, string> = {
xs: "text-xs @[250px]:text-sm",
sm: "text-sm @[250px]:text-base",
base: "text-base @[250px]:text-lg",
lg: "text-lg @[250px]:text-xl",
xl: "text-xl @[250px]:text-2xl",
};
// ===== 증감 표시 =====
function TrendIndicator({ value }: { value: number }) {
const isPositive = value > 0;
const isZero = value === 0;
const color = isPositive
? "text-emerald-600"
: isZero
? "text-muted-foreground"
: "text-rose-600";
const arrow = isPositive ? "↑" : isZero ? "→" : "↓";
return (
<span className={`inline-flex items-center gap-0.5 text-xs font-medium ${color}`}>
<span>{arrow}</span>
<span>{Math.abs(value).toFixed(1)}%</span>
</span>
);
}
// ===== 색상 구간 판정 =====
function getColorForValue(
@ -93,8 +57,9 @@ export function KpiCardComponent({
const displayValue = data ?? 0;
const valueColor = getColorForValue(displayValue, kpiConfig?.colorRanges);
// valueFontSize 설정 (기본: base)
// valueFontSize 배율 (기본: base = 1.0)
const valueFontSize: FontSize = itemStyle?.valueFontSize ?? "base";
const scale = FONT_SIZE_SCALE[valueFontSize];
// 라벨 정렬
const labelAlignClass = TEXT_ALIGN_CLASSES[itemStyle?.labelAlign ?? "center"];
@ -102,28 +67,82 @@ export function KpiCardComponent({
// 단위: 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 className="@container flex h-full w-full flex-col items-center justify-center p-3">
{/* 라벨 - 사용자 정렬 적용 */}
{visibility.showLabel && (
<p className={`w-full text-muted-foreground ${LABEL_FONT_SIZE_CLASSES[valueFontSize]} ${labelAlignClass}`}>
<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>
)}
{/* 메인 값 - valueFontSize 반응형 */}
{/* 메인 값 */}
{visibility.showValue && (
<div className="flex items-baseline gap-1">
<div className="flex items-baseline gap-[0.15em]">
<span
className={VALUE_FONT_SIZE_CLASSES[valueFontSize]}
style={valueColor ? { color: valueColor } : undefined}
className="font-bold leading-none"
style={{
fontSize: `${valueFontPx}px`,
...(valueColor ? { color: valueColor } : {}),
}}
>
{formulaDisplay ?? abbreviateNumber(displayValue)}
</span>
{/* 단위 */}
{visibility.showUnit && unit && (
<span className={UNIT_FONT_SIZE_CLASSES[valueFontSize]}>
<span
className="text-muted-foreground font-medium"
style={{ fontSize: `${unitFontPx}px` }}
>
{unit}
</span>
)}
@ -131,13 +150,25 @@ export function KpiCardComponent({
)}
{/* 증감율 */}
{visibility.showTrend && trendValue != null && (
<TrendIndicator value={trendValue} />
{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>
)}
{/* 보조 라벨 (수식 표시 등) */}
{visibility.showSubLabel && formulaDisplay && (
<p className="text-xs text-muted-foreground @[200px]:text-sm">
{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>
)}