/** * 조건부 서식 유틸리티 * 셀 값에 따른 스타일 계산 */ import { ConditionalFormatRule } 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: ConditionalFormatRule ): 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: ConditionalFormatRule ): 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: ConditionalFormatRule ): 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 = { 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: ConditionalFormatRule ): 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: ConditionalFormatRule[], 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;