312 lines
7.0 KiB
TypeScript
312 lines
7.0 KiB
TypeScript
/**
|
|
* 조건부 서식 유틸리티
|
|
* 셀 값에 따른 스타일 계산
|
|
*/
|
|
|
|
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<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: 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;
|
|
|