From cda7e7bbfe791e4bf77cbcabcf1e6734eb5d0457 Mon Sep 17 00:00:00 2001 From: SeongHyun Kim Date: Thu, 26 Mar 2026 15:03:47 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20KPI=20=EC=B9=B4=EB=93=9C=20=EA=B8=80?= =?UTF-8?q?=EC=9E=90=20=ED=81=AC=EA=B8=B0=EB=A5=BC=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=ED=81=AC=EA=B8=B0=EC=97=90=20=EB=B9=84?= =?UTF-8?q?=EB=A1=80=ED=95=98=EB=8F=84=EB=A1=9D=20=EA=B0=9C=EC=84=A0=20Res?= =?UTF-8?q?izeObserver=EB=A1=9C=20=EC=8B=A4=EC=A0=9C=20=EC=BB=A8=ED=85=8C?= =?UTF-8?q?=EC=9D=B4=EB=84=88=20=ED=94=BD=EC=85=80=20=ED=81=AC=EA=B8=B0?= =?UTF-8?q?=EB=A5=BC=20=EA=B0=90=EC=A7=80=ED=95=98=EC=97=AC=20=EC=88=AB?= =?UTF-8?q?=EC=9E=90=C2=B7=EB=9D=BC=EB=B2=A8=C2=B7=EB=8B=A8=EC=9C=84=20?= =?UTF-8?q?=ED=8F=B0=ED=8A=B8=20=ED=81=AC=EA=B8=B0=EB=A5=BC=20=EB=8F=99?= =?UTF-8?q?=EC=A0=81=EC=9C=BC=EB=A1=9C=20=EA=B3=84=EC=82=B0=ED=95=9C?= =?UTF-8?q?=EB=8B=A4.=20=EA=B8=B0=EC=A1=B4=20=EA=B3=A0=EC=A0=95=20@contain?= =?UTF-8?q?er=20Tailwind=20=EB=B8=8C=EB=A0=88=EC=9D=B4=ED=81=AC=ED=8F=AC?= =?UTF-8?q?=EC=9D=B8=ED=8A=B8=20=EB=B0=A9=EC=8B=9D=20=EB=8C=80=EC=B2=B4.?= =?UTF-8?q?=20-=20=EC=88=AB=EC=9E=90:=20=EC=BB=A8=ED=85=8C=EC=9D=B4?= =?UTF-8?q?=EB=84=88=20=EB=86=92=EC=9D=B4=EC=9D=98=2042~62%=20(=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=9A=94=EC=86=8C=20=EC=88=98=EC=97=90=20=EB=94=B0?= =?UTF-8?q?=EB=9D=BC=20=EC=A1=B0=EC=A0=95)=20-=20=EB=84=88=EB=B9=84=20?= =?UTF-8?q?=EA=B8=B0=EC=A4=80=2035%=20=EC=BA=A1=EC=9C=BC=EB=A1=9C=20?= =?UTF-8?q?=EA=B0=80=EB=A1=9C=EB=A1=9C=20=EB=A7=A4=EC=9A=B0=20=EB=84=93?= =?UTF-8?q?=EC=9D=80=20=EC=85=80=EB=8F=84=20=EC=A0=81=EC=A0=88=ED=9E=88=20?= =?UTF-8?q?=EC=A0=9C=ED=95=9C=20-=20=EB=9D=BC=EB=B2=A8:=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=EC=9D=98=2013%,=20=EB=8B=A8=EC=9C=84:=20=EC=88=AB?= =?UTF-8?q?=EC=9E=90=EC=9D=98=2040%,=20=EC=B6=94=EC=84=B8:=20=EB=86=92?= =?UTF-8?q?=EC=9D=B4=EC=9D=98=209%=20-=20valueFontSize(xs/sm/base/lg/xl)?= =?UTF-8?q?=EB=8A=94=20=EC=A0=84=EC=B2=B4=20=EB=B0=B0=EC=9C=A8=EB=A1=9C=20?= =?UTF-8?q?=EA=B3=84=EC=86=8D=20=EB=8F=99=EC=9E=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../pop-dashboard/items/KpiCard.tsx | 151 +++++++++++------- 1 file changed, 91 insertions(+), 60 deletions(-) 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(" / ")}

)}