Files
invyone/frontend/lib/registry/components/table/utils/pivot/conditionalFormat.ts
T
DDD1542 49b4cdf562 feat: 테이블 pivot 모드 본체 통째 흡수 (T3b)
5 viewMode 통합 세번째 단계 — Codex 권고 (Pivot 통째 흡수, 회귀 위험
차단) 그대로 적용. v2-pivot-grid 의 본체 + utils + components + hooks +
보조 타입을 모두 table/ 하위로 이전. 리팩토링 X (V2Date picker 패턴 동일).

table/types.ts 보조 타입 흡수
- PivotResult, PivotHeaderNode, PivotFlatRow, PivotFlatColumn, PivotCellValue,
  PivotCellData, PivotGridState, PivotGridProps
- PivotDataSourceType, PivotFilterCondition, PivotJoinConfig,
  PivotDataSourceConfig, PivotTotalsConfig, PivotFieldChooserConfig,
  PivotChartConfig, PivotConditionalFormatRule, PivotStyleConfig,
  PivotExportConfig
- PivotFieldConfig 에 filterValues / filterType / isCalculated /
  calculateFormula 누락 속성 추가
- TableConfig 에 pivot 보조 키 (pivotTotals / pivotStyle /
  pivotFieldChooser / pivotChart / pivotExportConfig)

table/utils/pivot/ 4개 파일 이전 (1505줄)
- pivotEngine.ts (812) — processPivotData / pathToKey / 헤더 트리 / 매트릭스 /
  10종 displayMode (runningTotal, percentDifferenceFromPrevious 등)
- aggregation.ts (180) — sum/count/avg/min/max/countDistinct + 포맷
- conditionalFormat.ts (311) — colorScale/dataBar/iconSet/cellValue 4 종
- exportExcel.ts (202) — Excel 내보내기 (xlsx)
- 옛 prefix(AggregationType 등) → Pivot prefix 일괄 정리

table/internals/pivot/components/ 7개 파일 이전 (2347줄)
- ContextMenu / DrillDownModal / FieldChooser / FieldPanel / FilterPopup /
  PivotChart / index

table/internals/pivot/hooks/ 3개 파일 이전 (570줄)
- usePivotState / useVirtualScroll / index

table/views/PivotView.tsx 신규 (PivotGridComponent.tsx 1963줄 통째 흡수)
- import 경로 일괄 정정 (../../types → ../types, ./utils → ../utils/pivot,
  ./components → ../internals/pivot/components, ./hooks →
  ../internals/pivot/hooks)
- 컴포넌트 이름 PivotGridComponent → PivotView
- 본체 로직 그대로 (리팩토링 X)

TableComponent.switch
- pivot 분기 placeholder 제거 → PivotView 호출
- DOM filter 에 pivotTotals/pivotStyle/pivotFieldChooser/pivotChart/
  pivotExportConfig 추가

13 files, +5400+ insertions. v2-pivot-grid/ 폴더 자체는 Phase T5 dead code
일괄 삭제에서 정리 예정.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-29 14:23:39 +09:00

312 lines
7.1 KiB
TypeScript

