feat: KPI 카드 글자 크기를 컨테이너 크기에 비례하도록 개선
ResizeObserver로 실제 컨테이너 픽셀 크기를 감지하여 숫자·라벨·단위 폰트 크기를 동적으로 계산한다. 기존 고정 @container Tailwind 브레이크포인트 방식 대체. - 숫자: 컨테이너 높이의 42~62% (표시 요소 수에 따라 조정) - 너비 기준 35% 캡으로 가로로 매우 넓은 셀도 적절히 제한 - 라벨: 높이의 13%, 단위: 숫자의 40%, 추세: 높이의 9% - valueFontSize(xs/sm/base/lg/xl)는 전체 배율로 계속 동작
This commit is contained in:
parent
761100a176
commit
cda7e7bbfe
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue