260 lines
6.4 KiB
TypeScript
260 lines
6.4 KiB
TypeScript
|
|
/**
|
||
|
|
* pop-dashboard 수식 파싱 및 평가 유틸리티
|
||
|
|
*
|
||
|
|
* 보안: eval()/new Function() 사용 금지. 직접 사칙연산 파서 구현.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import type { FormulaConfig, FormulaDisplayFormat } from "../../types";
|
||
|
|
|
||
|
|
// ===== 토큰 타입 =====
|
||
|
|
|
||
|
|
type TokenType = "number" | "variable" | "operator" | "lparen" | "rparen";
|
||
|
|
|
||
|
|
interface Token {
|
||
|
|
type: TokenType;
|
||
|
|
value: string;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== 토크나이저 =====
|
||
|
|
|
||
|
|
/** 수식 문자열을 토큰 배열로 분리 */
|
||
|
|
function tokenize(expression: string): Token[] {
|
||
|
|
const tokens: Token[] = [];
|
||
|
|
let i = 0;
|
||
|
|
const expr = expression.replace(/\s+/g, "");
|
||
|
|
|
||
|
|
while (i < expr.length) {
|
||
|
|
const ch = expr[i];
|
||
|
|
|
||
|
|
// 숫자 (정수, 소수)
|
||
|
|
if (/\d/.test(ch) || (ch === "." && i + 1 < expr.length && /\d/.test(expr[i + 1]))) {
|
||
|
|
let num = "";
|
||
|
|
while (i < expr.length && (/\d/.test(expr[i]) || expr[i] === ".")) {
|
||
|
|
num += expr[i];
|
||
|
|
i++;
|
||
|
|
}
|
||
|
|
tokens.push({ type: "number", value: num });
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 변수 (A, B, C 등 알파벳)
|
||
|
|
if (/[A-Za-z]/.test(ch)) {
|
||
|
|
let varName = "";
|
||
|
|
while (i < expr.length && /[A-Za-z0-9_]/.test(expr[i])) {
|
||
|
|
varName += expr[i];
|
||
|
|
i++;
|
||
|
|
}
|
||
|
|
tokens.push({ type: "variable", value: varName });
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 연산자
|
||
|
|
if ("+-*/".includes(ch)) {
|
||
|
|
tokens.push({ type: "operator", value: ch });
|
||
|
|
i++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 괄호
|
||
|
|
if (ch === "(") {
|
||
|
|
tokens.push({ type: "lparen", value: "(" });
|
||
|
|
i++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
if (ch === ")") {
|
||
|
|
tokens.push({ type: "rparen", value: ")" });
|
||
|
|
i++;
|
||
|
|
continue;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 알 수 없는 문자는 건너뜀
|
||
|
|
i++;
|
||
|
|
}
|
||
|
|
|
||
|
|
return tokens;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== 재귀 하강 파서 =====
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 사칙연산 수식을 안전하게 평가 (재귀 하강 파서)
|
||
|
|
*
|
||
|
|
* 문법:
|
||
|
|
* expr = term (('+' | '-') term)*
|
||
|
|
* term = factor (('*' | '/') factor)*
|
||
|
|
* factor = NUMBER | VARIABLE | '(' expr ')'
|
||
|
|
*
|
||
|
|
* @param expression - 수식 문자열 (예: "A / B * 100")
|
||
|
|
* @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
|
||
|
|
* @returns 계산 결과 (0으로 나누기 시 0 반환)
|
||
|
|
*/
|
||
|
|
export function evaluateFormula(
|
||
|
|
expression: string,
|
||
|
|
values: Record<string, number>
|
||
|
|
): number {
|
||
|
|
const tokens = tokenize(expression);
|
||
|
|
let pos = 0;
|
||
|
|
|
||
|
|
function peek(): Token | undefined {
|
||
|
|
return tokens[pos];
|
||
|
|
}
|
||
|
|
|
||
|
|
function consume(): Token {
|
||
|
|
return tokens[pos++];
|
||
|
|
}
|
||
|
|
|
||
|
|
// factor = NUMBER | VARIABLE | '(' expr ')'
|
||
|
|
function parseFactor(): number {
|
||
|
|
const token = peek();
|
||
|
|
if (!token) return 0;
|
||
|
|
|
||
|
|
if (token.type === "number") {
|
||
|
|
consume();
|
||
|
|
return parseFloat(token.value);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (token.type === "variable") {
|
||
|
|
consume();
|
||
|
|
return values[token.value] ?? 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
if (token.type === "lparen") {
|
||
|
|
consume(); // '(' 소비
|
||
|
|
const result = parseExpr();
|
||
|
|
if (peek()?.type === "rparen") {
|
||
|
|
consume(); // ')' 소비
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
// 예상치 못한 토큰
|
||
|
|
consume();
|
||
|
|
return 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
// term = factor (('*' | '/') factor)*
|
||
|
|
function parseTerm(): number {
|
||
|
|
let result = parseFactor();
|
||
|
|
while (peek()?.type === "operator" && (peek()!.value === "*" || peek()!.value === "/")) {
|
||
|
|
const op = consume().value;
|
||
|
|
const right = parseFactor();
|
||
|
|
if (op === "*") {
|
||
|
|
result *= right;
|
||
|
|
} else {
|
||
|
|
// 0으로 나누기 방지
|
||
|
|
result = right === 0 ? 0 : result / right;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
// expr = term (('+' | '-') term)*
|
||
|
|
function parseExpr(): number {
|
||
|
|
let result = parseTerm();
|
||
|
|
while (peek()?.type === "operator" && (peek()!.value === "+" || peek()!.value === "-")) {
|
||
|
|
const op = consume().value;
|
||
|
|
const right = parseTerm();
|
||
|
|
result = op === "+" ? result + right : result - right;
|
||
|
|
}
|
||
|
|
return result;
|
||
|
|
}
|
||
|
|
|
||
|
|
const result = parseExpr();
|
||
|
|
return Number.isFinite(result) ? result : 0;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 수식 결과를 displayFormat에 맞게 포맷팅
|
||
|
|
*
|
||
|
|
* @param config - 수식 설정
|
||
|
|
* @param values - 변수값 맵 (예: { A: 1234, B: 5678 })
|
||
|
|
* @returns 포맷된 문자열
|
||
|
|
*/
|
||
|
|
export function formatFormulaResult(
|
||
|
|
config: FormulaConfig,
|
||
|
|
values: Record<string, number>
|
||
|
|
): string {
|
||
|
|
const formatMap: Record<FormulaDisplayFormat, () => string> = {
|
||
|
|
value: () => {
|
||
|
|
const result = evaluateFormula(config.expression, values);
|
||
|
|
return formatNumber(result);
|
||
|
|
},
|
||
|
|
fraction: () => {
|
||
|
|
// "1,234 / 5,678" 형태
|
||
|
|
const ids = config.values.map((v) => v.id);
|
||
|
|
if (ids.length >= 2) {
|
||
|
|
return `${formatNumber(values[ids[0]] ?? 0)} / ${formatNumber(values[ids[1]] ?? 0)}`;
|
||
|
|
}
|
||
|
|
return formatNumber(evaluateFormula(config.expression, values));
|
||
|
|
},
|
||
|
|
percent: () => {
|
||
|
|
const result = evaluateFormula(config.expression, values);
|
||
|
|
return `${(result * 100).toFixed(1)}%`;
|
||
|
|
},
|
||
|
|
ratio: () => {
|
||
|
|
// "1,234 : 5,678" 형태
|
||
|
|
const ids = config.values.map((v) => v.id);
|
||
|
|
if (ids.length >= 2) {
|
||
|
|
return `${formatNumber(values[ids[0]] ?? 0)} : ${formatNumber(values[ids[1]] ?? 0)}`;
|
||
|
|
}
|
||
|
|
return formatNumber(evaluateFormula(config.expression, values));
|
||
|
|
},
|
||
|
|
};
|
||
|
|
|
||
|
|
return formatMap[config.displayFormat]();
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 수식에 사용된 변수 ID가 모두 존재하는지 검증
|
||
|
|
*
|
||
|
|
* @param expression - 수식 문자열
|
||
|
|
* @param availableIds - 사용 가능한 변수 ID 배열
|
||
|
|
* @returns 유효 여부
|
||
|
|
*/
|
||
|
|
export function validateExpression(
|
||
|
|
expression: string,
|
||
|
|
availableIds: string[]
|
||
|
|
): boolean {
|
||
|
|
const tokens = tokenize(expression);
|
||
|
|
const usedVars = tokens
|
||
|
|
.filter((t) => t.type === "variable")
|
||
|
|
.map((t) => t.value);
|
||
|
|
|
||
|
|
return usedVars.every((v) => availableIds.includes(v));
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* 큰 숫자 축약 (Container Query 축소 시 사용)
|
||
|
|
*
|
||
|
|
* 1234 -> "1,234"
|
||
|
|
* 12345 -> "1.2만"
|
||
|
|
* 1234567 -> "123.5만"
|
||
|
|
* 123456789 -> "1.2억"
|
||
|
|
*/
|
||
|
|
export function abbreviateNumber(value: number): string {
|
||
|
|
const abs = Math.abs(value);
|
||
|
|
const sign = value < 0 ? "-" : "";
|
||
|
|
|
||
|
|
if (abs >= 100_000_000) {
|
||
|
|
return `${sign}${(abs / 100_000_000).toFixed(1)}억`;
|
||
|
|
}
|
||
|
|
if (abs >= 10_000) {
|
||
|
|
return `${sign}${(abs / 10_000).toFixed(1)}만`;
|
||
|
|
}
|
||
|
|
return `${sign}${formatNumber(abs)}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
// ===== 내부 헬퍼 =====
|
||
|
|
|
||
|
|
/** 숫자를 천 단위 콤마 포맷 */
|
||
|
|
function formatNumber(value: number): string {
|
||
|
|
if (Number.isInteger(value)) {
|
||
|
|
return value.toLocaleString("ko-KR");
|
||
|
|
}
|
||
|
|
// 소수점 이하 최대 2자리
|
||
|
|
return value.toLocaleString("ko-KR", {
|
||
|
|
minimumFractionDigits: 0,
|
||
|
|
maximumFractionDigits: 2,
|
||
|
|
});
|
||
|
|
}
|