/**
* 조건부 서식 유틸리티
* 셀 값에 따른 스타일 계산
*/
import { PivotConditionalFormatRule } from "../../types";
// ==================== 타입 ====================
export interface CellFormatStyle {
backgroundColor?: string;
textColor?: string;
fontWeight?: string;
dataBarWidth?: number; // 0-100%
dataBarColor?: string;
icon?: string; // 이모지 또는 아이콘 이름
}
// ==================== 색상 유틸리티 ====================
/**
* HEX 색상을 RGB로 변환
*/
function hexToRgb(hex: string): { r: number; g: number; b: number } | null {
const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
return result
? {
r: parseInt(result[1], 16),
g: parseInt(result[2], 16),
b: parseInt(result[3], 16),
}
: null;
}
/**
* RGB를 HEX로 변환
*/
function rgbToHex(r: number, g: number, b: number): string {
return (
"#" +
[r, g, b]
.map((x) => {
const hex = Math.round(x).toString(16);
return hex.length === 1 ? "0" + hex : hex;
})
.join("")
);
}
/**
* 두 색상 사이의 보간
*/
function interpolateColor(
color1: string,
color2: string,
factor: number
): string {
const rgb1 = hexToRgb(color1);
const rgb2 = hexToRgb(color2);
if (!rgb1 || !rgb2) return color1;
const r = rgb1.r + (rgb2.r - rgb1.r) * factor;
const g = rgb1.g + (rgb2.g - rgb1.g) * factor;
const b = rgb1.b + (rgb2.b - rgb1.b) * factor;
return rgbToHex(r, g, b);
}
// ==================== 조건부 서식 계산 ====================
/**
* Color Scale 스타일 계산
*/
function applyColorScale(
value: number,
minValue: number,
maxValue: number,
rule: PivotConditionalFormatRule
): CellFormatStyle {
if (!rule.colorScale) return {};
const { minColor, midColor, maxColor } = rule.colorScale;
const range = maxValue - minValue;
if (range === 0) {
return { backgroundColor: minColor };
}
const normalizedValue = (value - minValue) / range;
let backgroundColor: string;
if (midColor) {
// 3색 그라데이션
if (normalizedValue <= 0.5) {
backgroundColor = interpolateColor(minColor, midColor, normalizedValue * 2);
} else {
backgroundColor = interpolateColor(midColor, maxColor, (normalizedValue - 0.5) * 2);
}
} else {
// 2색 그라데이션
backgroundColor = interpolateColor(minColor, maxColor, normalizedValue);
}
// 배경색에 따른 텍스트 색상 결정
const rgb = hexToRgb(backgroundColor);
const textColor =
rgb && rgb.r * 0.299 + rgb.g * 0.587 + rgb.b * 0.114 > 186
? "#000000"
: "#ffffff";
return { backgroundColor, textColor };
}
/**
* Data Bar 스타일 계산
*/
function applyDataBar(
value: number,
minValue: number,
maxValue: number,
rule: PivotConditionalFormatRule
): CellFormatStyle {
if (!rule.dataBar) return {};
const { color, minValue: ruleMin, maxValue: ruleMax } = rule.dataBar;
const min = ruleMin ?? minValue;
const max = ruleMax ?? maxValue;
const range = max - min;
if (range === 0) {
return { dataBarWidth: 100, dataBarColor: color };
}
const width = Math.max(0, Math.min(100, ((value - min) / range) * 100));
return {
dataBarWidth: width,
dataBarColor: color,
};
}
/**
* Icon Set 스타일 계산
*/
function applyIconSet(
value: number,
minValue: number,
maxValue: number,
rule: PivotConditionalFormatRule
): CellFormatStyle {
if (!rule.iconSet) return {};
const { type, thresholds, reverse } = rule.iconSet;
const range = maxValue - minValue;
const percentage = range === 0 ? 100 : ((value - minValue) / range) * 100;
// 아이콘 정의
const iconSets: Record<string, string[]> = {
arrows: ["↓", "→", "↑"],
traffic: ["🔴", "🟡", "🟢"],
rating: ["⭐", "⭐⭐", "⭐⭐⭐"],
flags: ["🚩", "🏳️", "🏁"],
};
const icons = iconSets[type] || iconSets.arrows;
const sortedIcons = reverse ? [...icons].reverse() : icons;
// 임계값에 따른 아이콘 선택
let iconIndex = 0;
for (let i = 0; i < thresholds.length; i++) {
if (percentage >= thresholds[i]) {
iconIndex = i + 1;
}
}
iconIndex = Math.min(iconIndex, sortedIcons.length - 1);
return {
icon: sortedIcons[iconIndex],
};
}
/**
* Cell Value 조건 스타일 계산
*/
function applyCellValue(
value: number,
rule: PivotConditionalFormatRule
): CellFormatStyle {
if (!rule.cellValue) return {};
const { operator, value1, value2, backgroundColor, textColor, bold } =
rule.cellValue;
let matches = false;
switch (operator) {
case ">":
matches = value > value1;
break;
case ">=":
matches = value >= value1;
break;
case "<":
matches = value < value1;
break;
case "<=":
matches = value <= value1;
break;
case "=":
matches = value === value1;
break;
case "!=":
matches = value !== value1;
break;
case "between":
matches = value2 !== undefined && value >= value1 && value <= value2;
break;
}
if (!matches) return {};
return {
backgroundColor,
textColor,
fontWeight: bold ? "bold" : undefined,
};
}
// ==================== 메인 함수 ====================
/**
* 조건부 서식 적용
*/
export function getConditionalStyle(
value: number | null | undefined,
field: string,
rules: PivotConditionalFormatRule[],
allValues: number[] // 해당 필드의 모든 값 (min/max 계산용)
): CellFormatStyle {
if (value === null || value === undefined || isNaN(value)) {
return {};
}
if (!rules || rules.length === 0) {
return {};
}
// min/max 계산
const numericValues = allValues.filter((v) => !isNaN(v));
const minValue = Math.min(...numericValues);
const maxValue = Math.max(...numericValues);
let resultStyle: CellFormatStyle = {};
// 해당 필드에 적용되는 규칙 필터링 및 적용
for (const rule of rules) {
// 필드 필터 확인
if (rule.field && rule.field !== field) {
continue;
}
let ruleStyle: CellFormatStyle = {};
switch (rule.type) {
case "colorScale":
ruleStyle = applyColorScale(value, minValue, maxValue, rule);
break;
case "dataBar":
ruleStyle = applyDataBar(value, minValue, maxValue, rule);
break;
case "iconSet":
ruleStyle = applyIconSet(value, minValue, maxValue, rule);
break;
case "cellValue":
ruleStyle = applyCellValue(value, rule);
break;
}
// 스타일 병합 (나중 규칙이 우선)
resultStyle = { ...resultStyle, ...ruleStyle };
}
return resultStyle;
}
/**
* 조건부 서식 스타일을 React 스타일 객체로 변환
*/
export function formatStyleToReact(
style: CellFormatStyle
): React.CSSProperties {
const result: React.CSSProperties = {};
if (style.backgroundColor) {
result.backgroundColor = style.backgroundColor;
}
if (style.textColor) {
result.color = style.textColor;
}
if (style.fontWeight) {
result.fontWeight = style.fontWeight as any;
}
return result;
}
export default getConditionalStyle;