diff --git a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx index aa02abfc..e3eaa4af 100644 --- a/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx +++ b/frontend/lib/registry/pop-components/pop-dashboard/items/KpiCard.tsx @@ -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 = { - 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 = { + xs: 0.65, + sm: 0.80, + base: 1.0, + lg: 1.25, + xl: 1.55, }; -const UNIT_FONT_SIZE_CLASSES: Record = { - 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 = { - 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 ( - - {arrow} - {Math.abs(value).toFixed(1)}% - - ); -} - // ===== 색상 구간 판정 ===== 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(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 ( -
- {/* 라벨 - 사용자 정렬 적용 */} - {visibility.showLabel && ( -

+

+ {/* 라벨 */} + {hasLabel && ( +

{item.label}

)} - {/* 메인 값 - valueFontSize 반응형 */} + {/* 메인 값 */} {visibility.showValue && ( -
+
{formulaDisplay ?? abbreviateNumber(displayValue)} {/* 단위 */} {visibility.showUnit && unit && ( - + {unit} )} @@ -131,13 +150,25 @@ export function KpiCardComponent({ )} {/* 증감율 */} - {visibility.showTrend && trendValue != null && ( - + {hasTrend && trendValue != null && ( + 0 ? "var(--color-emerald-600)" : trendValue === 0 ? "var(--muted-foreground)" : "var(--color-rose-600)", + }} + > + {trendValue > 0 ? "↑" : trendValue === 0 ? "→" : "↓"} + {Math.abs(trendValue).toFixed(1)}% + )} {/* 보조 라벨 (수식 표시 등) */} - {visibility.showSubLabel && formulaDisplay && ( -

+ {hasSubLabel && ( +

{item.formula?.values.map((v) => v.label).join(" / ")}

)}